13 Commits
Author SHA1 Message Date
zemion b11162d5f1 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:04:24 +02:00
zemion 2be4a0c598 perf(tickets): paginate scoped records in SQL
Module Package Release / publish-packages (push) Successful in 13s
Release v0.1.23. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:40 +02:00
zemion 0ad2ef96b4 fix(webui): bind ticket deletion to help
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 11:47:24 +02:00
zemion 38e038b3a0 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 10s
2026-08-24 01:23:36 +02:00
zemion c6c643778b refactor(webui): align ticket responsive foundations
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 12:05:24 +02:00
zemion c60228f45e refactor(webui): use shared ticket dialog sizing 2026-08-22 12:02:43 +02:00
zemion a9faafb2dc feat(tickets): complete governed ticket workspace 2026-08-22 11:57:53 +02:00
zemion c128d51844 build(webui): expose tagged Tickets package 2026-08-22 11:48:43 +02:00
zemion 7cbf3bed96 feat(tickets): deliver canonical operational lifecycle 2026-08-22 11:41:45 +02:00
zemion b4bb52d8b8 docs: complete public documentation baseline
Module Package Release / publish-packages (push) Successful in 10s
2026-08-22 07:55:20 +02:00
zemion ff4380ffef Release v0.1.18
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 21:07:45 +02:00
zemion 5ba42d3d68 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 10s
2026-08-05 20:34:04 +02:00
zemion b8fceff3f4 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 19:52:11 +02:00
28 changed files with 5331 additions and 99 deletions
+26 -3
View File
@@ -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
+34 -12
View File
@@ -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
+30 -4
View File
@@ -1,8 +1,34 @@
{
"name": "@govoplan/tickets",
"version": "0.1.15",
"name": "@govoplan/tickets-webui",
"version": "0.1.23",
"private": true,
"description": "GovOPlaN Tickets platform module seed.",
"description": "Canonical GovOPlaN operational ticket lifecycle module.",
"type": "module",
"peerDependencies": {}
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/tickets.css": "./webui/src/styles/tickets.css"
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"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
}
}
}
+4 -4
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-tickets"
version = "0.1.15"
description = "GovOPlaN Tickets platform module seed."
version = "0.1.23"
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.15",
"govoplan-access>=0.1.15",
"govoplan-core>=0.1.46",
"govoplan-access>=0.1.18",
]
[tool.setuptools.packages.find]
@@ -0,0 +1,8 @@
from govoplan_tickets.backend.db.models import (
Ticket,
TicketComment,
TicketEscalation,
TicketHistory,
)
__all__ = ["Ticket", "TicketComment", "TicketEscalation", "TicketHistory"]
+123
View File
@@ -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"]
+388
View File
@@ -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",
]
@@ -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"]
@@ -0,0 +1,21 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'tickets.module-boundary': {'consequence_classes': {'canonical_store': 'Tickets ist der einzige '
'betriebsbereite '
'Ticketshop; Helpdesk '
'liefert Richtlinien und '
'Warteschlangensemantik.',
'case_boundary': 'Fall Eskalation schafft '
'eine stabile auditierbare '
'Verbindung und konvertiert '
'oder kopiert nie '
'Ticket-Historie.',
'soft_delete': 'Die Löschung verbirgt die '
'aktuelle Arbeit, bewahrt aber '
'unveränderliche Nachweise der '
'Rechenschaftspflicht.'}}}
+350 -64
View File
@@ -1,17 +1,74 @@
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 govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_tickets.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
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.15"
READ_SCOPE = "tickets:ticket:read"
WRITE_SCOPE = "tickets:ticket:write"
ADMIN_SCOPE = "tickets:ticket:admin"
MODULE_VERSION = "0.1.23"
WRITE_SCOPE = LEGACY_WRITE_SCOPE
OPTIONAL_DEPENDENCIES = (
"cases",
"helpdesk",
"projects",
"wiki",
"assets",
@@ -33,7 +90,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 +98,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,51 +163,185 @@ 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.authorized-pagination",
title="Authorized ticket pages and totals",
summary="Ticket pages and their exact totals use the same current authorization filter.",
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."
"Ticket lists apply tenant, deletion, search, queue, status, and current read-access filters in the database "
"before counting or selecting a page. Elevated ticket scopes retain their existing access; other readers see "
"tenant-visible tickets and tickets linked to their current subjects or actor identities. Hidden tickets do "
"not enter totals or consume page slots. Pages contain at most 200 records and use service target, priority, "
"updated time, then stable ticket ID ordering. Each request evaluates current access again; paging is not an "
"immutable snapshot across concurrent edits. Exact JSON subject matching supports SQLite and PostgreSQL and "
"does not coerce numeric or boolean identifiers; unsupported database dialects fail closed."
),
layer="always", documentation_types=("user", "admin"),
audience=("user", "operator", "tenant_admin"),
translations={"de": {
"title": "Berechtigte Ticketseiten und Gesamtzahlen",
"summary": "Ticketseiten und ihre exakten Gesamtzahlen verwenden denselben aktuellen Berechtigungsfilter.",
"body": (
"Ticketlisten wenden Mandanten-, Lösch-, Such-, Warteschlangen-, Status- und aktuelle Lesefilter in der "
"Datenbank vor Zählung und Seitenauswahl an. Erweiterte Ticketrechte behalten ihren bisherigen Zugriff; "
"andere Lesende sehen mandantenweit sichtbare Tickets und Tickets mit Bezug zu ihren aktuellen Subjekten "
"oder Akteurskennungen. Verborgene Tickets zählen nicht mit und belegen keine Seitenplätze. Seiten enthalten "
"höchstens 200 Datensätze, sortiert nach Serviceziel, Priorität, Änderungszeit und stabiler Ticketkennung. "
"Jede Anfrage prüft den aktuellen Zugriff erneut; Seitenabrufe bilden bei parallelen Änderungen keinen "
"unveränderlichen Snapshot. Exakte JSON-Subjektvergleiche unterstützen SQLite und PostgreSQL ohne Umwandlung "
"numerischer oder boolescher Kennungen; nicht unterstützte Datenbankdialekte werden sicher abgelehnt."
),
}},
),
DocumentationTopic(
id="tickets.module-boundary",
title="Tickets module boundary",
summary="Operational requests, incidents, problems, reports, queue work, service targets, and auditable resolution.",
body=(
"Documentation books sit immediately beside the visible heading or contextual label for "
"Tickets, not among operational action buttons. Field help remains beside its label. "
"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"),
audience=("user", "operator", "module_admin", "product_owner"),
order=100,
translations={
"de": {
"title": "Modulgrenze von Tickets",
"summary": "Operative Anfragen, Störungen, Probleme, Meldungen, Warteschlangenarbeit, Serviceziele und nachvollziehbare Lösungen.",
"body": "Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für Tickets, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe "
"bleibt neben der Feldbezeichnung. "
"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.",
}
},
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={
"seed": True,
"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."
),
"kind": "reference",
"consequence_classes": {
"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.",
},
},
),
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.action.participants",
"tickets.action.link",
"tickets.action.delete",
"tickets.section.history",
"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,
@@ -124,18 +349,79 @@ 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),
ViewSurface(id="tickets.section.history", module_id=MODULE_ID, kind="section", label="Immutable ticket history", parent_id="tickets.page.detail", order=70),
ViewSurface(id="tickets.action.participants", module_id=MODULE_ID, kind="action", label="Manage ticket participants", parent_id="tickets.page.detail", order=80),
ViewSurface(id="tickets.action.link", module_id=MODULE_ID, kind="action", label="Add or remove ticket reference", parent_id="tickets.page.detail", order=90),
ViewSurface(id="tickets.action.delete", module_id=MODULE_ID, kind="action", label="Delete ticket", parent_id="tickets.page.detail", order=100),
),
),
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,
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
@@ -0,0 +1 @@
"""Tickets-owned database migrations."""
@@ -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")
@@ -0,0 +1 @@
"""Tickets migration revisions."""
+445
View File
@@ -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"]
+111
View File
@@ -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",
]
@@ -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",
]
File diff suppressed because it is too large Load Diff
+58 -12
View File
@@ -2,30 +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.22", manifest.version)
self.assertEqual(("access",), manifest.dependencies)
self.assertEqual(
{role.slug for role in manifest.role_templates},
{"tickets_manager", "tickets_viewer"},
{
READ_SCOPE,
REPORT_SCOPE,
TRIAGE_SCOPE,
ASSIGN_SCOPE,
RESOLVE_SCOPE,
ADMIN_SCOPE,
WRITE_SCOPE,
},
{permission.scope for permission in manifest.permissions},
)
self.assertTrue(manifest.documentation)
self.assertIsNone(manifest.route_factory)
self.assertIsNone(manifest.migration_spec)
self.assertIsNone(manifest.frontend)
self.assertEqual(
{
"tickets_reporter",
"tickets_manager",
"tickets_viewer",
"tickets_administrator",
},
{role.slug for role in manifest.role_templates},
)
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,
)
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__":
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from dataclasses import replace
import unittest
from sqlalchemy import event, select
from sqlalchemy.dialects import postgresql
import test_ticket_service as fixture
from govoplan_tickets.backend import service
from govoplan_tickets.backend.db.models import Ticket
class TicketListQueryTests(unittest.TestCase):
def setUp(self):
self.fixture = fixture.TicketServiceTests()
self.fixture.setUp()
self.engine = self.fixture.Session.kw["bind"]
with self.fixture.Session() as session:
for index in range(50):
service.create_ticket(
session, self.fixture.manager,
record=replace(fixture._record(reporter="reporter-1"), ticket_id=f"ticket-{index:03}", ticket_number=f"TKT-{index:03}"),
idempotency_key=f"fixture-{index}",
)
session.commit()
def tearDown(self):
self.engine.dispose()
def test_one_row_page_loads_one_row_with_exact_total_and_stable_ties(self):
with self.fixture.Session() as session:
session.query(Ticket).update({Ticket.updated_at: fixture.NOW})
session.commit()
loaded = []
queries = []
def row_loaded(target, context):
loaded.append(target.id)
def executed(conn, cursor, statement, parameters, context, executemany):
if statement.lstrip().upper().startswith("SELECT"):
queries.append(statement)
event.listen(Ticket, "load", row_loaded)
event.listen(self.engine, "before_cursor_execute", executed)
try:
with self.fixture.Session() as session:
page, total = service.list_tickets(session, self.fixture.manager, offset=1, limit=1)
self.assertEqual(50, total)
self.assertEqual(["ticket-001"], [item.ticket_id for item in page])
self.assertEqual(["ticket-001"], loaded)
self.assertEqual(2, len(queries))
self.assertIn("LIMIT", queries[-1])
finally:
event.remove(Ticket, "load", row_loaded)
event.remove(self.engine, "before_cursor_execute", executed)
def test_count_and_page_match_current_object_authorization(self):
viewer = fixture._Principal("viewer-1", {service.READ_SCOPE})
viewer.group_ids = ("group-1",)
with self.fixture.Session() as session:
rows = session.query(Ticket).order_by(Ticket.id).all()
rows[0].visibility = "tenant"
rows[1].assignee = {"kind": "account", "id": viewer.account_id}
rows[2].reporter = {"kind": "account", "id": viewer.account_id}
rows[3].requester = {"kind": "account", "id": viewer.account_id}
rows[4].participants = [{"kind": "group", "id": "group-1"}]
rows[5].created_by = viewer.account_id
rows[6].participants = ['{"kind":"account","id":"viewer-1"}']
rows[7].tenant_id = "tenant-2"
rows[7].visibility = "tenant"
session.commit()
expected = {row.id for row in rows if service._can_read_row(viewer, row)}
actual = set()
for offset in range(len(expected)):
page, total = service.list_tickets(session, viewer, offset=offset, limit=1)
self.assertEqual(len(expected), total)
self.assertEqual(1, len(page))
actual.add(page[0].ticket_id)
self.assertEqual(expected, actual)
self.assertEqual(6, len(actual))
rows[4].participants = []
session.commit()
self.assertEqual(5, service.list_tickets(session, viewer, limit=1)[1])
viewer.scopes = frozenset()
self.assertEqual(((), 0), service.list_tickets(session, viewer, limit=1))
def test_read_predicate_compiles_for_postgresql_with_bound_subjects(self):
viewer = fixture._Principal("viewer' OR 1=1 --", {service.READ_SCOPE})
compiled = select(Ticket.id).where(service._ticket_read_predicate(viewer)).compile(dialect=postgresql.dialect())
self.assertIn("json_array_elements", str(compiled))
self.assertNotIn(viewer.account_id, str(compiled))
self.assertIn(viewer.account_id, compiled.params.values())
+367
View File
@@ -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()
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@govoplan/tickets-webui",
"version": "0.1.23",
"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
}
}
}
+23
View File
@@ -0,0 +1,23 @@
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("<Dialog"), "Ticket mutations use shared focus-contained dialogs");
assert.ok(page.includes("FieldLabel"), "Ticket fields use the shared label and help contract");
assert.ok(page.includes("StatusBadge"), "Ticket state is not conveyed by color alone");
assert.ok(page.includes("listTicketHistory"), "The workspace projects immutable ticket history");
assert.ok(page.includes("ParticipantsDialog"), "Participants use a governed shared dialog");
assert.ok(page.includes("LinkDialog"), "References and attachments use a governed shared dialog");
assert.ok(page.includes("<ConfirmDialog"), "Administrative deletion is separately confirmed");
assert.ok(page.includes("ticket-destructive-actions"), "Destructive ticket actions are visually separated");
assert.ok(!page.includes("window.alert("), "Tickets must not use browser alerts");
assert.ok(!/<(div|span|li|tr)\b[^>]*\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.");
+205
View File
@@ -0,0 +1,205 @@
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<string, unknown>;
};
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<string, unknown>;
};
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<string, boolean>;
consequences: Record<string, string>;
};
export type TicketHistoryEntry = {
revision: number;
event_type: string;
occurred_at: string;
actor_id?: string | null;
reason: string;
details: Record<string, unknown>;
};
export function listTickets(
settings: ApiSettings,
options: { statuses?: string[]; priorities?: string[]; ticketTypes?: string[]; queueRef?: string; query?: string; limit?: number },
signal?: AbortSignal
): Promise<TicketListResponse> {
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<TicketAvailability> {
return apiFetch(settings, "/api/v1/tickets/availability", { signal });
}
export function getTicket(settings: ApiSettings, ticketId: string, signal?: AbortSignal): Promise<TicketRecord> {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(ticketId)}`, { signal });
}
export function listTicketHistory(settings: ApiSettings, ticketId: string, signal?: AbortSignal): Promise<{ history: TicketHistoryEntry[] }> {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(ticketId)}/history`, { signal });
}
export function createTicket(settings: ApiSettings, record: TicketRecord): Promise<TicketRecord> {
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<string, unknown>, changeReason: string): Promise<TicketRecord> {
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<TicketRecord> {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/assignment`, {
method: "POST",
body: JSON.stringify({ ...mutation(record, changeReason), assignee })
});
}
export function replaceTicketParticipants(settings: ApiSettings, record: TicketRecord, participants: TicketSubject[], changeReason: string): Promise<TicketRecord> {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/participants`, {
method: "PUT",
body: JSON.stringify({ ...mutation(record, changeReason), participants })
});
}
export function addTicketLink(settings: ApiSettings, record: TicketRecord, link: TicketLink, changeReason: string): Promise<TicketRecord> {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/links`, {
method: "POST",
body: JSON.stringify({ ...mutation(record, changeReason), link })
});
}
export function removeTicketLink(settings: ApiSettings, record: TicketRecord, linkId: string, changeReason: string): Promise<TicketRecord> {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/links/${encodeURIComponent(linkId)}`, {
method: "DELETE",
body: JSON.stringify({ ...mutation(record, changeReason), changes: {} })
});
}
export function resolveTicket(
settings: ApiSettings,
record: TicketRecord,
targetStatus: string,
resolutionSummary: string | null,
changeReason: string
): Promise<TicketRecord> {
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<string, unknown> }>;
}
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<string, unknown> }>;
}
export function deleteTicket(settings: ApiSettings, record: TicketRecord, reason: string): Promise<TicketRecord> {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}`, {
method: "DELETE",
body: JSON.stringify({
expected_revision: record.revision,
occurred_at: new Date().toISOString(),
reason,
idempotency_key: crypto.randomUUID()
})
});
}
+804
View File
@@ -0,0 +1,804 @@
import {
MessageSquarePlus,
Link2,
Pencil,
Plus,
Search,
Send,
TicketCheck,
Trash2,
Users,
UserRoundCheck
} from "lucide-react";
import {
useEffect,
useMemo,
useState,
type FormEvent
} from "react";
import {
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FieldLabel,
FilterBar,
FormLayout,
LoadingIndicator,
PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
hasScope,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
addTicketComment,
addTicketLink,
assignTicket,
createTicket,
deleteTicket,
escalateTicket,
getTicket,
getTicketAvailability,
listTicketHistory,
listTickets,
removeTicketLink,
replaceTicketParticipants,
resolveTicket,
triageTicket,
type TicketAvailability,
type TicketHistoryEntry,
type TicketLink,
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<TicketRecord[]>([]);
const [selectedId, setSelectedId] = useState("");
const [total, setTotal] = useState(0);
const [availability, setAvailability] = useState<TicketAvailability | null>(null);
const [history, setHistory] = useState<TicketHistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [dialogError, setDialogError] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<TicketRecord | null>(null);
const [assignmentOpen, setAssignmentOpen] = useState(false);
const [resolutionOpen, setResolutionOpen] = useState(false);
const [escalationOpen, setEscalationOpen] = useState(false);
const [participantsOpen, setParticipantsOpen] = useState(false);
const [linkOpen, setLinkOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = 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 canAdmin = hasScope(auth, "tickets:ticket:admin");
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]
);
useEffect(() => {
if (!selectedId) {
setHistory([]);
return;
}
const controller = new AbortController();
void Promise.all([
getTicket(settings, selectedId, controller.signal),
listTicketHistory(settings, selectedId, controller.signal)
]).then(([record, result]) => {
setTickets((current) => current.map((item) => item.ticket_id === record.ticket_id ? record : item));
setHistory(result.history);
}).catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason, "Ticket details could not be loaded."));
});
return () => controller.abort();
}, [settings, 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<string, unknown> = {
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 saveParticipants(participants: TicketSubject[], reason: string) {
if (!selected) return;
await runAction(async () => {
const saved = await replaceTicketParticipants(settings, selected, participants, reason);
setParticipantsOpen(false);
return saved;
});
}
async function saveLink(link: TicketLink, reason: string) {
if (!selected) return;
await runAction(async () => {
const saved = await addTicketLink(settings, selected, link, reason);
setLinkOpen(false);
return saved;
});
}
async function removeLink(linkId: string) {
if (!selected) return;
await runAction(() => removeTicketLink(settings, selected, linkId, "Removed an obsolete ticket reference."));
}
async function confirmDelete() {
if (!selected) return;
setSaving(true);
setDialogError("");
try {
await deleteTicket(settings, selected, "Removed the ticket from active operational work.");
setDeleteOpen(false);
await reload();
} catch (reason) {
setDialogError(message(reason, "The ticket could not be deleted."));
} finally {
setSaving(false);
}
}
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<TicketRecord>) {
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 (
<main className="tickets-page">
<WorkspaceFrame className="tickets-shell" label="Tickets workspace" interfaceId="tickets.route.workspace" helpContextId="tickets.route.workspace" helpModuleId="tickets">
<WorkspaceActionBar
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(), loading }}
contextActions={<>
<FilterBar as="form" surface="control" wrap="never" width="default" className="tickets-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" />
<input value={query} onChange={(event) => setQuery(event.target.value)} aria-label="Search tickets" placeholder="Search number, title, description, or queue" />
<Button type="submit" variant="primary">Search</Button>
</FilterBar>
<label className="tickets-status-filter">
<span>Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="">All current tickets</option>
{[...STATES, "resolved", "closed"].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
</select>
</label>
<span className="tickets-count">{total} tickets</span>
</>}
title="Tickets"
titleLevel={1}
titleHelp={<DocumentationHelpLink reference={{ topicId: "tickets.operational-workflow", documentationType: "user" }} label="Open Tickets documentation" />}
createAction={canReport ?
<Button type="button" variant="primary" onClick={() => {
setEditing(null);
setDialogError("");
setEditorOpen(true);
}}><Plus size={16} aria-hidden="true" /> Report ticket</Button>
: undefined}
/>
{error && <DismissibleAlert tone="danger" onDismiss={() => setError("")}>{error}</DismissibleAlert>}
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError} onDismiss={() => setDialogError("")}>{dialogError}</DismissibleAlert>}
{availability && (!availability.routing.available || !availability.case_escalation.available) &&
<div className="tickets-availability" role="status">
{!availability.routing.available && <span>Automatic Helpdesk routing is unavailable; queue and target remain manual.</span>}
{!availability.case_escalation.available && <span>Cases escalation is unavailable; ticket resolution remains usable.</span>}
</div>
}
<div className="tickets-workspace">
<PageScrollViewport className="tickets-list-viewport">
{loading && <LoadingIndicator label="Loading tickets" />}
{!loading && tickets.length === 0 && <StatePanel size="compact" description="No matching tickets." />}
<SelectionList variant="navigation" label="Ticket queue">
{tickets.map((item) =>
<SelectionListItem key={item.ticket_id} selected={item.ticket_id === selectedId} onClick={() => setSelectedId(item.ticket_id)}>
<SelectionListItemContent
leading={<TicketCheck size={18} aria-hidden="true" />}
title={item.title}
description={`${item.ticket_number} · ${item.queue_ref || "No queue"} · ${formatDate(item.service_target_at)}`}
/>
<div className="ticket-list-state">
<StatusBadge status={priorityTone(item.priority)} label={humanize(item.priority)} />
<StatusBadge status={statusTone(item.status)} label={humanize(item.status)} />
</div>
</SelectionListItem>
)}
</SelectionList>
</PageScrollViewport>
<PageScrollViewport className="ticket-detail-viewport">
{selected ?
<TicketDetail
record={selected}
availability={availability}
canTriage={canTriage}
canAssign={canAssign}
canResolve={canResolve}
canAdmin={canAdmin}
canComment={canReport || canTriage}
canCreateCase={canCreateCase}
history={history}
saving={saving}
onEdit={() => {
setEditing(selected);
setDialogError("");
setEditorOpen(true);
}}
onAssign={() => setAssignmentOpen(true)}
onResolve={() => setResolutionOpen(true)}
onEscalate={() => setEscalationOpen(true)}
onParticipants={() => setParticipantsOpen(true)}
onAddLink={() => setLinkOpen(true)}
onRemoveLink={removeLink}
onDelete={() => setDeleteOpen(true)}
onComment={saveComment}
/> :
<StatePanel size="fill" title="Tickets" description="Select a ticket to inspect and continue its work." />
}
</PageScrollViewport>
</div>
</WorkspaceFrame>
<TicketEditorDialog open={editorOpen} record={editing} saving={saving} error={dialogError} onClose={() => setEditorOpen(false)} onSave={saveEditor} />
<AssignmentDialog open={assignmentOpen} record={selected} saving={saving} onClose={() => setAssignmentOpen(false)} onSave={saveAssignment} />
<ResolutionDialog open={resolutionOpen} record={selected} saving={saving} onClose={() => setResolutionOpen(false)} onSave={saveResolution} />
<EscalationDialog open={escalationOpen} record={selected} saving={saving} onClose={() => setEscalationOpen(false)} onSave={saveEscalation} />
<ParticipantsDialog open={participantsOpen} record={selected} saving={saving} onClose={() => setParticipantsOpen(false)} onSave={saveParticipants} />
<LinkDialog open={linkOpen} saving={saving} onClose={() => setLinkOpen(false)} onSave={saveLink} />
<ConfirmDialog open={deleteOpen} title="Delete ticket" message="Remove this ticket from active work? Its immutable history is retained for governed recovery and audit." confirmLabel="Delete ticket" tone="danger" busy={saving} interfaceId="tickets.action.delete" helpContextId="tickets.action.delete" helpModuleId="tickets" onCancel={() => setDeleteOpen(false)} onConfirm={() => void confirmDelete()} />
</main>
);
}
function TicketDetail({ record, availability, history, canTriage, canAssign, canResolve, canAdmin, canComment, canCreateCase, saving, onEdit, onAssign, onResolve, onEscalate, onParticipants, onAddLink, onRemoveLink, onDelete, onComment }: {
record: TicketRecord;
availability: TicketAvailability | null;
history: TicketHistoryEntry[];
canTriage: boolean;
canAssign: boolean;
canResolve: boolean;
canAdmin: boolean;
canComment: boolean;
canCreateCase: boolean;
saving: boolean;
onEdit: () => void;
onAssign: () => void;
onResolve: () => void;
onEscalate: () => void;
onParticipants: () => void;
onAddLink: () => void;
onRemoveLink: (linkId: string) => Promise<void>;
onDelete: () => void;
onComment: (body: string, visibility: "internal" | "external") => Promise<void>;
}) {
const [comment, setComment] = useState("");
const [visibility, setVisibility] = useState<"internal" | "external">("internal");
useEffect(() => {
if (!canTriage) setVisibility("external");
}, [canTriage, record.ticket_id]);
return (
<article className="ticket-detail">
<header className="ticket-detail-header">
<div>
<span className="ticket-eyebrow">{record.ticket_number} · {humanize(record.ticket_type)}</span>
<h2>{record.title}</h2>
</div>
<div className="ticket-detail-badges">
<StatusBadge status={priorityTone(record.priority)} label={humanize(record.priority)} />
<StatusBadge status={statusTone(record.status)} label={humanize(record.status)} />
</div>
</header>
<div className="ticket-detail-actions" aria-label="Ticket actions">
{canTriage && <Button type="button" onClick={onEdit}><Pencil size={16} /> Triage</Button>}
{canAssign && <Button type="button" onClick={onAssign}><UserRoundCheck size={16} /> Assign</Button>}
{canTriage && <Button type="button" interfaceId="tickets.action.participants" helpContextId="tickets.action.participants" helpModuleId="tickets" onClick={onParticipants}><Users size={16} /> Participants</Button>}
{canTriage && <Button type="button" interfaceId="tickets.action.link" helpContextId="tickets.action.link" helpModuleId="tickets" onClick={onAddLink}><Link2 size={16} /> Add reference</Button>}
{canResolve && <Button type="button" variant="primary" onClick={onResolve}><TicketCheck size={16} /> Advance</Button>}
{canTriage && canCreateCase && availability?.case_escalation.available && <Button type="button" onClick={onEscalate}><Send size={16} /> Escalate to Case</Button>}
</div>
<p className="ticket-description">{record.description}</p>
<div className="ticket-facts">
<Fact label="Queue" value={record.queue_ref || "Not selected"} />
<Fact label="Service target" value={formatDateTime(record.service_target_at)} />
<Fact label="Assignee" value={record.assignee?.label || record.assignee?.id || "Unassigned"} />
<Fact label="Revision" value={String(record.revision)} />
<Fact label="Reporter" value={record.reporter?.label || record.reporter?.id || "Not recorded"} />
<Fact label="Visibility" value={humanize(record.visibility)} />
<Fact label="Received" value={formatDateTime(record.received_at)} />
<Fact label="Resolved" value={formatDateTime(record.resolved_at)} />
</div>
{record.resolution_summary &&
<section className="ticket-section">
<h2>Resolution</h2>
<p>{record.resolution_summary}</p>
</section>
}
<section className="ticket-section">
<h2>References and attachments <span>{record.links.length}</span></h2>
{record.links.length === 0 ? <p className="ticket-muted">No typed references have been added.</p> :
<ul className="ticket-link-list">
{record.links.map((link) => <li key={link.link_id}><div>{link.url ? <a href={link.url}>{link.label || link.resource_id}</a> : <span>{link.label || link.resource_id}</span>}<small>{humanize(link.kind)} · {link.owner_module}</small></div>{canTriage && <Button type="button" variant="ghost" className="compact-action" disabled={saving} onClick={() => void onRemoveLink(link.link_id)}>Remove</Button>}</li>)}
</ul>
}
</section>
<section className="ticket-section">
<h2>Participants <span>{record.participants.length}</span></h2>
{record.participants.length === 0 ? <p className="ticket-muted">No additional participants are recorded.</p> : <ul className="ticket-participant-list">{record.participants.map((subject) => <li key={`${subject.kind}:${subject.id}:${subject.role || ""}`}><strong>{subject.label || subject.id}</strong><small>{humanize(subject.role || subject.kind)}</small></li>)}</ul>}
</section>
<section className="ticket-section" data-interface-id="tickets.section.history" data-help-context-id="tickets.section.history">
<h2>Immutable history <span>{history.length}</span></h2>
{history.length === 0 ? <p className="ticket-muted">History is loading or unavailable.</p> : <ol className="ticket-history-list">{history.map((entry) => <li key={`${entry.revision}:${entry.event_type}`}><div><strong>{humanize(entry.event_type)}</strong><span>Revision {entry.revision} · {formatDateTime(entry.occurred_at)}</span></div><p>{entry.reason}</p></li>)}</ol>}
</section>
{canComment && <section className="ticket-section">
<h2><MessageSquarePlus size={17} /> Add comment</h2>
<form className="ticket-comment-form" onSubmit={(event) => {
event.preventDefault();
const body = comment.trim();
if (!body) return;
void onComment(body, visibility).then(() => setComment(""));
}}>
<textarea value={comment} onChange={(event) => setComment(event.target.value)} rows={3} maxLength={20_000} aria-label="Ticket comment" placeholder="Record a factual follow-up" />
<select value={visibility} onChange={(event) => setVisibility(event.target.value as "internal" | "external")} aria-label="Comment visibility">
{canTriage && <option value="internal">Internal comment</option>}
<option value="external">Reporter-visible comment</option>
</select>
<Button type="submit" disabled={saving || !comment.trim()}>Add comment</Button>
</form>
</section>}
{canAdmin && <section className="ticket-destructive-actions" aria-label="Destructive ticket actions"><h2>Administrative removal</h2><p>Deletion removes the ticket from active queues and Search while retaining immutable evidence.</p><Button type="button" variant="danger" helpContextId="tickets.action.delete" helpModuleId="tickets" onClick={onDelete}><Trash2 size={16} /> Delete ticket</Button></section>}
</article>
);
}
function TicketEditorDialog({ open, record, saving, error, onClose, onSave }: {
open: boolean;
record: TicketRecord | null;
saving: boolean;
error: string;
onClose: () => void;
onSave: (values: EditorValues) => Promise<void>;
}) {
const [values, setValues] = useState(() => editorValues(record));
useEffect(() => { if (open) setValues(editorValues(record)); }, [open, record]);
function set<K extends keyof EditorValues>(key: K, value: EditorValues[K]) {
setValues((current) => ({ ...current, [key]: value }));
}
return (
<Dialog open={open} title={record ? "Triage ticket" : "Report ticket"} size="large" onClose={onClose} closeDisabled={saving} footer={<>
<Button type="button" onClick={onClose} disabled={saving}>Cancel</Button>
<Button type="submit" form="ticket-editor-form" variant="primary" disabled={saving}>{saving ? "Saving..." : "Save"}</Button>
</>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="ticket-editor-form" columns={2} gap="compact" collapseAt="narrow" className="ticket-editor-form" onSubmit={(event) => { event.preventDefault(); void onSave(values); }}>
<label><FieldLabel>Type</FieldLabel><select value={values.ticketType} onChange={(event) => set("ticketType", event.target.value as EditorValues["ticketType"])}><option value="request">Request</option><option value="incident">Incident</option><option value="problem">Problem</option><option value="report">Report</option></select></label>
<label><FieldLabel>Priority</FieldLabel><select value={values.priority} onChange={(event) => set("priority", event.target.value as EditorValues["priority"])}><option value="low">Low</option><option value="normal">Normal</option><option value="high">High</option><option value="urgent">Urgent</option></select></label>
{record && <label><FieldLabel>Status</FieldLabel><select value={values.status} disabled={record.status === "resolved" || record.status === "closed"} onChange={(event) => set("status", event.target.value)}>{!STATES.includes(values.status) && <option value={values.status}>{humanize(values.status)}</option>}{STATES.map((state) => <option key={state} value={state}>{humanize(state)}</option>)}</select></label>}
<label><FieldLabel>Visibility</FieldLabel><select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}><option value="restricted">Participants and queue staff</option><option value="tenant">Entire tenant</option></select></label>
<label className="wide"><FieldLabel>Title</FieldLabel><input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /></label>
<label className="wide"><FieldLabel>Description</FieldLabel><textarea value={values.description} required rows={6} maxLength={40_000} onChange={(event) => set("description", event.target.value)} /></label>
<label><FieldLabel help="May be selected manually when Helpdesk routing is absent.">Queue</FieldLabel><input value={values.queueRef} maxLength={255} onChange={(event) => set("queueRef", event.target.value)} /></label>
<label><FieldLabel>Service target</FieldLabel><input type="datetime-local" value={values.serviceTargetAt} onChange={(event) => set("serviceTargetAt", event.target.value)} /></label>
<label className="wide"><FieldLabel help="Stored with immutable revision evidence.">Change reason</FieldLabel><input value={values.changeReason} required maxLength={1_000} onChange={(event) => set("changeReason", event.target.value)} /></label>
</FormLayout>
</Dialog>
);
}
function AssignmentDialog({ open, record, saving, onClose, onSave }: {
open: boolean;
record: TicketRecord | null;
saving: boolean;
onClose: () => void;
onSave: (subject: TicketSubject | null, reason: string) => Promise<void>;
}) {
const [kind, setKind] = useState("account");
const [id, setId] = useState("");
const [label, setLabel] = useState("");
const [reason, setReason] = useState("Assigned ticket for further work.");
useEffect(() => {
if (!open) return;
setKind(record?.assignee?.kind || "account");
setId(record?.assignee?.id || "");
setLabel(record?.assignee?.label || "");
}, [open, record]);
return <Dialog open={open} title="Assign ticket" onClose={onClose} closeDisabled={saving} footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="ticket-assignment-form" variant="primary" disabled={saving}>Save assignment</Button></>}>
<FormLayout id="ticket-assignment-form" columns={1} gap="compact" onSubmit={(event) => { event.preventDefault(); void onSave(id.trim() ? { kind, id: id.trim(), label: label.trim() || null } : null, reason); }}>
<label><FieldLabel>Subject type</FieldLabel><select value={kind} onChange={(event) => setKind(event.target.value)}><option value="account">Account</option><option value="group">Group</option><option value="role">Role</option><option value="function">Function</option><option value="organization_unit">Organization unit</option></select></label>
<label><FieldLabel help="Leave empty to remove the current assignment.">Subject identifier</FieldLabel><input value={id} maxLength={255} onChange={(event) => setId(event.target.value)} /></label>
<label><FieldLabel>Display label</FieldLabel><input value={label} maxLength={500} onChange={(event) => setLabel(event.target.value)} /></label>
<label><FieldLabel>Change reason</FieldLabel><input value={reason} required maxLength={1_000} onChange={(event) => setReason(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function ParticipantsDialog({ open, record, saving, onClose, onSave }: {
open: boolean;
record: TicketRecord | null;
saving: boolean;
onClose: () => void;
onSave: (participants: TicketSubject[], reason: string) => Promise<void>;
}) {
const [participants, setParticipants] = useState<TicketSubject[]>([]);
const [kind, setKind] = useState("account");
const [id, setId] = useState("");
const [label, setLabel] = useState("");
const [role, setRole] = useState("");
const [reason, setReason] = useState("Updated ticket participants.");
useEffect(() => {
if (!open) return;
setParticipants(record?.participants ?? []);
setKind("account");
setId("");
setLabel("");
setRole("");
setReason("Updated ticket participants.");
}, [open, record]);
function addParticipant() {
const cleanId = id.trim();
if (!cleanId || participants.length >= 100) return;
const participant = { kind, id: cleanId, label: label.trim() || null, role: role.trim() || null };
setParticipants((current) => [
...current.filter((item) => !(item.kind === participant.kind && item.id === participant.id && item.role === participant.role)),
participant
]);
setId("");
setLabel("");
setRole("");
}
return <Dialog open={open} title="Manage participants" onClose={onClose} closeDisabled={saving} className="ticket-participants-dialog" footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="ticket-participants-form" variant="primary" disabled={saving}>Save participants</Button></>}>
<FormLayout id="ticket-participants-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave(participants, reason); }}>
<label><FieldLabel>Subject type</FieldLabel><select value={kind} onChange={(event) => setKind(event.target.value)}><option value="account">Account</option><option value="identity">Identity</option><option value="group">Group</option><option value="role">Role</option><option value="function">Function</option><option value="function_assignment">Function assignment</option><option value="organization_unit">Organization unit</option><option value="external">External</option></select></label>
<label><FieldLabel>Subject identifier</FieldLabel><input value={id} maxLength={255} onChange={(event) => setId(event.target.value)} /></label>
<label><FieldLabel>Display label</FieldLabel><input value={label} maxLength={500} onChange={(event) => setLabel(event.target.value)} /></label>
<label><FieldLabel>Ticket role</FieldLabel><input value={role} maxLength={80} onChange={(event) => setRole(event.target.value)} /></label>
<div className="wide ticket-dialog-add"><Button type="button" onClick={addParticipant} disabled={!id.trim() || participants.length >= 100}><Plus size={16} /> Add participant</Button></div>
<div className="wide ticket-dialog-list" aria-label="Selected ticket participants">
{participants.length === 0 ? <p className="ticket-muted">No additional participants.</p> : <ul>{participants.map((participant) => <li key={`${participant.kind}:${participant.id}:${participant.role || ""}`}><span><strong>{participant.label || participant.id}</strong><small>{humanize(participant.role || participant.kind)}</small></span><Button type="button" variant="ghost" onClick={() => setParticipants((current) => current.filter((item) => item !== participant))}>Remove</Button></li>)}</ul>}
</div>
<label className="wide"><FieldLabel help="Stored with immutable revision evidence.">Change reason</FieldLabel><input value={reason} required maxLength={1_000} onChange={(event) => setReason(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function LinkDialog({ open, saving, onClose, onSave }: {
open: boolean;
saving: boolean;
onClose: () => void;
onSave: (link: TicketLink, reason: string) => Promise<void>;
}) {
const [kind, setKind] = useState("related");
const [ownerModule, setOwnerModule] = useState("");
const [resourceType, setResourceType] = useState("");
const [resourceId, setResourceId] = useState("");
const [relation, setRelation] = useState("related");
const [label, setLabel] = useState("");
const [url, setUrl] = useState("");
const [reason, setReason] = useState("Added a typed ticket reference.");
useEffect(() => {
if (!open) return;
setKind("related");
setOwnerModule("");
setResourceType("");
setResourceId("");
setRelation("related");
setLabel("");
setUrl("");
setReason("Added a typed ticket reference.");
}, [open]);
return <Dialog open={open} title="Add reference or attachment" onClose={onClose} closeDisabled={saving} className="ticket-link-dialog" footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="ticket-link-form" variant="primary" disabled={saving}>Add reference</Button></>}>
<FormLayout id="ticket-link-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => {
event.preventDefault();
void onSave({
link_id: crypto.randomUUID(),
kind,
owner_module: ownerModule,
resource_type: resourceType,
resource_id: resourceId,
relation,
label: label || null,
url: url || null,
metadata: {}
}, reason);
}}>
<label><FieldLabel>Reference kind</FieldLabel><select value={kind} onChange={(event) => setKind(event.target.value)}><option value="related">Related record</option><option value="attachment">Attachment</option><option value="file">File</option><option value="project">Project</option><option value="wiki">Wiki</option><option value="asset">Asset</option><option value="facility">Facility</option><option value="form">Form</option><option value="external">External reference</option></select></label>
<label><FieldLabel>Owning module</FieldLabel><input value={ownerModule} required maxLength={100} onChange={(event) => setOwnerModule(event.target.value)} /></label>
<label><FieldLabel>Resource type</FieldLabel><input value={resourceType} required maxLength={100} onChange={(event) => setResourceType(event.target.value)} /></label>
<label><FieldLabel>Resource identifier</FieldLabel><input value={resourceId} required maxLength={255} onChange={(event) => setResourceId(event.target.value)} /></label>
<label><FieldLabel>Relation</FieldLabel><input value={relation} required maxLength={80} onChange={(event) => setRelation(event.target.value)} /></label>
<label><FieldLabel>Display label</FieldLabel><input value={label} maxLength={500} onChange={(event) => setLabel(event.target.value)} /></label>
<label className="wide"><FieldLabel help="Use a relative application path or an explicit http(s) URL.">Open link</FieldLabel><input value={url} maxLength={1_500} onChange={(event) => setUrl(event.target.value)} /></label>
<label className="wide"><FieldLabel help="Stored with immutable revision evidence.">Change reason</FieldLabel><input value={reason} required maxLength={1_000} onChange={(event) => setReason(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function ResolutionDialog({ open, record, saving, onClose, onSave }: {
open: boolean;
record: TicketRecord | null;
saving: boolean;
onClose: () => void;
onSave: (status: string, summary: string, reason: string) => Promise<void>;
}) {
const [status, setStatus] = useState("resolved");
const [summary, setSummary] = useState("");
const [reason, setReason] = useState("Advanced ticket lifecycle.");
useEffect(() => {
if (!open) return;
setStatus(record?.status === "resolved" || record?.status === "closed" ? "in_progress" : "resolved");
setSummary(record?.resolution_summary || "");
}, [open, record]);
const needsSummary = status === "resolved" || status === "closed";
return <Dialog open={open} title="Advance ticket" onClose={onClose} closeDisabled={saving} footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="ticket-resolution-form" variant="primary" disabled={saving}>Apply</Button></>}>
<FormLayout id="ticket-resolution-form" columns={1} gap="compact" onSubmit={(event) => { event.preventDefault(); void onSave(status, summary, reason); }}>
<label><FieldLabel>Target state</FieldLabel><select value={status} onChange={(event) => setStatus(event.target.value)}><option value="in_progress">In progress / reopen</option><option value="waiting">Waiting</option><option value="resolved">Resolved</option><option value="closed">Closed</option><option value="cancelled">Cancelled</option></select></label>
<label><FieldLabel help="Required when resolving or closing.">Resolution summary</FieldLabel><textarea value={summary} required={needsSummary} rows={5} maxLength={20_000} onChange={(event) => setSummary(event.target.value)} /></label>
<label><FieldLabel>Change reason</FieldLabel><input value={reason} required maxLength={1_000} onChange={(event) => setReason(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function EscalationDialog({ open, record, saving, onClose, onSave }: {
open: boolean;
record: TicketRecord | null;
saving: boolean;
onClose: () => void;
onSave: (caseType: string, note: string) => Promise<void>;
}) {
const [caseType, setCaseType] = useState("");
const [note, setNote] = useState("");
useEffect(() => { if (open) { setCaseType(""); setNote(""); } }, [open, record]);
return <Dialog open={open} title="Escalate ticket to Case" onClose={onClose} closeDisabled={saving} footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="ticket-escalation-form" variant="primary" disabled={saving}>Create linked Case</Button></>}>
<FormLayout id="ticket-escalation-form" columns={1} gap="compact" onSubmit={(event) => { event.preventDefault(); void onSave(caseType, note); }}>
<p className="ticket-muted">The Ticket remains the authoritative intake and service history. Cases owns the formal procedure and returns a stable link.</p>
<label><FieldLabel help="Must match an active type configured in Cases.">Case type key</FieldLabel><input value={caseType} required maxLength={120} onChange={(event) => setCaseType(event.target.value)} /></label>
<label><FieldLabel>Handoff note</FieldLabel><textarea value={note} rows={5} maxLength={10_000} onChange={(event) => setNote(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function Fact({ label, value }: { label: string; value: string }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function editorValues(record: TicketRecord | null): EditorValues {
return {
ticketType: record?.ticket_type ?? "request",
priority: record?.priority ?? "normal",
status: record?.status ?? "new",
title: record?.title ?? "",
description: record?.description ?? "",
visibility: record?.visibility ?? "restricted",
queueRef: record?.queue_ref ?? "",
serviceTargetAt: dateTimeInput(record?.service_target_at),
changeReason: record ? "Updated ticket triage information." : "Reported an operational request."
};
}
function hasAny(auth: PlatformRouteContext["auth"], ...scopes: string[]): boolean {
return scopes.some((scope) => hasScope(auth, scope));
}
function ticketNumber(id: string): string {
const date = new Date().toISOString().slice(0, 10).replaceAll("-", "");
return `TKT-${date}-${id.slice(0, 8).toUpperCase()}`;
}
function humanize(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function statusTone(status: string): string {
if (status === "resolved" || status === "closed") return "active";
if (status === "cancelled") return "inactive";
if (status === "waiting") return "warning";
return "pending";
}
function priorityTone(priority: string): string {
if (priority === "urgent") return "danger";
if (priority === "high") return "warning";
return "inactive";
}
function formatDate(value?: string | null): string {
if (!value) return "No target";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(value));
}
function formatDateTime(value?: string | null): string {
if (!value) return "Not set";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function dateTimeInput(value?: string | null): string {
if (!value) return "";
const date = new Date(value);
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
}
function dateTimeValue(value: string): string | null {
return value ? new Date(value).toISOString() : null;
}
function message(reason: unknown, fallback: string): string {
return reason instanceof Error ? reason.message : fallback;
}
+12
View File
@@ -0,0 +1,12 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
/** Module-owned translations for contextual headings. */
export const generatedTranslations: PlatformTranslations = {
en: {
"Tickets": "Tickets",
},
de: {
"Tickets": "Tickets",
},
};
+1
View File
@@ -0,0 +1 @@
export { ticketsModule as default, ticketsModule } from "./module";
+55
View File
@@ -0,0 +1,55 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/tickets.css";
const TicketsPage = lazy(() => import("./features/tickets/TicketsPage"));
export const ticketsModule: PlatformWebModule = {
translations: generatedTranslations,
id: "tickets",
label: "Tickets",
version: "0.1.20",
optionalDependencies: [
"cases",
"helpdesk",
"projects",
"wiki",
"files",
"search"
],
routes: [
{
path: "/tickets",
anyOf: ["tickets:ticket:read"],
order: 22,
surfaceId: "tickets.route.workspace",
render: (context) => createElement(TicketsPage, context)
}
],
navItems: [
{
to: "/tickets",
label: "Tickets",
iconName: "ticket-check",
anyOf: ["tickets:ticket:read"],
order: 22,
surfaceId: "tickets.navigation"
}
],
viewSurfaces: [
{ id: "tickets.navigation", moduleId: "tickets", kind: "navigation", label: "Tickets navigation", order: 10 },
{ id: "tickets.route.workspace", moduleId: "tickets", kind: "route", label: "Tickets workspace", order: 20 },
{ id: "tickets.page.queue", moduleId: "tickets", kind: "section", label: "Ticket queue", parentId: "tickets.route.workspace", order: 30 },
{ id: "tickets.page.detail", moduleId: "tickets", kind: "section", label: "Ticket details", parentId: "tickets.route.workspace", order: 40 },
{ id: "tickets.action.report", moduleId: "tickets", kind: "action", label: "Report ticket", parentId: "tickets.page.queue", order: 50 },
{ id: "tickets.action.resolve", moduleId: "tickets", kind: "action", label: "Resolve ticket", parentId: "tickets.page.detail", order: 60 },
{ id: "tickets.section.history", moduleId: "tickets", kind: "section", label: "Immutable ticket history", parentId: "tickets.page.detail", order: 70 },
{ id: "tickets.action.participants", moduleId: "tickets", kind: "action", label: "Manage ticket participants", parentId: "tickets.page.detail", order: 80 },
{ id: "tickets.action.link", moduleId: "tickets", kind: "action", label: "Add or remove ticket reference", parentId: "tickets.page.detail", order: 90 },
{ id: "tickets.action.delete", moduleId: "tickets", kind: "action", label: "Delete ticket", parentId: "tickets.page.detail", order: 100 }
]
};
export default ticketsModule;
+331
View File
@@ -0,0 +1,331 @@
.tickets-page {
height: 100%;
min-height: 0;
overflow: hidden;
}
.tickets-search {
flex: 1 1 540px;
}
.tickets-status-filter {
display: flex;
align-items: center;
gap: 7px;
}
.tickets-status-filter > span,
.tickets-count {
color: var(--text-soft);
font-size: 0.8rem;
}
.tickets-count {
margin-left: auto;
}
.tickets-availability {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface-subtle, var(--surface));
color: var(--text-soft);
font-size: 0.78rem;
}
.tickets-workspace {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: minmax(390px, 42%) minmax(0, 1fr);
}
.tickets-list-viewport,
.ticket-detail-viewport {
min-height: 0;
padding: 14px;
}
.tickets-list-viewport {
border-right: 1px solid var(--border);
background: var(--surface-subtle, var(--surface));
}
.ticket-list-state {
display: flex;
align-items: center;
gap: 5px;
}
.ticket-detail {
width: min(100%, 980px);
margin: 0 auto;
padding: 4px 8px 32px;
}
.ticket-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 15px;
border-bottom: 1px solid var(--border);
}
.ticket-detail-header h2 {
margin: 4px 0 0;
font-size: 1.4rem;
letter-spacing: 0;
}
.ticket-eyebrow,
.ticket-muted {
color: var(--text-soft);
font-size: 0.82rem;
}
.ticket-detail-badges,
.ticket-detail-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
}
.ticket-detail-actions {
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.ticket-description {
max-width: 78ch;
margin: 18px 0;
white-space: pre-wrap;
}
.ticket-facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-compact);
background: var(--border);
}
.ticket-facts > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
padding: 11px;
background: var(--surface-raised);
}
.ticket-facts span {
color: var(--text-soft);
font-size: 0.76rem;
}
.ticket-facts strong {
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.86rem;
}
.ticket-section {
margin-top: 24px;
}
.ticket-section h2 {
display: flex;
align-items: center;
gap: 7px;
margin: 0 0 10px;
font-size: 0.98rem;
letter-spacing: 0;
}
.ticket-section h2 span {
color: var(--text-soft);
font-size: 0.8rem;
}
.ticket-link-list {
margin: 0;
padding: 0;
list-style: none;
}
.ticket-link-list li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 9px 2px;
border-bottom: 1px solid var(--border);
}
.ticket-link-list small {
color: var(--text-soft);
}
.ticket-link-list li > div,
.ticket-participant-list li,
.ticket-dialog-list li,
.ticket-history-list li > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.ticket-participant-list,
.ticket-dialog-list ul,
.ticket-history-list {
margin: 0;
padding: 0;
list-style: none;
}
.ticket-participant-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.ticket-participant-list li {
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
.ticket-participant-list small,
.ticket-dialog-list small,
.ticket-history-list span {
color: var(--text-soft);
font-size: 0.76rem;
}
.ticket-history-list li {
display: grid;
grid-template-columns: minmax(180px, 0.35fr) minmax(0, 1fr);
gap: 16px;
padding: 10px 2px;
border-bottom: 1px solid var(--border);
}
.ticket-history-list p {
margin: 0;
}
.ticket-dialog-add {
display: flex;
justify-content: flex-end;
}
.ticket-dialog-list {
max-height: 220px;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-compact);
}
.ticket-dialog-list > p {
padding: 10px;
}
.ticket-dialog-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.ticket-destructive-actions {
margin-top: 32px;
padding-top: 18px;
border-top: 1px solid var(--danger);
}
.ticket-destructive-actions h2 {
margin: 0 0 6px;
font-size: 0.98rem;
}
.ticket-destructive-actions p {
color: var(--text-soft);
}
.ticket-comment-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: end;
gap: 8px;
}
.ticket-comment-form textarea {
grid-row: span 2;
resize: vertical;
}
.ticket-editor-form label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
@media (max-width: 900px) {
.tickets-workspace {
grid-template-columns: minmax(330px, 46%) minmax(0, 1fr);
}
.ticket-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.tickets-count {
margin-left: 0;
}
.tickets-workspace {
grid-template-columns: 1fr;
}
.tickets-list-viewport {
max-height: 42vh;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.ticket-comment-form {
grid-template-columns: 1fr;
}
.ticket-history-list {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.ticket-detail-header,
.ticket-link-list li {
flex-direction: column;
}
.ticket-facts {
grid-template-columns: 1fr;
}
.ticket-participant-list {
grid-template-columns: 1fr;
}
}