From 7cbf3bed9666ce6c185fe870f4a67ecfb1b85f1a Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 22 Aug 2026 11:41:45 +0200 Subject: [PATCH] feat(tickets): deliver canonical operational lifecycle --- README.md | 29 +- docs/TICKETS_DOMAIN_BOUNDARY.md | 46 +- package.json | 4 +- pyproject.toml | 6 +- src/govoplan_tickets/backend/db/__init__.py | 8 + src/govoplan_tickets/backend/db/models.py | 123 ++ src/govoplan_tickets/backend/domain.py | 388 ++++++ src/govoplan_tickets/backend/dsar_provider.py | 287 ++++ src/govoplan_tickets/backend/manifest.py | 358 ++++- .../backend/migrations/__init__.py | 1 + ...1f4b7a2c5e_v0120_tickets_vertical_slice.py | 140 ++ .../backend/migrations/versions/__init__.py | 1 + src/govoplan_tickets/backend/router.py | 445 +++++++ src/govoplan_tickets/backend/schemas.py | 111 ++ src/govoplan_tickets/backend/search_source.py | 212 +++ src/govoplan_tickets/backend/service.py | 1161 +++++++++++++++++ tests/test_manifest.py | 75 +- tests/test_ticket_service.py | 367 ++++++ webui/package.json | 31 + webui/scripts/test-interface-pattern.mjs | 18 + webui/src/api/tickets.ts | 155 +++ webui/src/features/tickets/TicketsPage.tsx | 595 +++++++++ webui/src/index.ts | 1 + webui/src/module.ts | 49 + webui/src/styles/tickets.css | 237 ++++ 25 files changed, 4742 insertions(+), 106 deletions(-) create mode 100644 src/govoplan_tickets/backend/db/__init__.py create mode 100644 src/govoplan_tickets/backend/db/models.py create mode 100644 src/govoplan_tickets/backend/domain.py create mode 100644 src/govoplan_tickets/backend/dsar_provider.py create mode 100644 src/govoplan_tickets/backend/migrations/__init__.py create mode 100644 src/govoplan_tickets/backend/migrations/versions/8d1f4b7a2c5e_v0120_tickets_vertical_slice.py create mode 100644 src/govoplan_tickets/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_tickets/backend/router.py create mode 100644 src/govoplan_tickets/backend/schemas.py create mode 100644 src/govoplan_tickets/backend/search_source.py create mode 100644 src/govoplan_tickets/backend/service.py create mode 100644 tests/test_ticket_service.py create mode 100644 webui/package.json create mode 100644 webui/scripts/test-interface-pattern.mjs create mode 100644 webui/src/api/tickets.ts create mode 100644 webui/src/features/tickets/TicketsPage.tsx create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/tickets.css diff --git a/README.md b/README.md index 58fade0..1853af7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,11 @@ resolution. Its runtime module ID is `tickets`. -This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation. +The module now provides the first canonical vertical slice: tenant-safe ticket +persistence, guarded lifecycle actions, distinct report/triage/assignment/ +resolution permissions, participants, typed links and attachment references, +comments, immutable revision history, Search contributions, soft deletion, and +optional Helpdesk-routing and Case-escalation capability boundaries. ## Initial Ownership @@ -52,6 +56,24 @@ Expected optional integrations: - notifications - search +Optional providers are discovered through Core contracts. Tickets remains +usable when they are absent: queue and service-target selection becomes manual, +Case escalation is disabled, file attachments remain typed references, and +global Search indexing is unavailable. The API and WebUI expose these +consequences rather than silently hiding actions. + +## Runtime Surface + +- `/api/v1/tickets` for reporting, listing, and tenant-safe discovery +- guarded triage, assignment, participant, link, comment, resolution, and + Case-escalation actions +- immutable `/history` evidence and optimistic revision checks +- `/availability` diagnostics for optional integrations +- `/tickets` queue/detail WebUI using shared workspace and dialog primitives +- `tickets.registry` and `privacy.dsar.tickets` provider capabilities +- `tickets.tickets` Search source with backfill, authorization rechecks, and + idempotent event changes + ## Development Install From the core checkout: @@ -61,11 +83,12 @@ cd /mnt/DATA/git/govoplan-core ./.venv/bin/python -m pip install -e ../govoplan-tickets ``` -Focused manifest verification: +Focused verification: ```bash cd /mnt/DATA/git/govoplan-tickets -PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests +PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests +cd webui && node scripts/test-interface-pattern.mjs ``` ## Gitea Workflow diff --git a/docs/TICKETS_DOMAIN_BOUNDARY.md b/docs/TICKETS_DOMAIN_BOUNDARY.md index e9eb19f..4fb7773 100644 --- a/docs/TICKETS_DOMAIN_BOUNDARY.md +++ b/docs/TICKETS_DOMAIN_BOUNDARY.md @@ -59,20 +59,42 @@ service history; the case becomes authoritative for the formal procedure. The former Issue Reporting and Helpdesk concepts become ticket type, intake, queue, and policy profiles. They do not need separate persistence models. -## Seed State +## Implemented Vertical Slice -The current repository state is intentionally small: +Tickets is now a native authoritative operational store with: -- module manifest and entry point -- tenant-level permission definitions -- manager and viewer role templates -- documentation topic describing the module boundary -- Gitea issue workflow templates -- manifest contract test +- tenant-isolated ticket identity and current state +- replay-safe reporting and guarded revision mutations +- distinct reporting, triage, assignment, resolution, and administration scopes +- reporter, requester, assignee, and typed participant references +- related-work and attachment references without copying owner-module content +- internal and reporter-visible comments +- immutable lifecycle history with actor, time, reason, revision, and request digest +- soft deletion that removes ordinary discovery while retaining evidence +- Search backfill, authorization rechecks, and event-driven index changes +- bounded data-subject export with manual retention review -No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear. +The `/tickets` WebUI and `/api/v1/tickets` routes expose this lifecycle. All +mutations carry an expected revision and an idempotency key. Resolved and closed +tickets require a resolution summary; reopening continues the same operational +record. -## First Implementation Slice +## Optional Providers -Define ticket identity, intake profiles, queues, triage, assignment, -resolution, and stable escalation links to cases. +Core contracts keep integrations optional and implementation-independent: + +- `tickets.routing` may supply a queue, service target, and routing explanation. + Without it, authorized users set queue and target manually. +- `tickets.case_escalation` may create a replay-safe formal Case and return its + stable reference. Without it, the ticket can still be resolved but the + escalation action is unavailable. + +Cases owns every created Case and its procedure. Helpdesk owns service profiles, +queue semantics, routing, and escalation-clock policy. Tickets stores only the +applied queue/target facts and the stable Case relation. + +## Remaining Provider Work + +- Helpdesk-owned configurable service profiles and routing provider +- Cases-owned concrete `tickets.case_escalation` provider +- connector-owned external service-desk transport and governed synchronization diff --git a/package.json b/package.json index 6124649..7c50a0d 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@govoplan/tickets", - "version": "0.1.19", + "version": "0.1.20", "private": true, - "description": "GovOPlaN Tickets platform module seed.", + "description": "Canonical GovOPlaN operational ticket lifecycle module.", "type": "module", "peerDependencies": {} } diff --git a/pyproject.toml b/pyproject.toml index 9747423..42c6300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-tickets" -version = "0.1.19" -description = "GovOPlaN Tickets platform module seed." +version = "0.1.20" +description = "Canonical GovOPlaN operational ticket lifecycle module." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.18", + "govoplan-core>=0.1.30", "govoplan-access>=0.1.18", ] diff --git a/src/govoplan_tickets/backend/db/__init__.py b/src/govoplan_tickets/backend/db/__init__.py new file mode 100644 index 0000000..0bfc898 --- /dev/null +++ b/src/govoplan_tickets/backend/db/__init__.py @@ -0,0 +1,8 @@ +from govoplan_tickets.backend.db.models import ( + Ticket, + TicketComment, + TicketEscalation, + TicketHistory, +) + +__all__ = ["Ticket", "TicketComment", "TicketEscalation", "TicketHistory"] diff --git a/src/govoplan_tickets/backend/db/models.py b/src/govoplan_tickets/backend/db/models.py new file mode 100644 index 0000000..e1ce735 --- /dev/null +++ b/src/govoplan_tickets/backend/db/models.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +import uuid + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class Ticket(Base, TimestampMixin): + __tablename__ = "tickets" + __table_args__ = ( + UniqueConstraint("tenant_id", "ticket_number", name="uq_ticket_number"), + Index("ix_ticket_queue", "tenant_id", "queue_ref", "status", "priority"), + Index("ix_ticket_catalog", "tenant_id", "status", "updated_at"), + Index("ix_ticket_service_target", "tenant_id", "service_target_at", "status"), + ) + + id: Mapped[str] = mapped_column(String(255), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + ticket_number: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + ticket_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + priority: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + status: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + visibility: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + queue_ref: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + assignee: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + reporter: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + requester: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + participants: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list) + links: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list) + metadata_payload: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, nullable=False, default=dict) + search_text: Mapped[str] = mapped_column(Text, nullable=False) + received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + change_reason: Mapped[str] = mapped_column(String(1_000), nullable=False) + service_target_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + resolution_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + updated_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + + +class TicketHistory(Base, TimestampMixin): + __tablename__ = "ticket_history" + __table_args__ = ( + UniqueConstraint("tenant_id", "ticket_id", "revision", name="uq_ticket_history_revision"), + UniqueConstraint("tenant_id", "idempotency_key", name="uq_ticket_history_idempotency"), + Index("ix_ticket_history_timeline", "tenant_id", "ticket_id", "occurred_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + ticket_id: Mapped[str] = mapped_column( + ForeignKey("tickets.id", ondelete="RESTRICT"), nullable=False, index=True + ) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + reason: Mapped[str] = mapped_column(String(1_000), nullable=False) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + details: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) + + +class TicketComment(Base, TimestampMixin): + __tablename__ = "ticket_comments" + __table_args__ = ( + UniqueConstraint("tenant_id", "comment_id", name="uq_ticket_comment"), + Index("ix_ticket_comment_timeline", "tenant_id", "ticket_id", "created_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + ticket_id: Mapped[str] = mapped_column( + ForeignKey("tickets.id", ondelete="RESTRICT"), nullable=False, index=True + ) + comment_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + ticket_revision: Mapped[int] = mapped_column(Integer, nullable=False) + visibility: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + body: Mapped[str] = mapped_column(Text, nullable=False) + created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + + +class TicketEscalation(Base, TimestampMixin): + __tablename__ = "ticket_escalations" + __table_args__ = ( + UniqueConstraint("tenant_id", "ticket_id", "idempotency_key", name="uq_ticket_escalation_replay"), + UniqueConstraint("tenant_id", "provider_id", "case_id", name="uq_ticket_case_link"), + Index("ix_ticket_escalation_timeline", "tenant_id", "ticket_id", "occurred_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + ticket_id: Mapped[str] = mapped_column( + ForeignKey("tickets.id", ondelete="RESTRICT"), nullable=False, index=True + ) + provider_id: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + case_number: Mapped[str] = mapped_column(String(255), nullable=False) + case_url: Mapped[str] = mapped_column(String(1_500), nullable=False) + handoff_note: Mapped[str | None] = mapped_column(Text, nullable=True) + outcome: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) + + +__all__ = ["Ticket", "TicketComment", "TicketEscalation", "TicketHistory"] diff --git a/src/govoplan_tickets/backend/domain.py b/src/govoplan_tickets/backend/domain.py new file mode 100644 index 0000000..201e294 --- /dev/null +++ b/src/govoplan_tickets/backend/domain.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Literal, cast + + +TicketType = Literal["request", "incident", "problem", "report"] +TicketPriority = Literal["low", "normal", "high", "urgent"] +TicketVisibility = Literal["tenant", "restricted"] + +TICKET_TYPES = frozenset({"request", "incident", "problem", "report"}) +TICKET_PRIORITIES = frozenset({"low", "normal", "high", "urgent"}) +TICKET_VISIBILITIES = frozenset({"tenant", "restricted"}) +TICKET_STATES = frozenset( + { + "new", + "triaged", + "in_progress", + "waiting", + "resolved", + "closed", + "cancelled", + } +) +TICKET_TRANSITIONS: Mapping[str, frozenset[str]] = { + "new": frozenset({"triaged", "in_progress", "cancelled"}), + "triaged": frozenset({"in_progress", "waiting", "resolved", "cancelled"}), + "in_progress": frozenset({"waiting", "resolved", "cancelled"}), + "waiting": frozenset({"in_progress", "resolved", "cancelled"}), + "resolved": frozenset({"closed", "in_progress"}), + "closed": frozenset({"in_progress"}), + "cancelled": frozenset({"in_progress"}), +} +SUBJECT_KINDS = frozenset( + { + "account", + "identity", + "group", + "role", + "function", + "function_assignment", + "organization_unit", + "service_account", + "external", + } +) +LINK_KINDS = frozenset( + { + "related", + "attachment", + "case", + "project", + "wiki", + "asset", + "facility", + "form", + "file", + "external", + } +) + + +class TicketDomainError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class TicketSubjectRef: + kind: str + id: str + label: str | None = None + role: str | None = None + + def __post_init__(self) -> None: + if self.kind not in SUBJECT_KINDS: + raise TicketDomainError(f"Unsupported ticket subject kind: {self.kind!r}.") + _required(self.id, "Ticket subject identifier", 255) + _optional(self.label, "Ticket subject label", 500) + _optional(self.role, "Ticket subject role", 80) + + @property + def key(self) -> tuple[str, str, str | None]: + return self.kind, self.id, self.role + + def to_dict(self) -> dict[str, object]: + return {"kind": self.kind, "id": self.id, "label": self.label, "role": self.role} + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "TicketSubjectRef": + return cls( + kind=_required(value.get("kind"), "Ticket subject kind", 40), + id=_required(value.get("id"), "Ticket subject identifier", 255), + label=_optional(value.get("label"), "Ticket subject label", 500), + role=_optional(value.get("role"), "Ticket subject role", 80), + ) + + +@dataclass(frozen=True, slots=True) +class TicketLink: + link_id: str + kind: str + owner_module: str + resource_type: str + resource_id: str + relation: str = "related" + label: str | None = None + url: str | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + def __post_init__(self) -> None: + _required(self.link_id, "Ticket link identifier", 255) + if self.kind not in LINK_KINDS: + raise TicketDomainError(f"Unsupported ticket link kind: {self.kind!r}.") + _required(self.owner_module, "Ticket link owner module", 100) + _required(self.resource_type, "Ticket link resource type", 100) + _required(self.resource_id, "Ticket link resource identifier", 255) + _required(self.relation, "Ticket link relation", 80) + _optional(self.label, "Ticket link label", 500) + _optional(self.url, "Ticket link URL", 1_500) + if self.url: + _safe_url(self.url) + if len(self.metadata) > 50: + raise TicketDomainError("Ticket link metadata is limited to 50 entries.") + + def to_dict(self) -> dict[str, object]: + return { + "link_id": self.link_id, + "kind": self.kind, + "owner_module": self.owner_module, + "resource_type": self.resource_type, + "resource_id": self.resource_id, + "relation": self.relation, + "label": self.label, + "url": self.url, + "metadata": dict(self.metadata), + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "TicketLink": + metadata = value.get("metadata") or {} + if not isinstance(metadata, Mapping): + raise TicketDomainError("Ticket link metadata must be an object.") + return cls( + link_id=_required(value.get("link_id"), "Ticket link identifier", 255), + kind=_required(value.get("kind"), "Ticket link kind", 40), + owner_module=_required(value.get("owner_module"), "Ticket link owner module", 100), + resource_type=_required(value.get("resource_type"), "Ticket link resource type", 100), + resource_id=_required(value.get("resource_id"), "Ticket link resource identifier", 255), + relation=str(value.get("relation") or "related"), + label=_optional(value.get("label"), "Ticket link label", 500), + url=_optional(value.get("url"), "Ticket link URL", 1_500), + metadata=dict(metadata), + ) + + +@dataclass(frozen=True, slots=True) +class TicketRecord: + tenant_id: str + ticket_id: str + ticket_number: str + revision: int + ticket_type: TicketType + priority: TicketPriority + status: str + title: str + description: str + visibility: TicketVisibility + received_at: datetime + recorded_at: datetime + change_reason: str + queue_ref: str | None = None + assignee: TicketSubjectRef | None = None + reporter: TicketSubjectRef | None = None + requester: TicketSubjectRef | None = None + participants: tuple[TicketSubjectRef, ...] = () + links: tuple[TicketLink, ...] = () + service_target_at: datetime | None = None + resolved_at: datetime | None = None + resolution_summary: str | None = None + deleted_at: datetime | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + _required(self.tenant_id, "Ticket tenant", 255) + _required(self.ticket_id, "Ticket identifier", 255) + _required(self.ticket_number, "Ticket number", 255) + if self.revision < 1: + raise TicketDomainError("Ticket revisions start at one.") + if self.ticket_type not in TICKET_TYPES: + raise TicketDomainError(f"Unsupported ticket type: {self.ticket_type!r}.") + if self.priority not in TICKET_PRIORITIES: + raise TicketDomainError(f"Unsupported ticket priority: {self.priority!r}.") + if self.status not in TICKET_STATES: + raise TicketDomainError(f"Unsupported ticket state: {self.status!r}.") + if self.visibility not in TICKET_VISIBILITIES: + raise TicketDomainError(f"Unsupported ticket visibility: {self.visibility!r}.") + _required(self.title, "Ticket title", 500) + _required(self.description, "Ticket description", 40_000) + _required(self.change_reason, "Ticket change reason", 1_000) + _optional(self.queue_ref, "Ticket queue reference", 255) + _optional(self.resolution_summary, "Ticket resolution summary", 20_000) + for value, label in ( + (self.received_at, "Ticket received_at"), + (self.recorded_at, "Ticket recorded_at"), + (self.service_target_at, "Ticket service_target_at"), + (self.resolved_at, "Ticket resolved_at"), + (self.deleted_at, "Ticket deleted_at"), + ): + _aware(value, label) + if self.resolved_at is not None and self.resolved_at < self.received_at: + raise TicketDomainError("Ticket resolved_at cannot precede received_at.") + if self.deleted_at is not None and self.deleted_at < self.received_at: + raise TicketDomainError("Ticket deleted_at cannot precede received_at.") + if self.status in {"resolved", "closed"} and not self.resolution_summary: + raise TicketDomainError("Resolved or closed tickets require a resolution summary.") + if self.status not in {"resolved", "closed"} and self.resolved_at is not None: + raise TicketDomainError("Only resolved or closed tickets carry resolved_at.") + participant_keys = {item.key for item in self.participants} + if len(participant_keys) != len(self.participants): + raise TicketDomainError("Ticket participants must be unique.") + link_ids = {item.link_id for item in self.links} + if len(link_ids) != len(self.links): + raise TicketDomainError("Ticket link identifiers must be unique.") + if len(self.participants) > 100 or len(self.links) > 200: + raise TicketDomainError("Ticket participants or links exceed their bounded limits.") + if len(self.metadata) > 100: + raise TicketDomainError("Ticket metadata is limited to 100 entries.") + + def to_dict(self) -> dict[str, Any]: + return { + "tenant_id": self.tenant_id, + "ticket_id": self.ticket_id, + "ticket_number": self.ticket_number, + "revision": self.revision, + "ticket_type": self.ticket_type, + "priority": self.priority, + "status": self.status, + "title": self.title, + "description": self.description, + "visibility": self.visibility, + "queue_ref": self.queue_ref, + "assignee": self.assignee.to_dict() if self.assignee else None, + "reporter": self.reporter.to_dict() if self.reporter else None, + "requester": self.requester.to_dict() if self.requester else None, + "participants": [item.to_dict() for item in self.participants], + "links": [item.to_dict() for item in self.links], + "service_target_at": _datetime_text(self.service_target_at), + "received_at": self.received_at.isoformat(), + "recorded_at": self.recorded_at.isoformat(), + "resolved_at": _datetime_text(self.resolved_at), + "resolution_summary": self.resolution_summary, + "deleted_at": _datetime_text(self.deleted_at), + "change_reason": self.change_reason, + "metadata": dict(self.metadata), + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "TicketRecord": + metadata = value.get("metadata") or {} + if not isinstance(metadata, Mapping): + raise TicketDomainError("Ticket metadata must be an object.") + return cls( + tenant_id=_required(value.get("tenant_id"), "Ticket tenant", 255), + ticket_id=_required(value.get("ticket_id"), "Ticket identifier", 255), + ticket_number=_required(value.get("ticket_number"), "Ticket number", 255), + revision=int(value.get("revision") or 0), + ticket_type=cast(TicketType, _required(value.get("ticket_type"), "Ticket type", 80)), + priority=cast(TicketPriority, _required(value.get("priority"), "Ticket priority", 40)), + status=_required(value.get("status"), "Ticket state", 40), + title=_required(value.get("title"), "Ticket title", 500), + description=_required(value.get("description"), "Ticket description", 40_000), + visibility=cast(TicketVisibility, str(value.get("visibility") or "tenant")), + queue_ref=_optional(value.get("queue_ref"), "Ticket queue reference", 255), + assignee=_subject(value.get("assignee")), + reporter=_subject(value.get("reporter")), + requester=_subject(value.get("requester")), + participants=tuple( + TicketSubjectRef.from_mapping(item) + for item in _mapping_items(value.get("participants"), "Ticket participants") + ), + links=tuple( + TicketLink.from_mapping(item) + for item in _mapping_items(value.get("links"), "Ticket links") + ), + service_target_at=_optional_datetime(value.get("service_target_at")), + received_at=_datetime(value.get("received_at"), "Ticket received_at"), + recorded_at=_datetime(value.get("recorded_at"), "Ticket recorded_at"), + resolved_at=_optional_datetime(value.get("resolved_at")), + resolution_summary=_optional(value.get("resolution_summary"), "Ticket resolution summary", 20_000), + deleted_at=_optional_datetime(value.get("deleted_at")), + change_reason=_required(value.get("change_reason"), "Ticket change reason", 1_000), + metadata=dict(metadata), + ) + + +def validate_transition(current: str, target: str) -> None: + if current == target: + return + if current not in TICKET_TRANSITIONS or target not in TICKET_TRANSITIONS[current]: + raise TicketDomainError(f"Ticket transition from {current!r} to {target!r} is not allowed.") + + +def _subject(value: object) -> TicketSubjectRef | None: + if value is None: + return None + if not isinstance(value, Mapping): + raise TicketDomainError("Ticket subject references must be objects.") + return TicketSubjectRef.from_mapping(value) + + +def _mapping_items(value: object, label: str) -> Sequence[Mapping[str, object]]: + if value is None: + return () + if not isinstance(value, (list, tuple)) or any(not isinstance(item, Mapping) for item in value): + raise TicketDomainError(f"{label} must be a list of objects.") + return value # type: ignore[return-value] + + +def _required(value: object, label: str, maximum: int) -> str: + clean = str(value or "").strip() + if not clean or len(clean) > maximum: + raise TicketDomainError(f"{label} must contain 1 to {maximum} characters.") + return clean + + +def _optional(value: object, label: str, maximum: int) -> str | None: + if value is None: + return None + clean = str(value).strip() + if not clean or len(clean) > maximum: + raise TicketDomainError(f"{label} must contain 1 to {maximum} characters when set.") + return clean + + +def _aware(value: datetime | None, label: str) -> None: + if value is not None and (value.tzinfo is None or value.utcoffset() is None): + raise TicketDomainError(f"{label} must include a timezone.") + + +def _datetime(value: object, label: str) -> datetime: + result = _optional_datetime(value) + if result is None: + raise TicketDomainError(f"{label} is required.") + return result + + +def _optional_datetime(value: object) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + result = value + else: + try: + result = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError as exc: + raise TicketDomainError("Ticket timestamp is invalid.") from exc + _aware(result, "Ticket timestamp") + return result + + +def _datetime_text(value: datetime | None) -> str | None: + return value.isoformat() if value else None + + +def _safe_url(value: str) -> None: + if "\\" in value or any(ord(character) < 32 or ord(character) == 127 for character in value): + raise TicketDomainError("Ticket link URL contains unsafe characters.") + if value.startswith("//"): + raise TicketDomainError("Ticket link URL must not be scheme-relative.") + if not (value.startswith("/") or value.startswith("https://") or value.startswith("http://")): + raise TicketDomainError("Ticket link URL must be application-relative or HTTP(S).") + + +__all__ = [ + "LINK_KINDS", + "SUBJECT_KINDS", + "TICKET_PRIORITIES", + "TICKET_STATES", + "TICKET_TRANSITIONS", + "TICKET_TYPES", + "TicketDomainError", + "TicketLink", + "TicketRecord", + "TicketSubjectRef", + "validate_transition", +] diff --git a/src/govoplan_tickets/backend/dsar_provider.py b/src/govoplan_tickets/backend/dsar_provider.py new file mode 100644 index 0000000..bb7335d --- /dev/null +++ b/src/govoplan_tickets/backend/dsar_provider.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) +from govoplan_tickets.backend.db.models import Ticket, TicketComment, TicketHistory + + +TICKETS_DSAR_CAPABILITY = dsar_capability_name("tickets") +_MAX_RECORDS = 5_000 +_CONFLICT = object() + + +@dataclass(frozen=True, slots=True) +class _Selectors: + subject_ids: tuple[str, ...] + ticket_id: str | None + + +class TicketsDsarProvider: + provider_id = "tickets" + module_id = "tickets" + + def search_subject( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + ) -> Sequence[DsarRecordRef]: + db = _session(session) + selectors = _selectors(subject) + if selectors is None: + return () + query = db.query(Ticket).filter(Ticket.tenant_id == tenant_id) + if selectors.ticket_id: + query = query.filter(Ticket.id == selectors.ticket_id) + rows = query.limit(_MAX_RECORDS + 1).all() + if len(rows) > _MAX_RECORDS: + raise ValueError("Tickets DSAR result limit exceeded; narrow the selectors.") + records: list[DsarRecordRef] = [] + for row in rows: + roles = _subject_roles(row, selectors.subject_ids) + activities = _activities(db, row, selectors.subject_ids) + comments = _comments(db, row, selectors.subject_ids) + if roles or comments: + records.append(_participation_record(row, roles, comments, activities)) + elif activities: + records.append(_actor_record(row, activities)) + return tuple(records) + + def plan_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + records: Sequence[DsarRecordRef], + ) -> Sequence[DsarErasureActionRef]: + del tenant_id + _session(session) + if _selectors(subject) is None: + raise ValueError("Tickets DSAR subject selectors conflict.") + actions = [] + for record in records: + _validate_record(record) + participation = record.resource_type == "ticket_participation" + actions.append( + DsarErasureActionRef( + action_id=f"tickets:{'manual_review' if participation else 'retain'}:{record.resource_type}:{record.resource_id}", + provider_id=self.provider_id, + module_id=self.module_id, + kind="manual_review" if participation else "retain", + resource_type=record.resource_type, + resource_id=record.resource_id, + title=f"Review {record.title}" if participation else f"Retain {record.title}", + rationale=( + "Reporter, requester, participant, and comment references may be operational evidence. " + "The ticket owner and applicable retention policy must decide whether they can be detached or minimized." + if participation + else record.retention_reason or "Ticket lifecycle attribution is immutable accountability evidence." + ), + executable=False, + ) + ) + return tuple(actions) + + def execute_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + actions: Sequence[DsarErasureActionRef], + request_id: str, + ) -> Sequence[DsarExecutionResultRef]: + del tenant_id + _session(session) + if _selectors(subject) is None: + raise ValueError("Tickets DSAR subject selectors conflict.") + results = [] + for action in actions: + _validate_action(action) + if action.executable or action.kind not in {"retain", "manual_review"}: + raise ValueError("Tickets DSAR publishes non-executable actions only.") + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "The ticket remains unchanged pending owner and retention review." + if action.kind == "manual_review" + else "Ticket lifecycle attribution remains immutable evidence." + ), + evidence={"request_id": request_id}, + ) + ) + return tuple(results) + + +def _selectors(subject: DsarSubjectRef) -> _Selectors | None: + refs = subject.external_references + values = ( + _coalesce(subject.account_id, refs.get("tickets.account"), refs.get("access.account")), + _coalesce(subject.identity_id, refs.get("tickets.identity"), refs.get("identity.id")), + _coalesce(subject.membership_id, refs.get("tickets.membership"), refs.get("tenancy.membership")), + ) + ticket = _coalesce(refs.get("tickets.ticket"), refs.get("tickets.item")) + if any(value is _CONFLICT for value in (*values, ticket)): + return None + subject_ids = tuple(dict.fromkeys(value for value in values if isinstance(value, str) and value)) + if not subject_ids: + return None + return _Selectors(subject_ids=subject_ids, ticket_id=ticket if isinstance(ticket, str) else None) + + +def _coalesce(*values: str | None) -> str | None | object: + normalized = {str(value).strip() for value in values if str(value or "").strip()} + if len(normalized) > 1: + return _CONFLICT + return next(iter(normalized), None) + + +def _subject_roles(row: Ticket, subject_ids: Sequence[str]) -> list[dict[str, object]]: + selected = set(subject_ids) + roles: list[dict[str, object]] = [] + for role, value in ( + ("reporter", row.reporter), + ("requester", row.requester), + ("assignee", row.assignee), + ): + if isinstance(value, Mapping) and str(value.get("id") or "") in selected: + roles.append({"role": role, "kind": value.get("kind"), "id": value.get("id"), "label": value.get("label")}) + for value in row.participants or (): + if isinstance(value, Mapping) and str(value.get("id") or "") in selected: + roles.append({"role": value.get("role") or "participant", "kind": value.get("kind"), "id": value.get("id"), "label": value.get("label")}) + return roles[:100] + + +def _activities(session: Session, row: Ticket, subject_ids: Sequence[str]) -> list[dict[str, object]]: + selected = set(subject_ids) + history = ( + session.query(TicketHistory) + .filter( + TicketHistory.tenant_id == row.tenant_id, + TicketHistory.ticket_id == row.id, + TicketHistory.actor_id.in_(tuple(selected)), + ) + .order_by(TicketHistory.revision.asc()) + .limit(500) + .all() + ) + return [ + {"event_type": item.event_type, "revision": item.revision, "occurred_at": _iso(item.occurred_at)} + for item in history + ] + + +def _comments(session: Session, row: Ticket, subject_ids: Sequence[str]) -> list[dict[str, object]]: + comments = ( + session.query(TicketComment) + .filter( + TicketComment.tenant_id == row.tenant_id, + TicketComment.ticket_id == row.id, + TicketComment.created_by.in_(tuple(subject_ids)), + ) + .order_by(TicketComment.created_at.asc()) + .limit(500) + .all() + ) + return [ + { + "comment_id": item.comment_id, + "body": item.body[:20_000], + "visibility": item.visibility, + "created_at": _iso(item.created_at), + } + for item in comments + ] + + +def _participation_record( + row: Ticket, + roles: list[dict[str, object]], + comments: list[dict[str, object]], + activities: list[dict[str, object]], +) -> DsarRecordRef: + return DsarRecordRef( + provider_id="tickets", + module_id="tickets", + resource_type="ticket_participation", + resource_id=row.id, + category="operational_service_request", + title=f"Ticket participation: {row.ticket_number}", + data={ + "ticket_number": row.ticket_number, + "ticket_type": row.ticket_type, + "priority": row.priority, + "status": row.status, + "title": row.title[:500], + "description": row.description[:40_000], + "subject_roles": roles, + "subject_comments": comments, + "subject_activities": activities, + "received_at": _iso(row.received_at), + "resolved_at": _iso(row.resolved_at), + "deleted_at": _iso(row.deleted_at), + "revision": row.revision, + }, + observed_at=_aware(row.updated_at or row.recorded_at), + retention_reason="The ticket may document institutional service delivery and requires owner review before subject references are changed.", + ) + + +def _actor_record(row: Ticket, activities: list[dict[str, object]]) -> DsarRecordRef: + return DsarRecordRef( + provider_id="tickets", + module_id="tickets", + resource_type="ticket_actor_attribution", + resource_id=row.id, + category="operator_accountability_evidence", + title="Ticket lifecycle attribution", + data={"activities": activities, "status": row.status, "revision": row.revision}, + observed_at=_aware(row.updated_at or row.recorded_at), + immutable_evidence=True, + retention_reason="Ticket lifecycle attribution is immutable accountability evidence.", + ) + + +def _aware(value: datetime | None) -> datetime | None: + if value is not None and value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + +def _iso(value: datetime | None) -> str | None: + result = _aware(value) + return result.isoformat() if result else None + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Tickets DSAR requires a SQLAlchemy Session.") + return value + + +def _validate_record(record: DsarRecordRef) -> None: + if record.provider_id != "tickets" or record.module_id != "tickets" or record.resource_type not in {"ticket_participation", "ticket_actor_attribution"}: + raise ValueError("Tickets DSAR record identity is invalid.") + + +def _validate_action(action: DsarErasureActionRef) -> None: + if action.provider_id != "tickets" or action.module_id != "tickets" or not action.action_id.startswith("tickets:"): + raise ValueError("Tickets DSAR action identity is invalid.") + + +__all__ = ["TICKETS_DSAR_CAPABILITY", "TicketsDsarProvider"] diff --git a/src/govoplan_tickets/backend/manifest.py b/src/govoplan_tickets/backend/manifest.py index c860421..01610a5 100644 --- a/src/govoplan_tickets/backend/manifest.py +++ b/src/govoplan_tickets/backend/manifest.py @@ -1,17 +1,71 @@ from __future__ import annotations -from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER -from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate -from govoplan_core.core.provider_governance import declared_module_architecture +from pathlib import Path + +from sqlalchemy import func + +from govoplan_core.core.access import ( + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, +) +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) +from govoplan_core.core.modules import ( + CapabilityDocumentation, + DocumentationCondition, + DocumentationLink, + DocumentationTopic, + FrontendModule, + FrontendRoute, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, + ModuleManifest, + NavItem, + PermissionDefinition, + ProductAreaContribution, + RoleTemplate, +) +from govoplan_core.core.provider_governance import ( + ModuleArchitectureDeclaration, + ModuleArchitectureDocumentation, + ModuleMaturityEvidence, +) +from govoplan_core.core.search import SearchSourceProviderRegistration +from govoplan_core.core.tickets import ( + CAPABILITY_TICKET_CASE_ESCALATION, + CAPABILITY_TICKET_ROUTING, +) +from govoplan_core.core.views import ViewSurface +from govoplan_core.db.base import Base +from govoplan_tickets.backend.db import models as ticket_models +from govoplan_tickets.backend.dsar_provider import ( + TICKETS_DSAR_CAPABILITY, + TicketsDsarProvider, +) +from govoplan_tickets.backend.search_source import create_tickets_search_source +from govoplan_tickets.backend.service import ( + ADMIN_SCOPE, + ASSIGN_SCOPE, + CAPABILITY_TICKETS_REGISTRY, + LEGACY_WRITE_SCOPE, + READ_SCOPE, + REPORT_SCOPE, + RESOLVE_SCOPE, + TRIAGE_SCOPE, + SqlTicketRegistry, +) + MODULE_ID = "tickets" MODULE_NAME = "Tickets" -MODULE_VERSION = "0.1.19" -READ_SCOPE = "tickets:ticket:read" -WRITE_SCOPE = "tickets:ticket:write" -ADMIN_SCOPE = "tickets:ticket:admin" +MODULE_VERSION = "0.1.20" +WRITE_SCOPE = LEGACY_WRITE_SCOPE OPTIONAL_DEPENDENCIES = ( "cases", + "helpdesk", "projects", "wiki", "assets", @@ -33,7 +87,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio scope=scope, label=label, description=description, - category="Tickets", + category=MODULE_NAME, level="tenant", module_id=module_id, resource=resource, @@ -41,30 +95,64 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio ) +def _router(context: ModuleContext): + from govoplan_tickets.backend.router import create_router + + return create_router(context.registry) + + +def _registry(context: ModuleContext) -> SqlTicketRegistry: + return SqlTicketRegistry(context.registry) + + +def _dsar_provider(_context: ModuleContext) -> TicketsDsarProvider: + return TicketsDsarProvider() + + +def _tenant_summary(session, tenant_id: str) -> dict[str, int]: + counts = { + status: int(count) + for status, count in session.query(ticket_models.Ticket.status, func.count()) + .filter( + ticket_models.Ticket.tenant_id == tenant_id, + ticket_models.Ticket.deleted_at.is_(None), + ) + .group_by(ticket_models.Ticket.status) + .all() + } + return { + "tickets": sum(counts.values()), + "open_tickets": sum( + count + for state, count in counts.items() + if state not in {"resolved", "closed", "cancelled"} + ), + "resolved_tickets": counts.get("resolved", 0) + counts.get("closed", 0), + } + + PERMISSIONS = ( - _permission( - READ_SCOPE, - "View tickets", - "Read discoverable tickets, queue state, and resolution context.", - ), - _permission( - WRITE_SCOPE, - "Manage tickets", - "Create, triage, assign, update, resolve, and link tickets.", - ), - _permission( - ADMIN_SCOPE, - "Administer tickets", - "Configure ticket types, queues, service policies, and intake profiles.", - ), + _permission(READ_SCOPE, "View tickets", "Read discoverable tickets, comments, links, queue state, and immutable history."), + _permission(REPORT_SCOPE, "Report tickets", "Create operational reports and requests and add external follow-up comments."), + _permission(TRIAGE_SCOPE, "Triage tickets", "Classify tickets, manage participants and links, and select queues and service targets."), + _permission(ASSIGN_SCOPE, "Assign tickets", "Assign or reassign tickets to accounts, groups, roles, functions, or organization units."), + _permission(RESOLVE_SCOPE, "Resolve tickets", "Advance, resolve, close, cancel, and reopen tickets with resolution evidence."), + _permission(ADMIN_SCOPE, "Administer tickets", "Inspect all tenant tickets, configure ticket behavior, and soft-delete operational records."), + _permission(LEGACY_WRITE_SCOPE, "Manage tickets (compatibility)", "Preserve existing broad manager grants while deployments migrate to the distinct triage, assignment, and resolution scopes."), ) ROLE_TEMPLATES = ( + RoleTemplate( + slug="tickets_reporter", + name="Tickets reporter", + description="Report and follow accessible operational tickets.", + permissions=(READ_SCOPE, REPORT_SCOPE), + ), RoleTemplate( slug="tickets_manager", name="Tickets manager", - description="Triage, assign, update, and resolve tickets.", - permissions=(READ_SCOPE, WRITE_SCOPE), + description="Triage, assign, update, escalate, and resolve tickets.", + permissions=(READ_SCOPE, REPORT_SCOPE, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE), ), RoleTemplate( slug="tickets_viewer", @@ -72,20 +160,23 @@ ROLE_TEMPLATES = ( description="Read discoverable tickets and their resolution context.", permissions=(READ_SCOPE,), ), + RoleTemplate( + slug="tickets_administrator", + name="Tickets administrator", + description="Administer the complete tenant ticket lifecycle and recovery surface.", + permissions=(READ_SCOPE, REPORT_SCOPE, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE, ADMIN_SCOPE), + ), ) DOCUMENTATION = ( DocumentationTopic( - id=f"{MODULE_ID}.module-boundary", - title=f"{MODULE_NAME} module boundary", - summary=( - "Queue-oriented reports, requests, incidents, problems, triage, " - "routing, service work, and auditable resolution." - ), + id="tickets.module-boundary", + title="Tickets module boundary", + summary="Operational requests, incidents, problems, reports, queue work, service targets, and auditable resolution.", body=( - "This repository is currently a platform module seed. It registers the domain boundary, " - "permission surface, role templates, and documentation metadata before runtime APIs, " - "database models, migrations, and WebUI routes are introduced." + "Tickets owns the operational service record and its lifecycle. Cases remains authoritative for formal procedures; " + "Helpdesk may contribute routing and service-target policy without creating a second ticket store. Attachments and " + "related work are typed references. Soft deletion removes a ticket from ordinary work and Search while retaining its immutable history." ), layer="available", documentation_types=("admin", "user"), @@ -93,41 +184,122 @@ DOCUMENTATION = ( translations={ "de": { "title": "Modulgrenze von Tickets", - "summary": "Warteschlangenorientierte Meldungen, Anfragen, Störungen, Probleme, Triage, Weiterleitung, Servicearbeit und nachvollziehbare Lösungen.", - "body": "Dieses Repository ist derzeit ein Grundgerüst für ein Plattformmodul. Es registriert die Fachgrenze, Berechtigungsoberfläche, Rollenvorlagen und Dokumentationsmetadaten, bevor Laufzeit-APIs, Datenbankmodelle, Migrationen und WebUI-Routen eingeführt werden.", + "summary": "Operative Anfragen, Störungen, Probleme, Meldungen, Warteschlangenarbeit, Serviceziele und nachvollziehbare Lösungen.", + "body": "Tickets verantwortet den operativen Servicevorgang und seinen Lebenszyklus. Cases bleibt für förmliche Verfahren maßgeblich; Helpdesk kann Weiterleitung und Serviceziele beisteuern, ohne einen zweiten Ticketspeicher anzulegen. Anhänge und verbundene Arbeit sind typisierte Verweise. Eine weiche Löschung entfernt ein Ticket aus der normalen Arbeit und Suche, erhält aber die unveränderliche Historie.", } }, - order=100, related_modules=OPTIONAL_DEPENDENCIES, - links=( - DocumentationLink( - label="Repository domain boundary", - href="govoplan-tickets/docs/TICKETS_DOMAIN_BOUNDARY.md", - kind="repository", - ), - ), + links=(DocumentationLink(label="Repository domain boundary", href="govoplan-tickets/docs/TICKETS_DOMAIN_BOUNDARY.md", kind="repository"),), metadata={ "kind": "reference", - "seed": True, "consequence_classes": { - "seed_boundary": "Declares ownership and permissions only; no runtime workflow is available yet.", + "canonical_store": "Tickets is the only operational ticket store; Helpdesk supplies policy and queue semantics.", + "case_boundary": "Case escalation creates a stable auditable link and never converts or copies ticket history.", + "soft_delete": "Deletion hides current work but preserves immutable accountability evidence.", }, - "domain_objects": [ - "ticket", - "ticket type and queue", - "triage and routing facts", - "reporter and requester references", - "assignment and service-level state", - "resolution and escalation links", - ], - "first_slice": ( - "Define ticket identity, intake profiles, queues, triage, " - "assignment, resolution, and stable escalation links to cases." - ), }, ), + DocumentationTopic( + id="tickets.operational-workflow", + title="Report, triage, assign, and resolve tickets", + summary="Use guarded actions and revision evidence to move operational work from intake to a confirmed outcome.", + body=( + "Reporters create a request, incident, problem, or report. Triage staff classify it, select a queue and target, and manage typed participants and references. " + "Assignment and resolution are separate permissions. Every accepted mutation requires the current revision, a reason, actor, time, and idempotency key. " + "Resolved and closed tickets require a resolution summary; reopening clears the resolved timestamp and continues the same record." + ), + layer="configured", + documentation_types=("user", "admin"), + audience=("user", "operator", "tenant_admin"), + conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, REPORT_SCOPE, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE, ADMIN_SCOPE)),), + translations={ + "de": { + "title": "Tickets melden, sichten, zuweisen und lösen", + "summary": "Operative Arbeit mit geschützten Aktionen und Revisionsnachweisen von der Aufnahme bis zum bestätigten Ergebnis führen.", + "body": "Meldende erstellen eine Anfrage, Störung, ein Problem oder eine Meldung. Die Triage klassifiziert sie, wählt Warteschlange und Ziel und pflegt typisierte Beteiligte und Verweise. Zuweisung und Lösung sind getrennte Berechtigungen. Jede angenommene Änderung benötigt die aktuelle Revision, einen Grund, Akteur, Zeitpunkt und Idempotenzschlüssel. Gelöste und geschlossene Tickets benötigen eine Lösungszusammenfassung; eine Wiedereröffnung führt denselben Vorgang fort.", + } + }, + related_modules=("helpdesk", "cases", "files", "search"), + metadata={ + "kind": "workflow", + "help_contexts": [ + "tickets.route.workspace", + "tickets.action.report", + "tickets.action.triage", + "tickets.action.assign", + "tickets.action.resolve", + "tickets.action.escalate", + "tickets.field.queue", + "tickets.field.service-target", + ], + }, + ), + DocumentationTopic( + id="tickets.optional-integrations", + title="Optional ticket integrations and reduced installations", + summary="Understand what remains available when Cases, Helpdesk, Files, Projects, Wiki, or Search is absent.", + body=( + "Without Helpdesk, authorized users select queues and service targets manually. Without Cases, formal escalation is disabled but ticket resolution remains available. " + "Without Files, attachments remain external references and Tickets never stores bytes. Project and Wiki references are retained without owner validation when those modules are absent. " + "Without Search, the ticket workspace and API remain usable but global discovery and indexing are unavailable. The workspace reports these consequences explicitly." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "tenant_admin"), + conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, ADMIN_SCOPE)),), + translations={ + "de": { + "title": "Optionale Ticket-Integrationen und reduzierte Installationen", + "summary": "Verstehen, was ohne Cases, Helpdesk, Files, Projects, Wiki oder Search verfügbar bleibt.", + "body": "Ohne Helpdesk wählen Berechtigte Warteschlange und Serviceziel manuell. Ohne Cases ist die förmliche Eskalation deaktiviert, die Ticketlösung bleibt verfügbar. Ohne Files bleiben Anhänge externe Verweise; Tickets speichert keine Dateiinhalte. Projekt- und Wiki-Verweise bleiben ohne Prüfung durch das Eigentümermodul erhalten. Ohne Search bleiben Arbeitsbereich und API nutzbar, globale Suche und Indizierung fehlen. Der Arbeitsbereich weist auf diese Folgen hin.", + } + }, + metadata={"kind": "workflow", "help_contexts": ["tickets.page.availability"]}, + ), + DocumentationTopic( + id="tickets.data-subject-requests", + title="Ticket data-subject requests", + summary="Export exact ticket participation and minimized actor attribution without automatically changing operational evidence.", + body=( + "Tickets matches exact account, identity, and membership identifiers inside the active tenant. Reporter, requester, assignee, participant, and authored-comment records include bounded ticket context. " + "Actor-only matches expose minimized lifecycle attribution. Arbitrary metadata, internal comments by other actors, request hashes, and idempotency keys are excluded. " + "Erasure remains a manual owner and retention review because ticket content and attribution can be institutional accountability evidence." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "privacy_officer", "auditor", "user"), + conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, ADMIN_SCOPE)),), + translations={ + "de": { + "title": "Datenschutzanfragen für Tickets", + "summary": "Exakte Ticket-Beteiligung und minimierte Akteurszuordnung exportieren, ohne operative Nachweise automatisch zu verändern.", + "body": "Tickets gleicht exakte Konto-, Identitäts- und Mitgliedschaftskennungen innerhalb des aktiven Mandanten ab. Treffer als meldende, anfragende, zugewiesene oder beteiligte Person sowie eigene Kommentare enthalten begrenzten Ticketkontext. Reine Akteurstreffer liefern eine minimierte Lebenszykluszuordnung. Beliebige Metadaten, interne Kommentare anderer Akteure, Anfrage-Hashes und Idempotenzschlüssel werden ausgeschlossen. Eine Löschung bleibt eine manuelle Prüfung durch Eigentümer und Aufbewahrungsverantwortliche, weil Inhalt und Zuordnung institutionelle Rechenschaftsnachweise sein können.", + } + }, + metadata={"kind": "workflow", "help_contexts": ["tickets.admin.dsar"]}, + ), ) +ARCHITECTURE = ModuleArchitectureDeclaration( + layer="human_work_procedure", + kind="domain", + maturity="vertical_slice", + evidence=( + ModuleMaturityEvidence(kind="documentation", reference="docs/TICKETS_DOMAIN_BOUNDARY.md", summary="Defines ticket, Case, and Helpdesk authority boundaries."), + ModuleMaturityEvidence(kind="test", reference="tests/test_ticket_service.py", summary="Proves lifecycle, replay safety, tenant isolation, access, integrations, history, and Search changes."), + ), + known_limits=( + "Helpdesk-owned configurable queue and service-profile administration is a separate provider slice.", + "External service-desk transport and synchronization remain connector-owned work.", + "Attachments are file references; binary storage remains Files-owned.", + ), + supported_authority_modes=("native_authoritative", "linked_reference", "governed_sync"), + owned_concepts=("ticket", "ticket queue reference", "ticket lifecycle", "ticket resolution", "ticket escalation link"), + non_owned_concepts=("formal case", "helpdesk service profile", "file content", "external service-desk transport"), + documentation=ModuleArchitectureDocumentation(operations=("docs/TICKETS_DOMAIN_BOUNDARY.md",)), +) + + manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, @@ -135,18 +307,70 @@ manifest = ModuleManifest( dependencies=("access",), optional_dependencies=OPTIONAL_DEPENDENCIES, required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + optional_capabilities=(CAPABILITY_TICKET_ROUTING, CAPABILITY_TICKET_CASE_ESCALATION), + provides_interfaces=( + ModuleInterfaceProvider(name=CAPABILITY_TICKETS_REGISTRY, version="1.0.0"), + ModuleInterfaceProvider(name=TICKETS_DSAR_CAPABILITY, version="0.1.0"), + ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, - documentation=DOCUMENTATION, - architecture=declared_module_architecture( - layer="human_work_procedure", - kind="domain", - maturity="scaffold", - documentation_ref="docs/TICKETS_DOMAIN_BOUNDARY.md", - known_limits=("Ticket persistence, queues, SLA, and external service-desk adapters are not implemented yet.",), - owned_concepts=("ticket", "ticket queue", "ticket transition"), - non_owned_concepts=("case", "project", "external service-desk record"), + route_factory=_router, + nav_items=(NavItem(path="/tickets", label="Tickets", icon="ticket-check", required_any=(READ_SCOPE,), order=22, surface_id="tickets.navigation"),), + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/tickets-webui", + routes=(FrontendRoute(path="/tickets", component="TicketsPage", required_any=(READ_SCOPE,), order=22, surface_id="tickets.route.workspace"),), + nav_items=(NavItem(path="/tickets", label="Tickets", icon="ticket-check", required_any=(READ_SCOPE,), order=22, surface_id="tickets.navigation"),), + product_areas=( + ProductAreaContribution( + id="work", + module_id=MODULE_ID, + label="i18n:govoplan-core.product_area.work", + icon="list-checks", + description="i18n:govoplan-core.product_area.work_description", + surface_ids=("tickets.route.workspace",), + order=10, + ), + ), + view_surfaces=( + ViewSurface(id="tickets.page.queue", module_id=MODULE_ID, kind="section", label="Ticket queue", parent_id="tickets.route.workspace", order=30), + ViewSurface(id="tickets.page.detail", module_id=MODULE_ID, kind="section", label="Ticket details", parent_id="tickets.route.workspace", order=40), + ViewSurface(id="tickets.action.report", module_id=MODULE_ID, kind="action", label="Report ticket", parent_id="tickets.page.queue", order=50), + ViewSurface(id="tickets.action.resolve", module_id=MODULE_ID, kind="action", label="Resolve ticket", parent_id="tickets.page.detail", order=60), + ), ), + tenant_summary_providers=(_tenant_summary,), + capability_factories={CAPABILITY_TICKETS_REGISTRY: _registry, TICKETS_DSAR_CAPABILITY: _dsar_provider}, + capability_documentation={ + CAPABILITY_TICKETS_REGISTRY: CapabilityDocumentation(label="Tickets registry", summary="Creates and reads replay-safe tenant ticket records without importing the Tickets implementation.", contract_version="1.0.0"), + TICKETS_DSAR_CAPABILITY: CapabilityDocumentation(label="Tickets data-subject request provider", summary="Exports bounded ticket participation and minimized immutable actor attribution.", contract_version="0.1.0"), + }, + search_sources=(SearchSourceProviderRegistration(id="tickets.tickets", factory=create_tickets_search_source),), + migration_spec=MigrationSpec( + module_id=MODULE_ID, + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + ticket_models.TicketEscalation, + ticket_models.TicketComment, + ticket_models.TicketHistory, + ticket_models.Ticket, + label="Tickets", + ), + retirement_notes="Destructive retirement removes ticket state and evidence only after an explicit database snapshot and retention review.", + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + ticket_models.Ticket, + ticket_models.TicketHistory, + ticket_models.TicketComment, + ticket_models.TicketEscalation, + label="Tickets", + ), + ), + documentation=DOCUMENTATION, + architecture=ARCHITECTURE, ) diff --git a/src/govoplan_tickets/backend/migrations/__init__.py b/src/govoplan_tickets/backend/migrations/__init__.py new file mode 100644 index 0000000..e9721ca --- /dev/null +++ b/src/govoplan_tickets/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Tickets-owned database migrations.""" diff --git a/src/govoplan_tickets/backend/migrations/versions/8d1f4b7a2c5e_v0120_tickets_vertical_slice.py b/src/govoplan_tickets/backend/migrations/versions/8d1f4b7a2c5e_v0120_tickets_vertical_slice.py new file mode 100644 index 0000000..4debf94 --- /dev/null +++ b/src/govoplan_tickets/backend/migrations/versions/8d1f4b7a2c5e_v0120_tickets_vertical_slice.py @@ -0,0 +1,140 @@ +"""v0.1.20 canonical Tickets vertical slice. + +Revision ID: 8d1f4b7a2c5e +Revises: None +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "8d1f4b7a2c5e" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "tickets", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column("tenant_id", sa.String(length=255), nullable=False), + sa.Column("ticket_number", sa.String(length=255), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("ticket_type", sa.String(length=80), nullable=False), + sa.Column("priority", sa.String(length=40), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("visibility", sa.String(length=40), nullable=False), + sa.Column("queue_ref", sa.String(length=255), nullable=True), + sa.Column("assignee", sa.JSON(), nullable=True), + sa.Column("reporter", sa.JSON(), nullable=True), + sa.Column("requester", sa.JSON(), nullable=True), + sa.Column("participants", sa.JSON(), nullable=False), + sa.Column("links", sa.JSON(), nullable=False), + sa.Column("metadata", sa.JSON(), nullable=False), + sa.Column("search_text", sa.Text(), nullable=False), + sa.Column("received_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("change_reason", sa.String(length=1000), nullable=False), + sa.Column("service_target_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolution_summary", sa.Text(), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_tickets")), + sa.UniqueConstraint("tenant_id", "ticket_number", name="uq_ticket_number"), + ) + for column in ( + "tenant_id", "ticket_number", "ticket_type", "priority", "status", "visibility", + "queue_ref", "received_at", "recorded_at", "service_target_at", "resolved_at", + "deleted_at", "created_by", "updated_by", + ): + op.create_index(op.f(f"ix_tickets_{column}"), "tickets", [column], unique=False) + op.create_index("ix_ticket_queue", "tickets", ["tenant_id", "queue_ref", "status", "priority"], unique=False) + op.create_index("ix_ticket_catalog", "tickets", ["tenant_id", "status", "updated_at"], unique=False) + op.create_index("ix_ticket_service_target", "tickets", ["tenant_id", "service_target_at", "status"], unique=False) + + op.create_table( + "ticket_history", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=255), nullable=False), + sa.Column("ticket_id", sa.String(length=255), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("event_type", sa.String(length=120), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("actor_id", sa.String(length=255), nullable=True), + sa.Column("reason", sa.String(length=1000), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("request_sha256", sa.String(length=64), nullable=False), + sa.Column("snapshot", sa.JSON(), nullable=False), + sa.Column("details", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["ticket_id"], ["tickets.id"], name=op.f("fk_ticket_history_ticket_id_tickets"), ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_ticket_history")), + sa.UniqueConstraint("tenant_id", "ticket_id", "revision", name="uq_ticket_history_revision"), + sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_ticket_history_idempotency"), + ) + for column in ("tenant_id", "ticket_id", "event_type", "occurred_at", "actor_id"): + op.create_index(op.f(f"ix_ticket_history_{column}"), "ticket_history", [column], unique=False) + op.create_index("ix_ticket_history_timeline", "ticket_history", ["tenant_id", "ticket_id", "occurred_at"], unique=False) + + op.create_table( + "ticket_comments", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=255), nullable=False), + sa.Column("ticket_id", sa.String(length=255), nullable=False), + sa.Column("comment_id", sa.String(length=255), nullable=False), + sa.Column("ticket_revision", sa.Integer(), nullable=False), + sa.Column("visibility", sa.String(length=40), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["ticket_id"], ["tickets.id"], name=op.f("fk_ticket_comments_ticket_id_tickets"), ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_ticket_comments")), + sa.UniqueConstraint("tenant_id", "comment_id", name="uq_ticket_comment"), + ) + for column in ("tenant_id", "ticket_id", "comment_id", "visibility", "created_by"): + op.create_index(op.f(f"ix_ticket_comments_{column}"), "ticket_comments", [column], unique=False) + op.create_index("ix_ticket_comment_timeline", "ticket_comments", ["tenant_id", "ticket_id", "created_at"], unique=False) + + op.create_table( + "ticket_escalations", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=255), nullable=False), + sa.Column("ticket_id", sa.String(length=255), nullable=False), + sa.Column("provider_id", sa.String(length=200), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("request_sha256", sa.String(length=64), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("actor_id", sa.String(length=255), nullable=True), + sa.Column("case_id", sa.String(length=255), nullable=False), + sa.Column("case_number", sa.String(length=255), nullable=False), + sa.Column("case_url", sa.String(length=1500), nullable=False), + sa.Column("handoff_note", sa.Text(), nullable=True), + sa.Column("outcome", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["ticket_id"], ["tickets.id"], name=op.f("fk_ticket_escalations_ticket_id_tickets"), ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_ticket_escalations")), + sa.UniqueConstraint("tenant_id", "ticket_id", "idempotency_key", name="uq_ticket_escalation_replay"), + sa.UniqueConstraint("tenant_id", "provider_id", "case_id", name="uq_ticket_case_link"), + ) + for column in ("tenant_id", "ticket_id", "provider_id", "occurred_at", "actor_id", "case_id"): + op.create_index(op.f(f"ix_ticket_escalations_{column}"), "ticket_escalations", [column], unique=False) + op.create_index("ix_ticket_escalation_timeline", "ticket_escalations", ["tenant_id", "ticket_id", "occurred_at"], unique=False) + + +def downgrade() -> None: + op.drop_table("ticket_escalations") + op.drop_table("ticket_comments") + op.drop_table("ticket_history") + op.drop_table("tickets") diff --git a/src/govoplan_tickets/backend/migrations/versions/__init__.py b/src/govoplan_tickets/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..daa2de5 --- /dev/null +++ b/src/govoplan_tickets/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Tickets migration revisions.""" diff --git a/src/govoplan_tickets/backend/router.py b/src/govoplan_tickets/backend/router.py new file mode 100644 index 0000000..94c3d27 --- /dev/null +++ b/src/govoplan_tickets/backend/router.py @@ -0,0 +1,445 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.core.concurrency import strong_resource_etag +from govoplan_core.db.session import get_session +from govoplan_tickets.backend.domain import ( + TicketDomainError, + TicketLink, + TicketRecord, + TicketSubjectRef, +) +from govoplan_tickets.backend.schemas import ( + TicketAssignmentRequest, + TicketCommentMutationResponse, + TicketCommentRequest, + TicketCommentsResponse, + TicketCreateRequest, + TicketDeleteRequest, + TicketEscalationRequest, + TicketEscalationResponse, + TicketHistoryResponse, + TicketLinkRequest, + TicketListResponse, + TicketParticipantsRequest, + TicketResolutionRequest, + TicketTriageRequest, +) +from govoplan_tickets.backend.service import ( + ADMIN_SCOPE, + LEGACY_WRITE_SCOPE, + READ_SCOPE, + REPORT_SCOPE, + TicketConflictError, + TicketIntegrationUnavailableError, + TicketNotFoundError, + TicketStoreError, + add_ticket_comment, + add_ticket_link, + assign_ticket, + create_ticket, + delete_ticket, + escalate_ticket_to_case, + get_ticket, + integration_availability, + list_ticket_comments, + list_tickets, + remove_ticket_link, + replace_participants, + resolve_ticket, + ticket_history, + triage_ticket, +) + + +def create_router(registry: object | None) -> APIRouter: + router = APIRouter(prefix="/tickets", tags=["tickets"]) + + @router.get("/availability", response_model=dict) + def api_availability( + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict: + _require(principal, READ_SCOPE) + return integration_availability(registry) + + @router.get("", response_model=TicketListResponse) + def api_list_tickets( + ticket_status: list[str] | None = Query(default=None, alias="status"), + priority: list[str] | None = Query(default=None), + ticket_type: list[str] | None = Query(default=None), + queue_ref: str | None = Query(default=None, max_length=255), + query: str = Query(default="", max_length=500), + include_deleted: bool = False, + offset: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> TicketListResponse: + _require(principal, READ_SCOPE) + if include_deleted: + _require(principal, ADMIN_SCOPE) + try: + items, total = list_tickets( + session, + principal, + statuses=ticket_status or (), + priorities=priority or (), + ticket_types=ticket_type or (), + queue_ref=queue_ref, + query=query, + include_deleted=include_deleted, + offset=offset, + limit=limit, + ) + except (TicketStoreError, TicketDomainError) as exc: + raise _error(exc) from exc + return TicketListResponse( + tickets=[item.to_dict() for item in items], + total=total, + offset=offset, + limit=limit, + ) + + @router.post("", response_model=dict, status_code=status.HTTP_201_CREATED) + def api_create_ticket( + payload: TicketCreateRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict: + _require_any(principal, REPORT_SCOPE, ADMIN_SCOPE, LEGACY_WRITE_SCOPE) + return _write_ticket( + session, + response, + lambda: create_ticket( + session, + principal, + record=TicketRecord.from_mapping(payload.record), + idempotency_key=payload.idempotency_key, + registry=registry, + ), + ) + + @router.get("/{ticket_id}", response_model=dict) + def api_get_ticket( + ticket_id: str, + response: Response, + include_deleted: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict: + _require(principal, READ_SCOPE) + if include_deleted: + _require(principal, ADMIN_SCOPE) + item = get_ticket( + session, + principal, + ticket_id=ticket_id, + include_deleted=include_deleted, + ) + if item is None: + raise HTTPException(status_code=404, detail="Ticket not found") + _etag(response, item) + return item.to_dict() + + @router.patch("/{ticket_id}/triage", response_model=dict) + def api_triage_ticket( + ticket_id: str, + payload: TicketTriageRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict: + return _write_ticket( + session, + response, + lambda: triage_ticket( + session, + principal, + ticket_id=ticket_id, + expected_revision=payload.expected_revision, + changes=payload.changes, + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + idempotency_key=payload.idempotency_key, + registry=registry, + ), + ) + + @router.post("/{ticket_id}/assignment", response_model=dict) + def api_assign_ticket( + ticket_id: str, + payload: TicketAssignmentRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict: + return _write_ticket( + session, + response, + lambda: assign_ticket( + session, + principal, + ticket_id=ticket_id, + expected_revision=payload.expected_revision, + assignee=(TicketSubjectRef.from_mapping(payload.assignee) if payload.assignee else None), + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + idempotency_key=payload.idempotency_key, + registry=registry, + ), + ) + + @router.post("/{ticket_id}/resolution", response_model=dict) + def api_resolve_ticket( + ticket_id: str, + payload: TicketResolutionRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict: + return _write_ticket( + session, + response, + lambda: resolve_ticket( + session, + principal, + ticket_id=ticket_id, + expected_revision=payload.expected_revision, + target_status=payload.target_status, + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + idempotency_key=payload.idempotency_key, + resolution_summary=payload.resolution_summary, + registry=registry, + ), + ) + + @router.put("/{ticket_id}/participants", response_model=dict) + def api_replace_participants( + ticket_id: str, + payload: TicketParticipantsRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict: + return _write_ticket( + session, + response, + lambda: replace_participants( + session, + principal, + ticket_id=ticket_id, + expected_revision=payload.expected_revision, + participants=tuple(TicketSubjectRef.from_mapping(item) for item in payload.participants), + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + idempotency_key=payload.idempotency_key, + registry=registry, + ), + ) + + @router.post("/{ticket_id}/links", response_model=dict) + def api_add_link( + ticket_id: str, + payload: TicketLinkRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict: + return _write_ticket( + session, + response, + lambda: add_ticket_link( + session, + principal, + ticket_id=ticket_id, + expected_revision=payload.expected_revision, + link=TicketLink.from_mapping(payload.link), + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + idempotency_key=payload.idempotency_key, + registry=registry, + ), + ) + + @router.delete("/{ticket_id}/links/{link_id}", response_model=dict) + def api_remove_link( + ticket_id: str, + link_id: str, + payload: TicketTriageRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict: + if payload.changes: + raise HTTPException(status_code=400, detail="Link removal changes must be empty") + return _write_ticket( + session, + response, + lambda: remove_ticket_link( + session, + principal, + ticket_id=ticket_id, + link_id=link_id, + expected_revision=payload.expected_revision, + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + idempotency_key=payload.idempotency_key, + registry=registry, + ), + ) + + @router.get("/{ticket_id}/comments", response_model=TicketCommentsResponse) + def api_ticket_comments( + ticket_id: str, + limit: int = Query(default=200, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> TicketCommentsResponse: + _require(principal, READ_SCOPE) + try: + items = list_ticket_comments(session, principal, ticket_id=ticket_id, limit=limit) + except (TicketStoreError, TicketNotFoundError, PermissionError) as exc: + raise _error(exc) from exc + return TicketCommentsResponse(comments=list(items)) + + @router.post("/{ticket_id}/comments", response_model=TicketCommentMutationResponse) + def api_add_comment( + ticket_id: str, + payload: TicketCommentRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> TicketCommentMutationResponse: + try: + item, comment = add_ticket_comment( + session, + principal, + ticket_id=ticket_id, + expected_revision=payload.expected_revision, + comment_id=payload.comment_id, + body=payload.body, + visibility=payload.visibility, + recorded_at=payload.recorded_at, + idempotency_key=payload.idempotency_key, + registry=registry, + ) + session.commit() + except (TicketStoreError, TicketDomainError, TicketNotFoundError, PermissionError, IntegrityError) as exc: + session.rollback() + raise _error(exc) from exc + _etag(response, item) + return TicketCommentMutationResponse(ticket=item.to_dict(), comment=comment) + + @router.get("/{ticket_id}/history", response_model=TicketHistoryResponse) + def api_ticket_history( + ticket_id: str, + limit: int = Query(default=200, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> TicketHistoryResponse: + _require(principal, READ_SCOPE) + try: + items = ticket_history(session, principal, ticket_id=ticket_id, limit=limit) + except (TicketStoreError, TicketNotFoundError, PermissionError) as exc: + raise _error(exc) from exc + return TicketHistoryResponse(history=list(items)) + + @router.post("/{ticket_id}/case-escalations", response_model=TicketEscalationResponse) + def api_escalate_ticket( + ticket_id: str, + payload: TicketEscalationRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> TicketEscalationResponse: + try: + item, escalation = escalate_ticket_to_case( + session, + principal, + ticket_id=ticket_id, + expected_revision=payload.expected_revision, + case_type_key=payload.case_type_key, + occurred_at=payload.occurred_at, + handoff_note=payload.handoff_note, + idempotency_key=payload.idempotency_key, + registry=registry, + ) + session.commit() + except (TicketStoreError, TicketDomainError, TicketNotFoundError, PermissionError, IntegrityError) as exc: + session.rollback() + raise _error(exc) from exc + _etag(response, item) + return TicketEscalationResponse(ticket=item.to_dict(), escalation=escalation) + + @router.delete("/{ticket_id}", response_model=dict) + def api_delete_ticket( + ticket_id: str, + payload: TicketDeleteRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict: + return _write_ticket( + session, + response, + lambda: delete_ticket( + session, + principal, + ticket_id=ticket_id, + expected_revision=payload.expected_revision, + occurred_at=payload.occurred_at, + reason=payload.reason, + idempotency_key=payload.idempotency_key, + registry=registry, + ), + ) + + return router + + +def _write_ticket(session: Session, response: Response, operation) -> dict: + try: + item = operation() + session.commit() + except (TicketStoreError, TicketDomainError, TicketNotFoundError, PermissionError, IntegrityError) as exc: + session.rollback() + raise _error(exc) from exc + _etag(response, item) + return item.to_dict() + + +def _etag(response: Response, item: TicketRecord) -> None: + response.headers["ETag"] = strong_resource_etag("ticket", item.ticket_id, item.revision) + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") + + +def _require_any(principal: ApiPrincipal, *scopes: str) -> None: + if not any(has_scope(principal, scope) for scope in scopes): + raise HTTPException(status_code=403, detail=f"Requires one of: {', '.join(scopes)}") + + +def _error(exc: Exception) -> HTTPException: + if isinstance(exc, TicketIntegrationUnavailableError): + code = 503 + elif isinstance(exc, TicketNotFoundError): + code = 404 + elif isinstance(exc, PermissionError): + code = 403 + elif isinstance(exc, (TicketConflictError, IntegrityError)) or "conflict" in str(exc).casefold(): + code = 409 + else: + code = 400 + return HTTPException(status_code=code, detail=str(exc)) + + +__all__ = ["create_router"] diff --git a/src/govoplan_tickets/backend/schemas.py b/src/govoplan_tickets/backend/schemas.py new file mode 100644 index 0000000..48a071a --- /dev/null +++ b/src/govoplan_tickets/backend/schemas.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class TicketCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + record: dict[str, Any] + idempotency_key: str = Field(min_length=1, max_length=255) + + +class TicketMutationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + expected_revision: int = Field(ge=1) + recorded_at: datetime + change_reason: str = Field(min_length=1, max_length=1_000) + idempotency_key: str = Field(min_length=1, max_length=255) + + +class TicketTriageRequest(TicketMutationRequest): + changes: dict[str, Any] + + +class TicketAssignmentRequest(TicketMutationRequest): + assignee: dict[str, Any] | None = None + + +class TicketResolutionRequest(TicketMutationRequest): + target_status: str = Field(min_length=1, max_length=40) + resolution_summary: str | None = Field(default=None, max_length=20_000) + + +class TicketParticipantsRequest(TicketMutationRequest): + participants: list[dict[str, Any]] = Field(max_length=100) + + +class TicketLinkRequest(TicketMutationRequest): + link: dict[str, Any] + + +class TicketCommentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + expected_revision: int = Field(ge=1) + comment_id: str = Field(min_length=1, max_length=255) + body: str = Field(min_length=1, max_length=20_000) + visibility: str = Field(default="internal", max_length=40) + recorded_at: datetime + idempotency_key: str = Field(min_length=1, max_length=255) + + +class TicketEscalationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + expected_revision: int = Field(ge=1) + case_type_key: str = Field(min_length=1, max_length=120) + occurred_at: datetime + handoff_note: str | None = Field(default=None, max_length=10_000) + idempotency_key: str = Field(min_length=1, max_length=255) + + +class TicketDeleteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + expected_revision: int = Field(ge=1) + occurred_at: datetime + reason: str = Field(min_length=1, max_length=1_000) + idempotency_key: str = Field(min_length=1, max_length=255) + + +class TicketListResponse(BaseModel): + tickets: list[dict[str, Any]] + total: int + offset: int + limit: int + + +class TicketCommentsResponse(BaseModel): + comments: list[dict[str, Any]] + + +class TicketHistoryResponse(BaseModel): + history: list[dict[str, Any]] + + +class TicketCommentMutationResponse(BaseModel): + ticket: dict[str, Any] + comment: dict[str, Any] + + +class TicketEscalationResponse(BaseModel): + ticket: dict[str, Any] + escalation: dict[str, Any] + + +__all__ = [ + "TicketAssignmentRequest", + "TicketCommentMutationResponse", + "TicketCommentRequest", + "TicketCommentsResponse", + "TicketCreateRequest", + "TicketDeleteRequest", + "TicketEscalationRequest", + "TicketEscalationResponse", + "TicketHistoryResponse", + "TicketLinkRequest", + "TicketListResponse", + "TicketParticipantsRequest", + "TicketResolutionRequest", + "TicketTriageRequest", +] diff --git a/src/govoplan_tickets/backend/search_source.py b/src/govoplan_tickets/backend/search_source.py new file mode 100644 index 0000000..a9dcfe9 --- /dev/null +++ b/src/govoplan_tickets/backend/search_source.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from urllib.parse import quote + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from govoplan_core.core.events import PlatformEvent +from govoplan_core.core.modules import ModuleContext +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillPage, + SearchBackfillRequest, + SearchDocument, + SearchIndexChange, + SearchResourceReference, + SearchResourceType, +) +from govoplan_tickets.backend.db.models import Ticket +from govoplan_tickets.backend.service import ( + ADMIN_SCOPE, + ASSIGN_SCOPE, + READ_SCOPE, + RESOLVE_SCOPE, + TRIAGE_SCOPE, + can_read_ticket, +) + + +PROVIDER_ID = "tickets.tickets" +RESOURCE_TYPE = "ticket" + + +class TicketsSearchSource: + def resource_types(self) -> Sequence[SearchResourceType]: + return ( + SearchResourceType( + provider_id=PROVIDER_ID, + module_id="tickets", + resource_type=RESOURCE_TYPE, + label="Tickets", + requires_authorization_recheck=True, + ), + ) + + def backfill(self, session: object, *, request: SearchBackfillRequest) -> SearchBackfillPage: + _assert_source(request.provider_id, request.resource_type) + db = _session(session) + query = select(Ticket).where( + Ticket.tenant_id == request.tenant_id, + Ticket.deleted_at.is_(None), + ) + if request.cursor: + query = query.where(Ticket.id > request.cursor) + rows = tuple(db.scalars(query.order_by(Ticket.id.asc()).limit(request.limit + 1))) + has_more = len(rows) > request.limit + selected = rows[: request.limit] + high_watermark = db.scalar( + select(func.max(Ticket.updated_at)).where( + Ticket.tenant_id == request.tenant_id, + Ticket.deleted_at.is_(None), + ) + ) + return SearchBackfillPage( + documents=tuple(_document(row) for row in selected), + next_cursor=selected[-1].id if has_more and selected else None, + complete=not has_more, + high_watermark=high_watermark.isoformat() if high_watermark else None, + ) + + def authorize( + self, + session: object, + principal: object, + *, + requests: Sequence[SearchAuthorizationRequest], + ) -> Mapping[str, bool]: + decisions = {item.reference.key: False for item in requests} + db = _session(session) + tenant_id = str(getattr(principal, "tenant_id", "") or "") + for request in requests: + reference = request.reference + if ( + reference.tenant_id != tenant_id + or reference.module_id != "tickets" + or reference.resource_type != RESOURCE_TYPE + ): + continue + decisions[reference.key] = can_read_ticket( + db, + principal, + ticket_id=reference.resource_id, + ) + return decisions + + def index_changes_for_event( + self, + session: object, + *, + event: PlatformEvent, + delivery_key: str, + ) -> Sequence[SearchIndexChange]: + if ( + event.module_id != "tickets" + or event.tenant is None + or event.resource is None + or event.resource.type != RESOURCE_TYPE + or event.resource.id is None + ): + return () + db = _session(session) + row = db.scalar( + select(Ticket).where( + Ticket.tenant_id == event.tenant.id, + Ticket.id == event.resource.id, + ) + ) + deleted = row is None or row.deleted_at is not None + cursor = event.event_id + document = None if deleted else _document(row, change_cursor=cursor) + reference = SearchResourceReference( + tenant_id=event.tenant.id, + module_id="tickets", + resource_type=RESOURCE_TYPE, + resource_id=event.resource.id, + ) + return ( + SearchIndexChange( + change_id=f"{delivery_key}:{PROVIDER_ID}", + provider_id=PROVIDER_ID, + kind="delete" if deleted else "upsert", + reference=reference, + source_revision=document.source_revision if document else cursor, + cursor=cursor, + document=document, + occurred_at=event.occurred_at, + ), + ) + + +def create_tickets_search_source(_context: ModuleContext) -> TicketsSearchSource: + return TicketsSearchSource() + + +def _document(row: Ticket, *, change_cursor: str | None = None) -> SearchDocument: + tokens = [ + f"scope:{READ_SCOPE}", + f"scope:{TRIAGE_SCOPE}", + f"scope:{ASSIGN_SCOPE}", + f"scope:{RESOLVE_SCOPE}", + f"scope:{ADMIN_SCOPE}", + ] + if row.created_by: + tokens.append(f"account:{row.created_by}") + for value in (row.assignee, row.reporter, row.requester, *(row.participants or [])): + if not isinstance(value, Mapping): + continue + kind = str(value.get("kind") or "") + subject_id = str(value.get("id") or "") + prefix = "function" if kind == "function_assignment" else kind + if prefix and subject_id and prefix != "external": + tokens.append(f"{prefix}:{subject_id}") + return SearchDocument( + tenant_id=row.tenant_id, + module_id="tickets", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + resource_id=row.id, + title=row.title, + url=f"/tickets?ticketId={quote(row.id, safe='')}", + summary=f"{row.ticket_number} · {row.status} · {row.priority}", + body=row.search_text[:200_000], + keywords=tuple( + item[:200] + for item in (row.ticket_number, row.ticket_type, row.status, row.priority, row.queue_ref or "") + if item + ), + visibility=row.visibility, + acl_tokens=tuple(dict.fromkeys(tokens)) if row.visibility == "restricted" else (), + metadata={ + "ticket_number": row.ticket_number, + "ticket_type": row.ticket_type, + "status": row.status, + "priority": row.priority, + "queue_ref": row.queue_ref, + "service_target_at": row.service_target_at.isoformat() if row.service_target_at else None, + }, + source_revision=str(row.revision), + change_cursor=change_cursor, + source_updated_at=row.updated_at or row.recorded_at, + requires_authorization_recheck=True, + ) + + +def _assert_source(provider_id: str, resource_type: str) -> None: + if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE: + raise ValueError("Unsupported Tickets search source.") + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Tickets search requires a SQLAlchemy session.") + return value + + +__all__ = [ + "PROVIDER_ID", + "RESOURCE_TYPE", + "TicketsSearchSource", + "create_tickets_search_source", +] diff --git a/src/govoplan_tickets/backend/service.py b/src/govoplan_tickets/backend/service.py new file mode 100644 index 0000000..276f50b --- /dev/null +++ b/src/govoplan_tickets/backend/service.py @@ -0,0 +1,1161 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +import hashlib +import json +from typing import Any + +from sqlalchemy.orm import Session + +from govoplan_core.core.events import ( + EventActorRef, + EventObjectRef, + EventTenantRef, + PlatformEvent, + emit_platform_event, +) +from govoplan_core.core.tickets import ( + TicketCaseEscalationCommand, + TicketRoutingRequest, + ticket_case_escalation_provider, + ticket_routing_provider, +) +from govoplan_core.security.module_permissions import scopes_grant_compatible +from govoplan_tickets.backend.db.models import ( + Ticket, + TicketComment, + TicketEscalation, + TicketHistory, +) +from govoplan_tickets.backend.domain import ( + TicketLink, + TicketRecord, + TicketSubjectRef, + validate_transition, +) + + +CAPABILITY_TICKETS_REGISTRY = "tickets.registry" +READ_SCOPE = "tickets:ticket:read" +REPORT_SCOPE = "tickets:ticket:report" +TRIAGE_SCOPE = "tickets:ticket:triage" +ASSIGN_SCOPE = "tickets:ticket:assign" +RESOLVE_SCOPE = "tickets:ticket:resolve" +ADMIN_SCOPE = "tickets:ticket:admin" +LEGACY_WRITE_SCOPE = "tickets:ticket:write" + + +class TicketStoreError(ValueError): + pass + + +class TicketNotFoundError(LookupError): + pass + + +class TicketConflictError(TicketStoreError): + pass + + +class TicketIntegrationUnavailableError(TicketStoreError): + pass + + +def create_ticket( + session: Session, + principal: object, + *, + record: TicketRecord, + idempotency_key: str, + registry: object | None = None, +) -> TicketRecord: + if not _has_any_scope(principal, REPORT_SCOPE, ADMIN_SCOPE, LEGACY_WRITE_SCOPE): + raise PermissionError("Reporting a ticket requires ticket report access.") + tenant_id = _principal_tenant(principal) + if record.tenant_id != tenant_id: + raise TicketStoreError("Tickets cannot cross tenants.") + if record.revision != 1: + raise TicketStoreError("New tickets start at revision 1.") + clean_key = _bounded(idempotency_key, "Ticket idempotency key", 255) + request_sha256 = _request_sha256(record.to_dict()) + replay = _history_replay(session, tenant_id, clean_key, request_sha256) + if replay is not None: + return TicketRecord.from_mapping(replay.snapshot) + if session.get(Ticket, record.ticket_id) is not None: + raise TicketConflictError("A ticket with this identifier already exists.") + if ( + session.query(Ticket.id) + .filter( + Ticket.tenant_id == tenant_id, + Ticket.ticket_number == record.ticket_number, + ) + .first() + is not None + ): + raise TicketConflictError("A ticket with this number already exists.") + + routed = _apply_routing(session, principal, record=record, registry=registry) + actor_id = _principal_actor(principal) + row = Ticket(id=routed.ticket_id, tenant_id=tenant_id, ticket_number=routed.ticket_number) + _write_row(row, routed) + row.created_by = actor_id + row.updated_by = actor_id + session.add(row) + session.flush() + _append_history( + session, + row=row, + record=routed, + event_type="reported", + actor_id=actor_id, + reason=routed.change_reason, + idempotency_key=clean_key, + request_sha256=request_sha256, + details={"routing": routed.metadata.get("routing")}, + ) + _emit(session, row, routed, operation="reported", registry=registry) + return routed + + +def get_ticket( + session: Session, + principal: object, + *, + ticket_id: str, + include_deleted: bool = False, +) -> TicketRecord | None: + row = _ticket_row(session, _principal_tenant(principal), ticket_id) + if row is None or (row.deleted_at is not None and not include_deleted): + return None + if not can_read_ticket(session, principal, ticket_id=ticket_id): + return None + return _record(row) + + +def list_tickets( + session: Session, + principal: object, + *, + statuses: Sequence[str] = (), + priorities: Sequence[str] = (), + ticket_types: Sequence[str] = (), + queue_ref: str | None = None, + query: str = "", + include_deleted: bool = False, + offset: int = 0, + limit: int = 100, +) -> tuple[tuple[TicketRecord, ...], int]: + tenant_id = _principal_tenant(principal) + if offset < 0 or not 1 <= limit <= 200: + raise TicketStoreError("Ticket list offset must be non-negative and limit between 1 and 200.") + statement = session.query(Ticket).filter(Ticket.tenant_id == tenant_id) + if not include_deleted: + statement = statement.filter(Ticket.deleted_at.is_(None)) + if statuses: + statement = statement.filter(Ticket.status.in_(tuple(dict.fromkeys(statuses)))) + if priorities: + statement = statement.filter(Ticket.priority.in_(tuple(dict.fromkeys(priorities)))) + if ticket_types: + statement = statement.filter(Ticket.ticket_type.in_(tuple(dict.fromkeys(ticket_types)))) + if queue_ref: + statement = statement.filter(Ticket.queue_ref == queue_ref) + clean_query = query.strip().casefold() + if clean_query: + statement = statement.filter(Ticket.search_text.contains(clean_query)) + candidates = statement.order_by( + Ticket.service_target_at.asc().nullslast(), + Ticket.priority.desc(), + Ticket.updated_at.desc(), + ).all() + accessible = tuple(row for row in candidates if _can_read_row(principal, row)) + selected = accessible[offset : offset + limit] + return tuple(_record(row) for row in selected), len(accessible) + + +def triage_ticket( + session: Session, + principal: object, + *, + ticket_id: str, + expected_revision: int, + changes: Mapping[str, object], + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> TicketRecord: + allowed = { + "ticket_type", + "priority", + "status", + "title", + "description", + "visibility", + "queue_ref", + "service_target_at", + "reporter", + "requester", + "participants", + "metadata", + } + normalized = _only_changes(changes, allowed) + status = normalized.get("status") + if status in {"resolved", "closed"}: + raise TicketStoreError("Resolve or close tickets through the resolution action.") + return _mutate( + session, + principal, + ticket_id=ticket_id, + expected_revision=expected_revision, + changes=normalized, + recorded_at=recorded_at, + change_reason=change_reason, + idempotency_key=idempotency_key, + event_type="triaged", + required_scopes=(TRIAGE_SCOPE,), + registry=registry, + ) + + +def assign_ticket( + session: Session, + principal: object, + *, + ticket_id: str, + expected_revision: int, + assignee: TicketSubjectRef | None, + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> TicketRecord: + return _mutate( + session, + principal, + ticket_id=ticket_id, + expected_revision=expected_revision, + changes={"assignee": assignee.to_dict() if assignee else None}, + recorded_at=recorded_at, + change_reason=change_reason, + idempotency_key=idempotency_key, + event_type="assigned" if assignee else "unassigned", + required_scopes=(ASSIGN_SCOPE,), + registry=registry, + ) + + +def resolve_ticket( + session: Session, + principal: object, + *, + ticket_id: str, + expected_revision: int, + target_status: str, + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + resolution_summary: str | None = None, + registry: object | None = None, +) -> TicketRecord: + if target_status not in {"in_progress", "waiting", "resolved", "closed", "cancelled"}: + raise TicketStoreError("Unsupported ticket resolution action.") + changes: dict[str, object] = {"status": target_status} + if target_status in {"resolved", "closed"}: + changes["resolution_summary"] = _bounded( + resolution_summary, + "Ticket resolution summary", + 20_000, + ) + changes["resolved_at"] = recorded_at + elif target_status == "in_progress": + changes["resolution_summary"] = None + changes["resolved_at"] = None + return _mutate( + session, + principal, + ticket_id=ticket_id, + expected_revision=expected_revision, + changes=changes, + recorded_at=recorded_at, + change_reason=change_reason, + idempotency_key=idempotency_key, + event_type=("reopened" if target_status == "in_progress" else target_status), + required_scopes=(RESOLVE_SCOPE,), + registry=registry, + ) + + +def replace_participants( + session: Session, + principal: object, + *, + ticket_id: str, + expected_revision: int, + participants: Sequence[TicketSubjectRef], + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> TicketRecord: + return _mutate( + session, + principal, + ticket_id=ticket_id, + expected_revision=expected_revision, + changes={"participants": [item.to_dict() for item in participants]}, + recorded_at=recorded_at, + change_reason=change_reason, + idempotency_key=idempotency_key, + event_type="participants_changed", + required_scopes=(TRIAGE_SCOPE,), + registry=registry, + ) + + +def add_ticket_link( + session: Session, + principal: object, + *, + ticket_id: str, + expected_revision: int, + link: TicketLink, + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> TicketRecord: + current = _required_ticket(session, principal, ticket_id=ticket_id, lock=True) + record = _record(current) + if any(item.link_id == link.link_id for item in record.links): + raise TicketConflictError("A ticket link with this identifier already exists.") + return _mutate_locked( + session, + principal, + row=current, + expected_revision=expected_revision, + changes={"links": [item.to_dict() for item in (*record.links, link)]}, + recorded_at=recorded_at, + change_reason=change_reason, + idempotency_key=idempotency_key, + event_type="link_added", + required_scopes=(TRIAGE_SCOPE,), + details={"link": link.to_dict()}, + registry=registry, + ) + + +def remove_ticket_link( + session: Session, + principal: object, + *, + ticket_id: str, + link_id: str, + expected_revision: int, + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> TicketRecord: + current = _required_ticket(session, principal, ticket_id=ticket_id, lock=True) + record = _record(current) + selected = next((item for item in record.links if item.link_id == link_id), None) + if selected is None: + raise TicketNotFoundError("Ticket link not found.") + return _mutate_locked( + session, + principal, + row=current, + expected_revision=expected_revision, + changes={"links": [item.to_dict() for item in record.links if item.link_id != link_id]}, + recorded_at=recorded_at, + change_reason=change_reason, + idempotency_key=idempotency_key, + event_type="link_removed", + required_scopes=(TRIAGE_SCOPE,), + details={"link": selected.to_dict()}, + registry=registry, + ) + + +def add_ticket_comment( + session: Session, + principal: object, + *, + ticket_id: str, + expected_revision: int, + comment_id: str, + body: str, + visibility: str, + recorded_at: datetime, + idempotency_key: str, + registry: object | None = None, +) -> tuple[TicketRecord, dict[str, object]]: + if visibility not in {"external", "internal"}: + raise TicketStoreError("Ticket comments are external or internal.") + if visibility == "internal" and not _has_any_scope(principal, TRIAGE_SCOPE, ADMIN_SCOPE, LEGACY_WRITE_SCOPE): + raise PermissionError("Internal ticket comments require triage access.") + if visibility == "external" and not _has_any_scope( + principal, + REPORT_SCOPE, + TRIAGE_SCOPE, + ADMIN_SCOPE, + LEGACY_WRITE_SCOPE, + ): + raise PermissionError("External ticket comments require report access.") + row = _required_ticket(session, principal, ticket_id=ticket_id, lock=True) + if not _can_read_row(principal, row): + raise PermissionError("Ticket comment access is denied.") + clean_comment_id = _bounded(comment_id, "Ticket comment identifier", 255) + clean_body = _bounded(body, "Ticket comment", 20_000) + record = _mutate_locked( + session, + principal, + row=row, + expected_revision=expected_revision, + changes={}, + recorded_at=recorded_at, + change_reason="Added a ticket comment.", + idempotency_key=idempotency_key, + event_type="comment_added", + required_scopes=(), + details={"comment_id": clean_comment_id, "visibility": visibility}, + registry=registry, + ) + existing = ( + session.query(TicketComment) + .filter( + TicketComment.tenant_id == record.tenant_id, + TicketComment.comment_id == clean_comment_id, + ) + .one_or_none() + ) + if existing is None: + existing = TicketComment( + tenant_id=record.tenant_id, + ticket_id=record.ticket_id, + comment_id=clean_comment_id, + ticket_revision=record.revision, + visibility=visibility, + body=clean_body, + created_by=_principal_actor(principal), + created_at=recorded_at, + updated_at=recorded_at, + ) + session.add(existing) + session.flush() + elif existing.ticket_id != record.ticket_id or existing.body != clean_body or existing.visibility != visibility: + raise TicketConflictError("Ticket comment identifier was reused for another comment.") + return record, _comment_payload(existing) + + +def list_ticket_comments( + session: Session, + principal: object, + *, + ticket_id: str, + limit: int = 200, +) -> tuple[dict[str, object], ...]: + if not 1 <= limit <= 500: + raise TicketStoreError("Ticket comment limit must be between 1 and 500.") + row = _required_ticket(session, principal, ticket_id=ticket_id) + if not _can_read_row(principal, row): + raise PermissionError("Ticket comment access is denied.") + query = session.query(TicketComment).filter( + TicketComment.tenant_id == row.tenant_id, + TicketComment.ticket_id == row.id, + ) + if not _has_any_scope(principal, TRIAGE_SCOPE, ADMIN_SCOPE, LEGACY_WRITE_SCOPE): + query = query.filter(TicketComment.visibility == "external") + rows = query.order_by(TicketComment.created_at.asc(), TicketComment.id.asc()).limit(limit).all() + return tuple(_comment_payload(item) for item in rows) + + +def ticket_history( + session: Session, + principal: object, + *, + ticket_id: str, + limit: int = 200, +) -> tuple[dict[str, object], ...]: + if not 1 <= limit <= 500: + raise TicketStoreError("Ticket history limit must be between 1 and 500.") + row = _required_ticket(session, principal, ticket_id=ticket_id) + if not _can_read_row(principal, row): + raise PermissionError("Ticket history access is denied.") + items = ( + session.query(TicketHistory) + .filter(TicketHistory.tenant_id == row.tenant_id, TicketHistory.ticket_id == row.id) + .order_by(TicketHistory.revision.desc()) + .limit(limit) + .all() + ) + disclose_details = _has_any_scope(principal, TRIAGE_SCOPE, ADMIN_SCOPE, LEGACY_WRITE_SCOPE) + return tuple( + { + "revision": item.revision, + "event_type": item.event_type, + "occurred_at": _iso(item.occurred_at), + "actor_id": item.actor_id, + "reason": item.reason, + "details": dict(item.details or {}) if disclose_details else {}, + } + for item in items + ) + + +def escalate_ticket_to_case( + session: Session, + principal: object, + *, + ticket_id: str, + expected_revision: int, + case_type_key: str, + occurred_at: datetime, + handoff_note: str | None, + idempotency_key: str, + registry: object | None, +) -> tuple[TicketRecord, dict[str, object]]: + if not _has_any_scope(principal, TRIAGE_SCOPE, ADMIN_SCOPE, LEGACY_WRITE_SCOPE): + raise PermissionError("Ticket escalation requires triage access.") + provider = ticket_case_escalation_provider(registry) + if provider is None: + raise TicketIntegrationUnavailableError("Cases escalation is not available in this installation.") + row = _required_ticket(session, principal, ticket_id=ticket_id, lock=True) + clean_key = _bounded(idempotency_key, "Ticket escalation idempotency key", 255) + request = { + "ticket_id": ticket_id, + "expected_revision": expected_revision, + "case_type_key": case_type_key, + "occurred_at": occurred_at, + "handoff_note": handoff_note, + } + request_sha256 = _request_sha256(request) + replay = ( + session.query(TicketEscalation) + .filter( + TicketEscalation.tenant_id == row.tenant_id, + TicketEscalation.ticket_id == row.id, + TicketEscalation.idempotency_key == clean_key, + ) + .one_or_none() + ) + if replay is not None: + if replay.request_sha256 != request_sha256: + raise TicketConflictError("Ticket escalation idempotency key was reused for another request.") + return _record(row), _escalation_payload(replay, replayed=True) + if row.revision != expected_revision: + raise TicketConflictError("Ticket revision conflict: the expected revision is stale.") + current = _record(row) + command = TicketCaseEscalationCommand( + tenant_id=row.tenant_id, + ticket_id=row.id, + ticket_number=row.ticket_number, + title=current.title, + case_type_key=_bounded(case_type_key, "Case type key", 120), + occurred_at=_aware(occurred_at, "Ticket escalation occurred_at"), + idempotency_key=clean_key, + handoff_note=_optional_bounded(handoff_note, "Ticket escalation handoff note", 10_000), + metadata={"ticket_revision": current.revision}, + ) + try: + result = provider.escalate_ticket(session, principal, command=command) + except PermissionError: + raise + except ValueError as exc: + raise TicketStoreError(str(exc)) from exc + link = TicketLink( + link_id=f"case:{result.provider_id}:{result.case_id}", + kind="case", + owner_module="cases", + resource_type="case", + resource_id=result.case_id, + relation="escalated_to", + label=result.case_number, + url=result.case_url, + metadata={"provider_id": result.provider_id}, + ) + if any(item.link_id == link.link_id for item in current.links): + links = current.links + else: + links = (*current.links, link) + updated = _mutate_locked( + session, + principal, + row=row, + expected_revision=expected_revision, + changes={"links": [item.to_dict() for item in links]}, + recorded_at=occurred_at, + change_reason=f"Escalated ticket to case {result.case_number}.", + idempotency_key=f"ticket-case-link:{clean_key}", + event_type="case_escalated", + required_scopes=(), + details={"case_id": result.case_id, "case_number": result.case_number}, + registry=registry, + ) + escalation = TicketEscalation( + tenant_id=row.tenant_id, + ticket_id=row.id, + provider_id=result.provider_id, + idempotency_key=clean_key, + request_sha256=request_sha256, + occurred_at=occurred_at, + actor_id=_principal_actor(principal), + case_id=result.case_id, + case_number=result.case_number, + case_url=result.case_url, + handoff_note=command.handoff_note, + outcome={"replayed_by_provider": result.replayed, **dict(result.metadata)}, + ) + session.add(escalation) + session.flush() + return updated, _escalation_payload(escalation, replayed=False) + + +def delete_ticket( + session: Session, + principal: object, + *, + ticket_id: str, + expected_revision: int, + occurred_at: datetime, + reason: str, + idempotency_key: str, + registry: object | None = None, +) -> TicketRecord: + return _mutate( + session, + principal, + ticket_id=ticket_id, + expected_revision=expected_revision, + changes={"deleted_at": occurred_at}, + recorded_at=occurred_at, + change_reason=reason, + idempotency_key=idempotency_key, + event_type="deleted", + required_scopes=(ADMIN_SCOPE,), + registry=registry, + ) + + +def can_read_ticket(session: Session, principal: object, *, ticket_id: str) -> bool: + row = _ticket_row(session, _principal_tenant(principal), ticket_id) + return row is not None and _can_read_row(principal, row) + + +def integration_availability(registry: object | None) -> dict[str, object]: + routing = ticket_routing_provider(registry) + escalation = ticket_case_escalation_provider(registry) + active = _active_modules(registry) + return { + "routing": {"available": routing is not None, "provider": type(routing).__name__ if routing else None}, + "case_escalation": {"available": escalation is not None, "provider": type(escalation).__name__ if escalation else None}, + "modules": { + name: name in active + for name in ("cases", "helpdesk", "projects", "wiki", "files", "search") + }, + "consequences": { + "cases": "Escalation is disabled; tickets remain independently resolvable.", + "helpdesk": "Manual queue and target selection remains available; automatic routing is disabled.", + "projects": "Project links remain typed references and are not validated by Tickets.", + "wiki": "Wiki links remain typed references and are not validated by Tickets.", + "files": "Attachments remain external file references; Tickets stores no file bytes.", + "search": "Ticket APIs remain usable; global indexing and discovery are disabled.", + }, + } + + +class SqlTicketRegistry: + def __init__(self, registry: object | None = None) -> None: + self.registry = registry + + def create_ticket(self, session: object, principal: object, *, record: TicketRecord, idempotency_key: str) -> TicketRecord: + return create_ticket(_session(session), principal, record=record, idempotency_key=idempotency_key, registry=self.registry) + + def get_ticket(self, session: object, principal: object, *, ticket_id: str) -> TicketRecord | None: + return get_ticket(_session(session), principal, ticket_id=ticket_id) + + +def _mutate( + session: Session, + principal: object, + *, + ticket_id: str, + expected_revision: int, + changes: Mapping[str, object], + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + event_type: str, + required_scopes: Sequence[str], + details: Mapping[str, object] | None = None, + registry: object | None, +) -> TicketRecord: + row = _required_ticket(session, principal, ticket_id=ticket_id, lock=True) + return _mutate_locked( + session, + principal, + row=row, + expected_revision=expected_revision, + changes=changes, + recorded_at=recorded_at, + change_reason=change_reason, + idempotency_key=idempotency_key, + event_type=event_type, + required_scopes=required_scopes, + details=details, + registry=registry, + ) + + +def _mutate_locked( + session: Session, + principal: object, + *, + row: Ticket, + expected_revision: int, + changes: Mapping[str, object], + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + event_type: str, + required_scopes: Sequence[str], + details: Mapping[str, object] | None = None, + registry: object | None, +) -> TicketRecord: + if required_scopes and not _has_any_scope(principal, *required_scopes, ADMIN_SCOPE, LEGACY_WRITE_SCOPE): + raise PermissionError(f"Ticket action requires one of: {', '.join(required_scopes)}.") + if not _can_read_row(principal, row): + raise PermissionError("Ticket access is denied.") + clean_key = _bounded(idempotency_key, "Ticket idempotency key", 255) + clean_reason = _bounded(change_reason, "Ticket change reason", 1_000) + instant = _aware(recorded_at, "Ticket recorded_at") + request = { + "ticket_id": row.id, + "expected_revision": expected_revision, + "changes": changes, + "recorded_at": instant, + "change_reason": clean_reason, + "event_type": event_type, + } + request_sha256 = _request_sha256(request) + replay = _history_replay(session, row.tenant_id, clean_key, request_sha256) + if replay is not None: + return TicketRecord.from_mapping(replay.snapshot) + if row.revision != expected_revision: + raise TicketConflictError("Ticket revision conflict: the expected revision is stale.") + current = _record(row) + payload = current.to_dict() + payload.update(dict(changes)) + payload.update( + { + "revision": current.revision + 1, + "recorded_at": instant.isoformat(), + "change_reason": clean_reason, + } + ) + updated = TicketRecord.from_mapping(payload) + validate_transition(current.status, updated.status) + _write_row(row, updated) + row.updated_by = _principal_actor(principal) + session.add(row) + session.flush() + _append_history( + session, + row=row, + record=updated, + event_type=event_type, + actor_id=row.updated_by, + reason=clean_reason, + idempotency_key=clean_key, + request_sha256=request_sha256, + details=details or {}, + ) + _emit(session, row, updated, operation=event_type, registry=registry) + return updated + + +def _apply_routing( + session: Session, + principal: object, + *, + record: TicketRecord, + registry: object | None, +) -> TicketRecord: + provider = ticket_routing_provider(registry) + if provider is None: + return record + plan = provider.route_ticket( + session, + principal, + request=TicketRoutingRequest( + tenant_id=record.tenant_id, + ticket_id=record.ticket_id, + ticket_type=record.ticket_type, + priority=record.priority, + title=record.title, + received_at=record.received_at, + queue_hint=record.queue_ref, + attributes=dict(record.metadata), + ), + ) + payload = record.to_dict() + if not record.queue_ref and plan.queue_ref: + payload["queue_ref"] = plan.queue_ref + if record.service_target_at is None and plan.service_target_at is not None: + payload["service_target_at"] = plan.service_target_at.isoformat() + payload["metadata"] = { + **dict(record.metadata), + "routing": { + "provider_id": plan.provider_id, + "explanation": plan.explanation, + **dict(plan.metadata), + }, + } + return TicketRecord.from_mapping(payload) + + +def _append_history( + session: Session, + *, + row: Ticket, + record: TicketRecord, + event_type: str, + actor_id: str | None, + reason: str, + idempotency_key: str, + request_sha256: str, + details: Mapping[str, object], +) -> None: + session.add( + TicketHistory( + tenant_id=row.tenant_id, + ticket_id=row.id, + revision=record.revision, + event_type=event_type, + occurred_at=record.recorded_at, + actor_id=actor_id, + reason=reason, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + snapshot=record.to_dict(), + details=dict(details), + ) + ) + session.flush() + + +def _history_replay(session: Session, tenant_id: str, key: str, digest: str) -> TicketHistory | None: + row = ( + session.query(TicketHistory) + .filter(TicketHistory.tenant_id == tenant_id, TicketHistory.idempotency_key == key) + .one_or_none() + ) + if row is not None and row.request_sha256 != digest: + raise TicketConflictError("Ticket idempotency key was reused for another request.") + return row + + +def _write_row(row: Ticket, record: TicketRecord) -> None: + row.revision = record.revision + row.ticket_type = record.ticket_type + row.priority = record.priority + row.status = record.status + row.title = record.title + row.description = record.description + row.visibility = record.visibility + row.queue_ref = record.queue_ref + row.assignee = record.assignee.to_dict() if record.assignee else None + row.reporter = record.reporter.to_dict() if record.reporter else None + row.requester = record.requester.to_dict() if record.requester else None + row.participants = [item.to_dict() for item in record.participants] + row.links = [item.to_dict() for item in record.links] + row.metadata_payload = dict(record.metadata) + row.search_text = _search_text(record) + row.received_at = record.received_at + row.recorded_at = record.recorded_at + row.change_reason = record.change_reason + row.service_target_at = record.service_target_at + row.resolved_at = record.resolved_at + row.resolution_summary = record.resolution_summary + row.deleted_at = record.deleted_at + + +def _record(row: Ticket) -> TicketRecord: + return TicketRecord.from_mapping( + { + "tenant_id": row.tenant_id, + "ticket_id": row.id, + "ticket_number": row.ticket_number, + "revision": row.revision, + "ticket_type": row.ticket_type, + "priority": row.priority, + "status": row.status, + "title": row.title, + "description": row.description, + "visibility": row.visibility, + "queue_ref": row.queue_ref, + "assignee": row.assignee, + "reporter": row.reporter, + "requester": row.requester, + "participants": row.participants or [], + "links": row.links or [], + "metadata": row.metadata_payload or {}, + "received_at": _iso(_db_aware(row.received_at)), + "recorded_at": _iso(_db_aware(row.recorded_at)), + "service_target_at": _iso(_db_aware(row.service_target_at)), + "resolved_at": _iso(_db_aware(row.resolved_at)), + "resolution_summary": row.resolution_summary, + "deleted_at": _iso(_db_aware(row.deleted_at)), + "change_reason": row.change_reason, + } + ) + + +def _ticket_row(session: Session, tenant_id: str, ticket_id: str, *, lock: bool = False) -> Ticket | None: + query = session.query(Ticket).filter(Ticket.tenant_id == tenant_id, Ticket.id == ticket_id) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def _required_ticket(session: Session, principal: object, *, ticket_id: str, lock: bool = False) -> Ticket: + row = _ticket_row(session, _principal_tenant(principal), ticket_id, lock=lock) + if row is None: + raise TicketNotFoundError("Ticket not found.") + return row + + +def _can_read_row(principal: object, row: Ticket) -> bool: + if row.tenant_id != _principal_tenant(principal) or not _has_scope(principal, READ_SCOPE): + return False + if _has_any_scope(principal, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE, ADMIN_SCOPE, LEGACY_WRITE_SCOPE): + return True + if row.visibility == "tenant": + return True + subjects = set(_principal_subjects(principal)) + values = [row.assignee, row.reporter, row.requester, *(row.participants or [])] + return any( + isinstance(value, Mapping) + and (str(value.get("kind") or ""), str(value.get("id") or "")) in subjects + for value in values + ) or str(row.created_by or "") in _principal_actor_ids(principal) + + +def _emit(session: Session, row: Ticket, record: TicketRecord, *, operation: str, registry: object | None) -> None: + emit_platform_event( + session, + PlatformEvent( + type=f"tickets.ticket.{operation}", + module_id="tickets", + payload={"ticket_id": row.id, "ticket_number": row.ticket_number, "revision": record.revision, "status": record.status}, + occurred_at=record.recorded_at, + actor=EventActorRef(type="account", id=row.updated_by or row.created_by), + tenant=EventTenantRef(id=row.tenant_id), + subject=EventObjectRef(type="ticket", id=row.id, label=record.title), + resource=EventObjectRef(type="ticket", id=row.id, label=record.title), + classification="restricted", + ), + registry=registry, + ) + + +def _comment_payload(row: TicketComment) -> dict[str, object]: + return { + "comment_id": row.comment_id, + "ticket_revision": row.ticket_revision, + "visibility": row.visibility, + "body": row.body, + "created_by": row.created_by, + "created_at": _iso(row.created_at), + } + + +def _escalation_payload(row: TicketEscalation, *, replayed: bool) -> dict[str, object]: + return { + "provider_id": row.provider_id, + "case_id": row.case_id, + "case_number": row.case_number, + "case_url": row.case_url, + "occurred_at": _iso(row.occurred_at), + "actor_id": row.actor_id, + "handoff_note": row.handoff_note, + "outcome": dict(row.outcome or {}), + "replayed": replayed, + } + + +def _search_text(record: TicketRecord) -> str: + values = [ + record.ticket_number, + record.ticket_type, + record.priority, + record.status, + record.title, + record.description, + record.queue_ref or "", + record.resolution_summary or "", + *(item.label or item.id for item in record.participants), + *(item.label or item.resource_id for item in record.links), + ] + return "\n".join(values).casefold() + + +def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]: + values: list[tuple[str, str]] = [] + singular = { + "account": getattr(principal, "account_id", None), + "identity": getattr(principal, "identity_id", None), + "function_assignment": getattr(principal, "acting_assignment_id", None), + } + for kind, value in singular.items(): + if str(value or "").strip(): + values.append((kind, str(value))) + for kind, attribute in ( + ("group", "group_ids"), + ("role", "role_ids"), + ("function_assignment", "function_assignment_ids"), + ): + values.extend((kind, str(item)) for item in getattr(principal, attribute, ()) or () if str(item or "").strip()) + return tuple(dict.fromkeys(values)) + + +def _principal_actor_ids(principal: object) -> tuple[str, ...]: + user = getattr(principal, "user", None) + return tuple( + dict.fromkeys( + str(value) + for value in ( + getattr(principal, "account_id", None), + getattr(principal, "identity_id", None), + getattr(principal, "membership_id", None), + getattr(user, "id", None), + ) + if str(value or "").strip() + ) + ) + + +def _principal_actor(principal: object) -> str | None: + values = _principal_actor_ids(principal) + return values[0] if values else None + + +def _principal_tenant(principal: object) -> str: + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if not tenant_id: + raise TicketStoreError("Ticket operations require a tenant-bound principal.") + return tenant_id + + +def _has_scope(principal: object, scope: str) -> bool: + method = getattr(principal, "has", None) + if callable(method): + return bool(method(scope)) + return scopes_grant_compatible(frozenset(getattr(principal, "scopes", ()) or ()), scope) + + +def _has_any_scope(principal: object, *scopes: str) -> bool: + return any(_has_scope(principal, scope) for scope in scopes) + + +def _only_changes(value: Mapping[str, object], allowed: set[str]) -> dict[str, object]: + unexpected = set(value) - allowed + if unexpected: + raise TicketStoreError("Unsupported ticket changes: " + ", ".join(sorted(unexpected))) + return dict(value) + + +def _bounded(value: object, label: str, maximum: int) -> str: + clean = str(value or "").strip() + if not clean or len(clean) > maximum: + raise TicketStoreError(f"{label} must contain 1 to {maximum} characters.") + return clean + + +def _optional_bounded(value: object, label: str, maximum: int) -> str | None: + if value is None: + return None + return _bounded(value, label, maximum) + + +def _aware(value: datetime, label: str) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise TicketStoreError(f"{label} must include a timezone.") + return value + + +def _request_sha256(value: object) -> str: + payload = json.dumps(_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _json_value(value: object) -> Any: + if isinstance(value, datetime): + return value.astimezone(UTC).isoformat() + if isinstance(value, Mapping): + return {str(key): _json_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_value(item) for item in value] + return value + + +def _iso(value: datetime | None) -> str | None: + return value.isoformat() if value else None + + +def _db_aware(value: datetime | None) -> datetime | None: + if value is not None and (value.tzinfo is None or value.utcoffset() is None): + return value.replace(tzinfo=UTC) + return value + + +def _active_modules(registry: object | None) -> frozenset[str]: + if registry is None: + return frozenset() + method = getattr(registry, "active_module_ids", None) + if callable(method): + return frozenset(str(item) for item in method()) + manifests = getattr(registry, "manifests", None) + if callable(manifests): + return frozenset(str(getattr(item, "id", "")) for item in manifests()) + return frozenset() + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Tickets require a SQLAlchemy session.") + return value + + +__all__ = [ + "ADMIN_SCOPE", + "ASSIGN_SCOPE", + "CAPABILITY_TICKETS_REGISTRY", + "LEGACY_WRITE_SCOPE", + "READ_SCOPE", + "REPORT_SCOPE", + "RESOLVE_SCOPE", + "SqlTicketRegistry", + "TRIAGE_SCOPE", + "TicketConflictError", + "TicketIntegrationUnavailableError", + "TicketNotFoundError", + "TicketStoreError", + "add_ticket_comment", + "add_ticket_link", + "assign_ticket", + "can_read_ticket", + "create_ticket", + "delete_ticket", + "escalate_ticket_to_case", + "get_ticket", + "integration_availability", + "list_ticket_comments", + "list_tickets", + "remove_ticket_link", + "replace_participants", + "resolve_ticket", + "ticket_history", + "triage_ticket", +] diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 8687a94..94da314 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -2,39 +2,76 @@ from __future__ import annotations import unittest +from govoplan_core.core.tickets import ( + CAPABILITY_TICKET_CASE_ESCALATION, + CAPABILITY_TICKET_ROUTING, +) +from govoplan_tickets.backend.dsar_provider import TICKETS_DSAR_CAPABILITY from govoplan_tickets.backend.manifest import ( ADMIN_SCOPE, + ASSIGN_SCOPE, READ_SCOPE, + REPORT_SCOPE, + RESOLVE_SCOPE, + TRIAGE_SCOPE, WRITE_SCOPE, get_manifest, ) +from govoplan_tickets.backend.service import CAPABILITY_TICKETS_REGISTRY -class ManifestSeedTests(unittest.TestCase): - def test_manifest_registers_seed_contract(self) -> None: +class ManifestTests(unittest.TestCase): + def test_manifest_registers_the_vertical_slice(self) -> None: manifest = get_manifest() - self.assertEqual(manifest.id, "tickets") - self.assertEqual(manifest.name, "Tickets") - self.assertEqual(manifest.dependencies, ("access",)) - self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}) + self.assertEqual("tickets", manifest.id) + self.assertEqual("0.1.20", manifest.version) + self.assertEqual(("access",), manifest.dependencies) self.assertEqual( + { + READ_SCOPE, + REPORT_SCOPE, + TRIAGE_SCOPE, + ASSIGN_SCOPE, + RESOLVE_SCOPE, + ADMIN_SCOPE, + WRITE_SCOPE, + }, + {permission.scope for permission in manifest.permissions}, + ) + self.assertEqual( + { + "tickets_reporter", + "tickets_manager", + "tickets_viewer", + "tickets_administrator", + }, {role.slug for role in manifest.role_templates}, - {"tickets_manager", "tickets_viewer"}, ) - self.assertTrue(manifest.documentation) - topic = manifest.documentation[0] - self.assertEqual("reference", topic.metadata["kind"]) - self.assertIn("seed_boundary", topic.metadata["consequence_classes"]) - self.assertTrue( - all( - topic.translations.get("de", {}).get(field) - for field in ("title", "summary", "body") + self.assertIsNotNone(manifest.route_factory) + self.assertIsNotNone(manifest.migration_spec) + self.assertIsNotNone(manifest.frontend) + self.assertEqual(1, len(manifest.search_sources)) + self.assertIn(CAPABILITY_TICKETS_REGISTRY, manifest.capability_factories) + self.assertIn(TICKETS_DSAR_CAPABILITY, manifest.capability_factories) + self.assertEqual( + {CAPABILITY_TICKET_ROUTING, CAPABILITY_TICKET_CASE_ESCALATION}, + set(manifest.optional_capabilities), + ) + self.assertEqual("vertical_slice", manifest.architecture.maturity) + + def test_documentation_has_static_and_workflow_baselines_in_german(self) -> None: + topics = get_manifest().documentation + self.assertTrue(any(topic.layer == "available" for topic in topics)) + self.assertTrue(any(topic.metadata.get("kind") == "workflow" for topic in topics)) + for topic in topics: + self.assertTrue( + all(topic.translations.get("de", {}).get(field) for field in ("title", "summary", "body")), + topic.id, ) - ) - self.assertIsNone(manifest.route_factory) - self.assertIsNone(manifest.migration_spec) - self.assertIsNone(manifest.frontend) + optional = next(topic for topic in topics if topic.id == "tickets.optional-integrations") + self.assertIn("Helpdesk", optional.body) + self.assertIn("Search", optional.body) if __name__ == "__main__": diff --git a/tests/test_ticket_service.py b/tests/test_ticket_service.py new file mode 100644 index 0000000..9d5982d --- /dev/null +++ b/tests/test_ticket_service.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent +from govoplan_core.core.search import SearchBackfillRequest +from govoplan_core.core.tickets import ( + CAPABILITY_TICKET_CASE_ESCALATION, + CAPABILITY_TICKET_ROUTING, + TicketCaseEscalationResult, + TicketRoutingPlan, +) +from govoplan_core.db.base import Base +from govoplan_tickets.backend.db.models import TicketComment, TicketEscalation, TicketHistory +from govoplan_tickets.backend.domain import TicketLink, TicketRecord, TicketSubjectRef +from govoplan_tickets.backend.search_source import PROVIDER_ID, RESOURCE_TYPE, TicketsSearchSource +from govoplan_tickets.backend.service import ( + ADMIN_SCOPE, + ASSIGN_SCOPE, + READ_SCOPE, + REPORT_SCOPE, + RESOLVE_SCOPE, + TRIAGE_SCOPE, + TicketConflictError, + add_ticket_comment, + add_ticket_link, + assign_ticket, + create_ticket, + escalate_ticket_to_case, + get_ticket, + list_ticket_comments, + list_tickets, + resolve_ticket, + ticket_history, + triage_ticket, +) + + +NOW = datetime(2026, 8, 22, 9, 0, tzinfo=UTC) + + +class _Principal: + def __init__(self, account_id: str, scopes: set[str], tenant_id: str = "tenant-1") -> None: + self.account_id = account_id + self.identity_id = None + self.membership_id = f"membership-{account_id}" + self.tenant_id = tenant_id + self.scopes = frozenset(scopes) + self.group_ids = frozenset() + self.role_ids = frozenset() + self.function_assignment_ids = frozenset() + self.acting_assignment_id = None + self.user = SimpleNamespace(id=account_id) + + def has(self, scope: str) -> bool: + return scope in self.scopes + + +class _Integrations: + def route_ticket(self, session, principal, *, request): + del session, principal + return TicketRoutingPlan( + provider_id="helpdesk", + queue_ref=request.queue_hint or "citizen-service", + service_target_at=request.received_at + timedelta(hours=24), + explanation="Matched the default service profile.", + ) + + def escalate_ticket(self, session, principal, *, command): + del session, principal + return TicketCaseEscalationResult( + provider_id="cases", + case_id=f"case-{command.ticket_id}", + case_number=f"CASE-{command.ticket_number}", + case_url=f"/cases/case-{command.ticket_id}", + ) + + +class _Registry: + def __init__(self, integrations: _Integrations | None = None) -> None: + self.capabilities = ( + { + CAPABILITY_TICKET_ROUTING: integrations, + CAPABILITY_TICKET_CASE_ESCALATION: integrations, + } + if integrations + else {} + ) + + def has_capability(self, name): + return name in self.capabilities + + def capability(self, name): + return self.capabilities[name] + + def active_module_ids(self): + return ("tickets", "cases", "helpdesk", "search") if self.capabilities else ("tickets",) + + +class TicketServiceTests(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.Session = sessionmaker(bind=engine, expire_on_commit=False) + self.manager = _Principal( + "manager-1", + {READ_SCOPE, REPORT_SCOPE, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE, ADMIN_SCOPE}, + ) + self.reporter = _Principal("reporter-1", {READ_SCOPE, REPORT_SCOPE}) + + def test_lifecycle_is_tenant_safe_replay_safe_and_revisioned(self) -> None: + registry = _Registry(_Integrations()) + with self.Session() as session: + created = create_ticket( + session, + self.reporter, + record=_record(reporter="reporter-1"), + idempotency_key="report-1", + registry=registry, + ) + session.commit() + self.assertEqual("citizen-service", created.queue_ref) + self.assertEqual(NOW + timedelta(hours=24), created.service_target_at) + + replay = create_ticket( + session, + self.reporter, + record=_record(reporter="reporter-1"), + idempotency_key="report-1", + registry=registry, + ) + self.assertEqual(created.to_dict(), replay.to_dict()) + + triaged = triage_ticket( + session, + self.manager, + ticket_id=created.ticket_id, + expected_revision=1, + changes={"status": "triaged", "priority": "high", "visibility": "restricted"}, + recorded_at=NOW + timedelta(minutes=5), + change_reason="Classified by the service desk.", + idempotency_key="triage-1", + registry=registry, + ) + assigned = assign_ticket( + session, + self.manager, + ticket_id=created.ticket_id, + expected_revision=triaged.revision, + assignee=TicketSubjectRef(kind="account", id="manager-1", label="Manager"), + recorded_at=NOW + timedelta(minutes=10), + change_reason="Assigned to the duty manager.", + idempotency_key="assign-1", + registry=registry, + ) + resolved = resolve_ticket( + session, + self.manager, + ticket_id=created.ticket_id, + expected_revision=assigned.revision, + target_status="resolved", + resolution_summary="Streetlight repair was commissioned.", + recorded_at=NOW + timedelta(hours=2), + change_reason="Resolution confirmed.", + idempotency_key="resolve-1", + registry=registry, + ) + session.commit() + self.assertEqual(4, resolved.revision) + self.assertEqual("resolved", resolved.status) + self.assertEqual(4, session.query(TicketHistory).count()) + + outsider = _Principal("outsider", {READ_SCOPE}) + self.assertIsNone(get_ticket(session, outsider, ticket_id=created.ticket_id)) + self.assertIsNotNone(get_ticket(session, self.reporter, ticket_id=created.ticket_id)) + other_tenant = _Principal("manager-1", self.manager.scopes, tenant_id="tenant-2") + self.assertIsNone(get_ticket(session, other_tenant, ticket_id=created.ticket_id)) + + def test_links_comments_history_and_case_escalation_are_auditable(self) -> None: + registry = _Registry(_Integrations()) + with self.Session() as session: + created = create_ticket( + session, + self.manager, + record=_record(reporter="manager-1"), + idempotency_key="report-2", + registry=registry, + ) + linked = add_ticket_link( + session, + self.manager, + ticket_id=created.ticket_id, + expected_revision=1, + link=TicketLink( + link_id="file-1", + kind="attachment", + owner_module="files", + resource_type="file", + resource_id="file-1", + label="Photo", + url="/files/file-1", + ), + recorded_at=NOW + timedelta(minutes=1), + change_reason="Attached inspection photo.", + idempotency_key="link-1", + registry=registry, + ) + commented, comment = add_ticket_comment( + session, + self.manager, + ticket_id=created.ticket_id, + expected_revision=linked.revision, + comment_id="comment-1", + body="Forwarded to maintenance.", + visibility="internal", + recorded_at=NOW + timedelta(minutes=2), + idempotency_key="comment-op-1", + registry=registry, + ) + escalated, outcome = escalate_ticket_to_case( + session, + self.manager, + ticket_id=created.ticket_id, + expected_revision=commented.revision, + case_type_key="service-request", + occurred_at=NOW + timedelta(minutes=3), + handoff_note="Formal procedure required.", + idempotency_key="escalate-1", + registry=registry, + ) + session.commit() + self.assertEqual("comment-1", comment["comment_id"]) + self.assertEqual("case-ticket-1", outcome["case_id"]) + self.assertEqual(1, session.query(TicketComment).count()) + self.assertEqual(1, session.query(TicketEscalation).count()) + self.assertTrue(any(item.kind == "case" for item in escalated.links)) + self.assertEqual(4, len(ticket_history(session, self.manager, ticket_id=created.ticket_id))) + self.assertEqual(1, len(list_ticket_comments(session, self.manager, ticket_id=created.ticket_id))) + + replayed, replay_outcome = escalate_ticket_to_case( + session, + self.manager, + ticket_id=created.ticket_id, + expected_revision=commented.revision, + case_type_key="service-request", + occurred_at=NOW + timedelta(minutes=3), + handoff_note="Formal procedure required.", + idempotency_key="escalate-1", + registry=registry, + ) + self.assertEqual(escalated.revision, replayed.revision) + self.assertTrue(replay_outcome["replayed"]) + + def test_revision_conflicts_and_search_rechecks_fail_closed(self) -> None: + with self.Session() as session: + created = create_ticket( + session, + self.manager, + record=_record(reporter="manager-1"), + idempotency_key="report-3", + ) + triage_ticket( + session, + self.manager, + ticket_id=created.ticket_id, + expected_revision=1, + changes={"status": "triaged"}, + recorded_at=NOW + timedelta(minutes=1), + change_reason="Triaged.", + idempotency_key="triage-3", + ) + session.commit() + with self.assertRaises(TicketConflictError): + triage_ticket( + session, + self.manager, + ticket_id=created.ticket_id, + expected_revision=1, + changes={"priority": "urgent"}, + recorded_at=NOW + timedelta(minutes=2), + change_reason="Stale update.", + idempotency_key="triage-stale", + ) + session.rollback() + + source = TicketsSearchSource() + page = source.backfill( + session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + rebuild_id="rebuild-1", + ), + ) + self.assertEqual(1, len(page.documents)) + event = PlatformEvent( + type="tickets.ticket.triaged", + module_id="tickets", + tenant=EventTenantRef(id="tenant-1"), + resource=EventObjectRef(type="ticket", id=created.ticket_id), + ) + changes = source.index_changes_for_event(session, event=event, delivery_key="delivery-1") + self.assertEqual("upsert", changes[0].kind) + + items, total = list_tickets(session, self.manager) + self.assertEqual(1, total) + self.assertEqual(created.ticket_id, items[0].ticket_id) + + def test_report_and_comment_permissions_are_enforced_below_the_router(self) -> None: + viewer = _Principal("viewer-1", {READ_SCOPE}) + with self.Session() as session: + with self.assertRaises(PermissionError): + create_ticket( + session, + viewer, + record=_record(reporter="viewer-1"), + idempotency_key="viewer-report", + ) + + created = create_ticket( + session, + self.reporter, + record=_record(reporter="reporter-1"), + idempotency_key="report-comment-test", + ) + with self.assertRaises(PermissionError): + add_ticket_comment( + session, + viewer, + ticket_id=created.ticket_id, + expected_revision=created.revision, + comment_id="viewer-comment", + body="A read-only viewer must not append a comment.", + visibility="external", + recorded_at=NOW + timedelta(minutes=1), + idempotency_key="viewer-comment-op", + ) + + +def _record(*, reporter: str) -> TicketRecord: + return TicketRecord( + tenant_id="tenant-1", + ticket_id="ticket-1", + ticket_number="TKT-2026-0001", + revision=1, + ticket_type="request", + priority="normal", + status="new", + title="Broken streetlight", + description="The lamp at the town square is not working.", + visibility="restricted", + reporter=TicketSubjectRef(kind="account", id=reporter), + requester=TicketSubjectRef(kind="account", id=reporter), + received_at=NOW, + recorded_at=NOW, + change_reason="Reported through the authenticated portal.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..ace987c --- /dev/null +++ b/webui/package.json @@ -0,0 +1,31 @@ +{ + "name": "@govoplan/tickets-webui", + "version": "0.1.20", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/tickets.css": "./src/styles/tickets.css" + }, + "scripts": { + "test:interface-pattern": "node scripts/test-interface-pattern.mjs" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.30", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/scripts/test-interface-pattern.mjs b/webui/scripts/test-interface-pattern.mjs new file mode 100644 index 0000000..bdd72ad --- /dev/null +++ b/webui/scripts/test-interface-pattern.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const page = fs.readFileSync("src/features/tickets/TicketsPage.tsx", "utf8"); +const styles = fs.readFileSync("src/styles/tickets.css", "utf8"); + +assert.ok(page.includes("WorkspaceActionBar"), "Tickets uses the semantic shared action bar"); +assert.ok(page.includes("refreshable"), "The refreshable Tickets workspace exposes Reload"); +assert.ok(page.includes("DocumentationHelpLink"), "Tickets exposes contextual documentation"); +assert.ok(page.includes("PageScrollViewport"), "Tickets owns bounded queue and detail scrolling"); +assert.ok(page.includes("]*\bonClick\s*=/.test(page), "Tickets uses semantic interactive elements"); +assert.ok(styles.includes("@media (max-width: 760px)"), "Tickets retains a responsive queue-detail layout"); + +console.log("Tickets interface pattern contract passed."); diff --git a/webui/src/api/tickets.ts b/webui/src/api/tickets.ts new file mode 100644 index 0000000..f3606a4 --- /dev/null +++ b/webui/src/api/tickets.ts @@ -0,0 +1,155 @@ +import { + apiFetch, + apiPath, + type ApiSettings +} from "@govoplan/core-webui"; + + +export type TicketSubject = { + kind: string; + id: string; + label?: string | null; + role?: string | null; +}; + +export type TicketLink = { + link_id: string; + kind: string; + owner_module: string; + resource_type: string; + resource_id: string; + relation: string; + label?: string | null; + url?: string | null; + metadata: Record; +}; + +export type TicketRecord = { + tenant_id: string; + ticket_id: string; + ticket_number: string; + revision: number; + ticket_type: "request" | "incident" | "problem" | "report"; + priority: "low" | "normal" | "high" | "urgent"; + status: string; + title: string; + description: string; + visibility: "tenant" | "restricted"; + queue_ref?: string | null; + assignee?: TicketSubject | null; + reporter?: TicketSubject | null; + requester?: TicketSubject | null; + participants: TicketSubject[]; + links: TicketLink[]; + service_target_at?: string | null; + received_at: string; + recorded_at: string; + resolved_at?: string | null; + resolution_summary?: string | null; + deleted_at?: string | null; + change_reason: string; + metadata: Record; +}; + +export type TicketListResponse = { + tickets: TicketRecord[]; + total: number; + offset: number; + limit: number; +}; + +export type TicketAvailability = { + routing: { available: boolean; provider?: string | null }; + case_escalation: { available: boolean; provider?: string | null }; + modules: Record; + consequences: Record; +}; + +export function listTickets( + settings: ApiSettings, + options: { statuses?: string[]; priorities?: string[]; ticketTypes?: string[]; queueRef?: string; query?: string; limit?: number }, + signal?: AbortSignal +): Promise { + return apiFetch(settings, apiPath("/api/v1/tickets", { + status: options.statuses, + priority: options.priorities, + ticket_type: options.ticketTypes, + queue_ref: options.queueRef, + query: options.query, + limit: options.limit + }), { signal }); +} + +export function getTicketAvailability(settings: ApiSettings, signal?: AbortSignal): Promise { + return apiFetch(settings, "/api/v1/tickets/availability", { signal }); +} + +export function createTicket(settings: ApiSettings, record: TicketRecord): Promise { + return apiFetch(settings, "/api/v1/tickets", { + method: "POST", + body: JSON.stringify({ record, idempotency_key: crypto.randomUUID() }) + }); +} + +function mutation(record: TicketRecord, changeReason: string) { + return { + expected_revision: record.revision, + recorded_at: new Date().toISOString(), + change_reason: changeReason, + idempotency_key: crypto.randomUUID() + }; +} + +export function triageTicket(settings: ApiSettings, record: TicketRecord, changes: Record, changeReason: string): Promise { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/triage`, { + method: "PATCH", + body: JSON.stringify({ ...mutation(record, changeReason), changes }) + }); +} + +export function assignTicket(settings: ApiSettings, record: TicketRecord, assignee: TicketSubject | null, changeReason: string): Promise { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/assignment`, { + method: "POST", + body: JSON.stringify({ ...mutation(record, changeReason), assignee }) + }); +} + +export function resolveTicket( + settings: ApiSettings, + record: TicketRecord, + targetStatus: string, + resolutionSummary: string | null, + changeReason: string +): Promise { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/resolution`, { + method: "POST", + body: JSON.stringify({ ...mutation(record, changeReason), target_status: targetStatus, resolution_summary: resolutionSummary }) + }); +} + +export function addTicketComment(settings: ApiSettings, record: TicketRecord, body: string, visibility: "internal" | "external") { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/comments`, { + method: "POST", + body: JSON.stringify({ + expected_revision: record.revision, + comment_id: crypto.randomUUID(), + body, + visibility, + recorded_at: new Date().toISOString(), + idempotency_key: crypto.randomUUID() + }) + }) as Promise<{ ticket: TicketRecord; comment: Record }>; +} + +export function escalateTicket(settings: ApiSettings, record: TicketRecord, caseTypeKey: string, handoffNote: string) { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/case-escalations`, { + method: "POST", + body: JSON.stringify({ + expected_revision: record.revision, + case_type_key: caseTypeKey, + handoff_note: handoffNote || null, + occurred_at: new Date().toISOString(), + idempotency_key: crypto.randomUUID() + }) + }) as Promise<{ ticket: TicketRecord; escalation: Record }>; +} diff --git a/webui/src/features/tickets/TicketsPage.tsx b/webui/src/features/tickets/TicketsPage.tsx new file mode 100644 index 0000000..62749b3 --- /dev/null +++ b/webui/src/features/tickets/TicketsPage.tsx @@ -0,0 +1,595 @@ +import { + MessageSquarePlus, + Pencil, + Plus, + Search, + Send, + TicketCheck, + UserRoundCheck +} from "lucide-react"; +import { + useEffect, + useMemo, + useState, + type FormEvent +} from "react"; +import { + Button, + Dialog, + DocumentationHelpLink, + DismissibleAlert, + FieldLabel, + FilterBar, + FormLayout, + LoadingIndicator, + PageScrollViewport, + SelectionList, + SelectionListItem, + SelectionListItemContent, + StatePanel, + StatusBadge, + WorkspaceActionBar, + WorkspaceFrame, + hasScope, + type PlatformRouteContext +} from "@govoplan/core-webui"; +import { + addTicketComment, + assignTicket, + createTicket, + escalateTicket, + getTicketAvailability, + listTickets, + resolveTicket, + triageTicket, + type TicketAvailability, + type TicketRecord, + type TicketSubject +} from "../../api/tickets"; + + +type EditorValues = { + ticketType: TicketRecord["ticket_type"]; + priority: TicketRecord["priority"]; + status: string; + title: string; + description: string; + visibility: TicketRecord["visibility"]; + queueRef: string; + serviceTargetAt: string; + changeReason: string; +}; + +const STATES = ["new", "triaged", "in_progress", "waiting", "cancelled"]; + + +export default function TicketsPage({ settings, auth }: PlatformRouteContext) { + const [query, setQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [tickets, setTickets] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [total, setTotal] = useState(0); + const [availability, setAvailability] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [dialogError, setDialogError] = useState(""); + const [editorOpen, setEditorOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [assignmentOpen, setAssignmentOpen] = useState(false); + const [resolutionOpen, setResolutionOpen] = useState(false); + const [escalationOpen, setEscalationOpen] = useState(false); + const [saving, setSaving] = useState(false); + const canReport = hasAny(auth, "tickets:ticket:report", "tickets:ticket:admin", "tickets:ticket:write"); + const canTriage = hasAny(auth, "tickets:ticket:triage", "tickets:ticket:admin", "tickets:ticket:write"); + const canAssign = hasAny(auth, "tickets:ticket:assign", "tickets:ticket:admin", "tickets:ticket:write"); + const canResolve = hasAny(auth, "tickets:ticket:resolve", "tickets:ticket:admin", "tickets:ticket:write"); + const canCreateCase = hasScope(auth, "cases:case:create"); + + function reload(signal?: AbortSignal) { + setLoading(true); + setError(""); + return Promise.all([ + listTickets(settings, { + statuses: statusFilter ? [statusFilter] : undefined, + query: submittedQuery, + limit: 200 + }, signal), + getTicketAvailability(settings, signal) + ]). + then(([result, integrationState]) => { + setTickets(result.tickets); + setTotal(result.total); + setAvailability(integrationState); + setSelectedId((current) => result.tickets.some((item) => item.ticket_id === current) + ? current + : result.tickets[0]?.ticket_id ?? ""); + }). + catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setError(reason instanceof Error ? reason.message : "Tickets could not be loaded."); + } + }). + finally(() => setLoading(false)); + } + + useEffect(() => { + const controller = new AbortController(); + void reload(controller.signal); + return () => controller.abort(); + }, [settings, statusFilter, submittedQuery]); + + const selected = useMemo( + () => tickets.find((item) => item.ticket_id === selectedId) ?? null, + [tickets, selectedId] + ); + + function submitSearch(event: FormEvent) { + event.preventDefault(); + setSubmittedQuery(query.trim()); + } + + async function saveEditor(values: EditorValues) { + setSaving(true); + setDialogError(""); + try { + let saved: TicketRecord; + if (editing) { + const changes: Record = { + ticket_type: values.ticketType, + priority: values.priority, + title: values.title, + description: values.description, + visibility: values.visibility, + queue_ref: values.queueRef || null, + service_target_at: dateTimeValue(values.serviceTargetAt) + }; + if (editing.status !== "resolved" && editing.status !== "closed") { + changes.status = values.status; + } + saved = await triageTicket(settings, editing, changes, values.changeReason); + } else { + const tenantId = auth.active_tenant?.id ?? auth.tenant.id; + const accountId = auth.user.account_id; + const now = new Date().toISOString(); + const ticketId = crypto.randomUUID(); + const subject: TicketSubject = { + kind: "account", + id: accountId, + label: auth.user.display_name ?? auth.user.email + }; + saved = await createTicket(settings, { + tenant_id: tenantId, + ticket_id: ticketId, + ticket_number: ticketNumber(ticketId), + revision: 1, + ticket_type: values.ticketType, + priority: values.priority, + status: "new", + title: values.title, + description: values.description, + visibility: values.visibility, + queue_ref: values.queueRef || null, + assignee: null, + reporter: subject, + requester: subject, + participants: [], + links: [], + service_target_at: dateTimeValue(values.serviceTargetAt), + received_at: now, + recorded_at: now, + resolved_at: null, + resolution_summary: null, + deleted_at: null, + change_reason: values.changeReason, + metadata: {} + }); + } + setEditorOpen(false); + setEditing(null); + await reload(); + setSelectedId(saved.ticket_id); + } catch (reason) { + setDialogError(message(reason, "The ticket could not be saved.")); + } finally { + setSaving(false); + } + } + + async function saveAssignment(subject: TicketSubject | null, reason: string) { + if (!selected) return; + await runAction(async () => { + const saved = await assignTicket(settings, selected, subject, reason); + setAssignmentOpen(false); + return saved; + }); + } + + async function saveResolution(status: string, summary: string, reason: string) { + if (!selected) return; + await runAction(async () => { + const saved = await resolveTicket(settings, selected, status, summary || null, reason); + setResolutionOpen(false); + return saved; + }); + } + + async function saveEscalation(caseType: string, note: string) { + if (!selected) return; + await runAction(async () => { + const result = await escalateTicket(settings, selected, caseType, note); + setEscalationOpen(false); + return result.ticket; + }); + } + + async function saveComment(body: string, visibility: "internal" | "external") { + if (!selected) return; + await runAction(async () => (await addTicketComment(settings, selected, body, visibility)).ticket); + } + + async function runAction(action: () => Promise) { + setSaving(true); + setDialogError(""); + try { + const saved = await action(); + await reload(); + setSelectedId(saved.ticket_id); + } catch (reason) { + setDialogError(message(reason, "The ticket action could not be completed.")); + } finally { + setSaving(false); + } + } + + return ( +
+ + void reload(), loading }} + contextActions={<> + + + + {total} tickets + } + helpAction={} + createAction={canReport ? + + : undefined} + /> + {error && setError("")}>{error}} + {dialogError && setDialogError("")}>{dialogError}} + {availability && (!availability.routing.available || !availability.case_escalation.available) && +
+ {!availability.routing.available && Automatic Helpdesk routing is unavailable; queue and target remain manual.} + {!availability.case_escalation.available && Cases escalation is unavailable; ticket resolution remains usable.} +
+ } +
+ + {loading && } + {!loading && tickets.length === 0 && } + + {tickets.map((item) => + setSelectedId(item.ticket_id)}> + + )} + + + + {selected ? + { + setEditing(selected); + setDialogError(""); + setEditorOpen(true); + }} + onAssign={() => setAssignmentOpen(true)} + onResolve={() => setResolutionOpen(true)} + onEscalate={() => setEscalationOpen(true)} + onComment={saveComment} + /> : + + } + +
+
+ setEditorOpen(false)} onSave={saveEditor} /> + setAssignmentOpen(false)} onSave={saveAssignment} /> + setResolutionOpen(false)} onSave={saveResolution} /> + setEscalationOpen(false)} onSave={saveEscalation} /> +
+ ); +} + + +function TicketDetail({ record, availability, canTriage, canAssign, canResolve, canCreateCase, saving, onEdit, onAssign, onResolve, onEscalate, onComment }: { + record: TicketRecord; + availability: TicketAvailability | null; + canTriage: boolean; + canAssign: boolean; + canResolve: boolean; + canCreateCase: boolean; + saving: boolean; + onEdit: () => void; + onAssign: () => void; + onResolve: () => void; + onEscalate: () => void; + onComment: (body: string, visibility: "internal" | "external") => Promise; +}) { + const [comment, setComment] = useState(""); + const [visibility, setVisibility] = useState<"internal" | "external">("internal"); + return ( +
+
+
+ {record.ticket_number} · {humanize(record.ticket_type)} +

{record.title}

+
+
+ + +
+
+
+ {canTriage && } + {canAssign && } + {canResolve && } + {canTriage && canCreateCase && availability?.case_escalation.available && } +
+

{record.description}

+
+ + + + + + + + +
+ {record.resolution_summary && +
+

Resolution

+

{record.resolution_summary}

+
+ } +
+

References and attachments {record.links.length}

+ {record.links.length === 0 ?

No typed references have been added.

: +
    + {record.links.map((link) =>
  • {link.url ? {link.label || link.resource_id} : {link.label || link.resource_id}}{humanize(link.kind)} · {link.owner_module}
  • )} +
+ } +
+
+

Add comment

+
{ + event.preventDefault(); + const body = comment.trim(); + if (!body) return; + void onComment(body, visibility).then(() => setComment("")); + }}> +