feat(tickets): deliver canonical operational lifecycle
This commit is contained in:
@@ -11,7 +11,11 @@ resolution.
|
|||||||
|
|
||||||
Its runtime module ID is `tickets`.
|
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
|
## Initial Ownership
|
||||||
|
|
||||||
@@ -52,6 +56,24 @@ Expected optional integrations:
|
|||||||
- notifications
|
- notifications
|
||||||
- search
|
- 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
|
## Development Install
|
||||||
|
|
||||||
From the core checkout:
|
From the core checkout:
|
||||||
@@ -61,11 +83,12 @@ cd /mnt/DATA/git/govoplan-core
|
|||||||
./.venv/bin/python -m pip install -e ../govoplan-tickets
|
./.venv/bin/python -m pip install -e ../govoplan-tickets
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused manifest verification:
|
Focused verification:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-tickets
|
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
|
## Gitea Workflow
|
||||||
|
|||||||
@@ -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,
|
The former Issue Reporting and Helpdesk concepts become ticket type, intake,
|
||||||
queue, and policy profiles. They do not need separate persistence models.
|
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-isolated ticket identity and current state
|
||||||
- tenant-level permission definitions
|
- replay-safe reporting and guarded revision mutations
|
||||||
- manager and viewer role templates
|
- distinct reporting, triage, assignment, resolution, and administration scopes
|
||||||
- documentation topic describing the module boundary
|
- reporter, requester, assignee, and typed participant references
|
||||||
- Gitea issue workflow templates
|
- related-work and attachment references without copying owner-module content
|
||||||
- manifest contract test
|
- 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,
|
Core contracts keep integrations optional and implementation-independent:
|
||||||
resolution, and stable escalation links to cases.
|
|
||||||
|
- `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
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/tickets",
|
"name": "@govoplan/tickets",
|
||||||
"version": "0.1.19",
|
"version": "0.1.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GovOPlaN Tickets platform module seed.",
|
"description": "Canonical GovOPlaN operational ticket lifecycle module.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"peerDependencies": {}
|
"peerDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-tickets"
|
name = "govoplan-tickets"
|
||||||
version = "0.1.19"
|
version = "0.1.20"
|
||||||
description = "GovOPlaN Tickets platform module seed."
|
description = "Canonical GovOPlaN operational ticket lifecycle module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.30",
|
||||||
"govoplan-access>=0.1.18",
|
"govoplan-access>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from govoplan_tickets.backend.db.models import (
|
||||||
|
Ticket,
|
||||||
|
TicketComment,
|
||||||
|
TicketEscalation,
|
||||||
|
TicketHistory,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["Ticket", "TicketComment", "TicketEscalation", "TicketHistory"]
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -1,17 +1,71 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from pathlib import Path
|
||||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
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_ID = "tickets"
|
||||||
MODULE_NAME = "Tickets"
|
MODULE_NAME = "Tickets"
|
||||||
MODULE_VERSION = "0.1.19"
|
MODULE_VERSION = "0.1.20"
|
||||||
READ_SCOPE = "tickets:ticket:read"
|
WRITE_SCOPE = LEGACY_WRITE_SCOPE
|
||||||
WRITE_SCOPE = "tickets:ticket:write"
|
|
||||||
ADMIN_SCOPE = "tickets:ticket:admin"
|
|
||||||
OPTIONAL_DEPENDENCIES = (
|
OPTIONAL_DEPENDENCIES = (
|
||||||
"cases",
|
"cases",
|
||||||
|
"helpdesk",
|
||||||
"projects",
|
"projects",
|
||||||
"wiki",
|
"wiki",
|
||||||
"assets",
|
"assets",
|
||||||
@@ -33,7 +87,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
scope=scope,
|
scope=scope,
|
||||||
label=label,
|
label=label,
|
||||||
description=description,
|
description=description,
|
||||||
category="Tickets",
|
category=MODULE_NAME,
|
||||||
level="tenant",
|
level="tenant",
|
||||||
module_id=module_id,
|
module_id=module_id,
|
||||||
resource=resource,
|
resource=resource,
|
||||||
@@ -41,30 +95,64 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _router(context: ModuleContext):
|
||||||
|
from govoplan_tickets.backend.router import create_router
|
||||||
|
|
||||||
|
return create_router(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry(context: ModuleContext) -> SqlTicketRegistry:
|
||||||
|
return SqlTicketRegistry(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(_context: ModuleContext) -> TicketsDsarProvider:
|
||||||
|
return TicketsDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
|
counts = {
|
||||||
|
status: int(count)
|
||||||
|
for status, count in session.query(ticket_models.Ticket.status, func.count())
|
||||||
|
.filter(
|
||||||
|
ticket_models.Ticket.tenant_id == tenant_id,
|
||||||
|
ticket_models.Ticket.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.group_by(ticket_models.Ticket.status)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"tickets": sum(counts.values()),
|
||||||
|
"open_tickets": sum(
|
||||||
|
count
|
||||||
|
for state, count in counts.items()
|
||||||
|
if state not in {"resolved", "closed", "cancelled"}
|
||||||
|
),
|
||||||
|
"resolved_tickets": counts.get("resolved", 0) + counts.get("closed", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(
|
_permission(READ_SCOPE, "View tickets", "Read discoverable tickets, comments, links, queue state, and immutable history."),
|
||||||
READ_SCOPE,
|
_permission(REPORT_SCOPE, "Report tickets", "Create operational reports and requests and add external follow-up comments."),
|
||||||
"View tickets",
|
_permission(TRIAGE_SCOPE, "Triage tickets", "Classify tickets, manage participants and links, and select queues and service targets."),
|
||||||
"Read discoverable tickets, queue state, and resolution context.",
|
_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(
|
_permission(ADMIN_SCOPE, "Administer tickets", "Inspect all tenant tickets, configure ticket behavior, and soft-delete operational records."),
|
||||||
WRITE_SCOPE,
|
_permission(LEGACY_WRITE_SCOPE, "Manage tickets (compatibility)", "Preserve existing broad manager grants while deployments migrate to the distinct triage, assignment, and resolution scopes."),
|
||||||
"Manage tickets",
|
|
||||||
"Create, triage, assign, update, resolve, and link tickets.",
|
|
||||||
),
|
|
||||||
_permission(
|
|
||||||
ADMIN_SCOPE,
|
|
||||||
"Administer tickets",
|
|
||||||
"Configure ticket types, queues, service policies, and intake profiles.",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
|
RoleTemplate(
|
||||||
|
slug="tickets_reporter",
|
||||||
|
name="Tickets reporter",
|
||||||
|
description="Report and follow accessible operational tickets.",
|
||||||
|
permissions=(READ_SCOPE, REPORT_SCOPE),
|
||||||
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="tickets_manager",
|
slug="tickets_manager",
|
||||||
name="Tickets manager",
|
name="Tickets manager",
|
||||||
description="Triage, assign, update, and resolve tickets.",
|
description="Triage, assign, update, escalate, and resolve tickets.",
|
||||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
permissions=(READ_SCOPE, REPORT_SCOPE, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="tickets_viewer",
|
slug="tickets_viewer",
|
||||||
@@ -72,20 +160,23 @@ ROLE_TEMPLATES = (
|
|||||||
description="Read discoverable tickets and their resolution context.",
|
description="Read discoverable tickets and their resolution context.",
|
||||||
permissions=(READ_SCOPE,),
|
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 = (
|
DOCUMENTATION = (
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.module-boundary",
|
id="tickets.module-boundary",
|
||||||
title=f"{MODULE_NAME} module boundary",
|
title="Tickets module boundary",
|
||||||
summary=(
|
summary="Operational requests, incidents, problems, reports, queue work, service targets, and auditable resolution.",
|
||||||
"Queue-oriented reports, requests, incidents, problems, triage, "
|
|
||||||
"routing, service work, and auditable resolution."
|
|
||||||
),
|
|
||||||
body=(
|
body=(
|
||||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
"Tickets owns the operational service record and its lifecycle. Cases remains authoritative for formal procedures; "
|
||||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
"Helpdesk may contribute routing and service-target policy without creating a second ticket store. Attachments and "
|
||||||
"database models, migrations, and WebUI routes are introduced."
|
"related work are typed references. Soft deletion removes a ticket from ordinary work and Search while retaining its immutable history."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -93,41 +184,122 @@ DOCUMENTATION = (
|
|||||||
translations={
|
translations={
|
||||||
"de": {
|
"de": {
|
||||||
"title": "Modulgrenze von Tickets",
|
"title": "Modulgrenze von Tickets",
|
||||||
"summary": "Warteschlangenorientierte Meldungen, Anfragen, Störungen, Probleme, Triage, Weiterleitung, Servicearbeit und nachvollziehbare Lösungen.",
|
"summary": "Operative Anfragen, Störungen, Probleme, Meldungen, Warteschlangenarbeit, Serviceziele und nachvollziehbare Lösungen.",
|
||||||
"body": "Dieses Repository ist derzeit ein Grundgerüst für ein Plattformmodul. Es registriert die Fachgrenze, Berechtigungsoberfläche, Rollenvorlagen und Dokumentationsmetadaten, bevor Laufzeit-APIs, Datenbankmodelle, Migrationen und WebUI-Routen eingeführt werden.",
|
"body": "Tickets verantwortet den operativen Servicevorgang und seinen Lebenszyklus. Cases bleibt für förmliche Verfahren maßgeblich; Helpdesk kann Weiterleitung und Serviceziele beisteuern, ohne einen zweiten Ticketspeicher anzulegen. Anhänge und verbundene Arbeit sind typisierte Verweise. Eine weiche Löschung entfernt ein Ticket aus der normalen Arbeit und Suche, erhält aber die unveränderliche Historie.",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
order=100,
|
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
links=(
|
links=(DocumentationLink(label="Repository domain boundary", href="govoplan-tickets/docs/TICKETS_DOMAIN_BOUNDARY.md", kind="repository"),),
|
||||||
DocumentationLink(
|
|
||||||
label="Repository domain boundary",
|
|
||||||
href="govoplan-tickets/docs/TICKETS_DOMAIN_BOUNDARY.md",
|
|
||||||
kind="repository",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
metadata={
|
metadata={
|
||||||
"kind": "reference",
|
"kind": "reference",
|
||||||
"seed": True,
|
|
||||||
"consequence_classes": {
|
"consequence_classes": {
|
||||||
"seed_boundary": "Declares ownership and permissions only; no runtime workflow is available yet.",
|
"canonical_store": "Tickets is the only operational ticket store; Helpdesk supplies policy and queue semantics.",
|
||||||
|
"case_boundary": "Case escalation creates a stable auditable link and never converts or copies ticket history.",
|
||||||
|
"soft_delete": "Deletion hides current work but preserves immutable accountability evidence.",
|
||||||
},
|
},
|
||||||
"domain_objects": [
|
|
||||||
"ticket",
|
|
||||||
"ticket type and queue",
|
|
||||||
"triage and routing facts",
|
|
||||||
"reporter and requester references",
|
|
||||||
"assignment and service-level state",
|
|
||||||
"resolution and escalation links",
|
|
||||||
],
|
|
||||||
"first_slice": (
|
|
||||||
"Define ticket identity, intake profiles, queues, triage, "
|
|
||||||
"assignment, resolution, and stable escalation links to cases."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="tickets.operational-workflow",
|
||||||
|
title="Report, triage, assign, and resolve tickets",
|
||||||
|
summary="Use guarded actions and revision evidence to move operational work from intake to a confirmed outcome.",
|
||||||
|
body=(
|
||||||
|
"Reporters create a request, incident, problem, or report. Triage staff classify it, select a queue and target, and manage typed participants and references. "
|
||||||
|
"Assignment and resolution are separate permissions. Every accepted mutation requires the current revision, a reason, actor, time, and idempotency key. "
|
||||||
|
"Resolved and closed tickets require a resolution summary; reopening clears the resolved timestamp and continues the same record."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("user", "operator", "tenant_admin"),
|
||||||
|
conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, REPORT_SCOPE, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE, ADMIN_SCOPE)),),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Tickets melden, sichten, zuweisen und lösen",
|
||||||
|
"summary": "Operative Arbeit mit geschützten Aktionen und Revisionsnachweisen von der Aufnahme bis zum bestätigten Ergebnis führen.",
|
||||||
|
"body": "Meldende erstellen eine Anfrage, Störung, ein Problem oder eine Meldung. Die Triage klassifiziert sie, wählt Warteschlange und Ziel und pflegt typisierte Beteiligte und Verweise. Zuweisung und Lösung sind getrennte Berechtigungen. Jede angenommene Änderung benötigt die aktuelle Revision, einen Grund, Akteur, Zeitpunkt und Idempotenzschlüssel. Gelöste und geschlossene Tickets benötigen eine Lösungszusammenfassung; eine Wiedereröffnung führt denselben Vorgang fort.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
related_modules=("helpdesk", "cases", "files", "search"),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"tickets.route.workspace",
|
||||||
|
"tickets.action.report",
|
||||||
|
"tickets.action.triage",
|
||||||
|
"tickets.action.assign",
|
||||||
|
"tickets.action.resolve",
|
||||||
|
"tickets.action.escalate",
|
||||||
|
"tickets.field.queue",
|
||||||
|
"tickets.field.service-target",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="tickets.optional-integrations",
|
||||||
|
title="Optional ticket integrations and reduced installations",
|
||||||
|
summary="Understand what remains available when Cases, Helpdesk, Files, Projects, Wiki, or Search is absent.",
|
||||||
|
body=(
|
||||||
|
"Without Helpdesk, authorized users select queues and service targets manually. Without Cases, formal escalation is disabled but ticket resolution remains available. "
|
||||||
|
"Without Files, attachments remain external references and Tickets never stores bytes. Project and Wiki references are retained without owner validation when those modules are absent. "
|
||||||
|
"Without Search, the ticket workspace and API remain usable but global discovery and indexing are unavailable. The workspace reports these consequences explicitly."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "tenant_admin"),
|
||||||
|
conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, ADMIN_SCOPE)),),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Optionale Ticket-Integrationen und reduzierte Installationen",
|
||||||
|
"summary": "Verstehen, was ohne Cases, Helpdesk, Files, Projects, Wiki oder Search verfügbar bleibt.",
|
||||||
|
"body": "Ohne Helpdesk wählen Berechtigte Warteschlange und Serviceziel manuell. Ohne Cases ist die förmliche Eskalation deaktiviert, die Ticketlösung bleibt verfügbar. Ohne Files bleiben Anhänge externe Verweise; Tickets speichert keine Dateiinhalte. Projekt- und Wiki-Verweise bleiben ohne Prüfung durch das Eigentümermodul erhalten. Ohne Search bleiben Arbeitsbereich und API nutzbar, globale Suche und Indizierung fehlen. Der Arbeitsbereich weist auf diese Folgen hin.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={"kind": "workflow", "help_contexts": ["tickets.page.availability"]},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="tickets.data-subject-requests",
|
||||||
|
title="Ticket data-subject requests",
|
||||||
|
summary="Export exact ticket participation and minimized actor attribution without automatically changing operational evidence.",
|
||||||
|
body=(
|
||||||
|
"Tickets matches exact account, identity, and membership identifiers inside the active tenant. Reporter, requester, assignee, participant, and authored-comment records include bounded ticket context. "
|
||||||
|
"Actor-only matches expose minimized lifecycle attribution. Arbitrary metadata, internal comments by other actors, request hashes, and idempotency keys are excluded. "
|
||||||
|
"Erasure remains a manual owner and retention review because ticket content and attribution can be institutional accountability evidence."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("tenant_admin", "privacy_officer", "auditor", "user"),
|
||||||
|
conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, ADMIN_SCOPE)),),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzanfragen für Tickets",
|
||||||
|
"summary": "Exakte Ticket-Beteiligung und minimierte Akteurszuordnung exportieren, ohne operative Nachweise automatisch zu verändern.",
|
||||||
|
"body": "Tickets gleicht exakte Konto-, Identitäts- und Mitgliedschaftskennungen innerhalb des aktiven Mandanten ab. Treffer als meldende, anfragende, zugewiesene oder beteiligte Person sowie eigene Kommentare enthalten begrenzten Ticketkontext. Reine Akteurstreffer liefern eine minimierte Lebenszykluszuordnung. Beliebige Metadaten, interne Kommentare anderer Akteure, Anfrage-Hashes und Idempotenzschlüssel werden ausgeschlossen. Eine Löschung bleibt eine manuelle Prüfung durch Eigentümer und Aufbewahrungsverantwortliche, weil Inhalt und Zuordnung institutionelle Rechenschaftsnachweise sein können.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={"kind": "workflow", "help_contexts": ["tickets.admin.dsar"]},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||||
|
layer="human_work_procedure",
|
||||||
|
kind="domain",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
evidence=(
|
||||||
|
ModuleMaturityEvidence(kind="documentation", reference="docs/TICKETS_DOMAIN_BOUNDARY.md", summary="Defines ticket, Case, and Helpdesk authority boundaries."),
|
||||||
|
ModuleMaturityEvidence(kind="test", reference="tests/test_ticket_service.py", summary="Proves lifecycle, replay safety, tenant isolation, access, integrations, history, and Search changes."),
|
||||||
|
),
|
||||||
|
known_limits=(
|
||||||
|
"Helpdesk-owned configurable queue and service-profile administration is a separate provider slice.",
|
||||||
|
"External service-desk transport and synchronization remain connector-owned work.",
|
||||||
|
"Attachments are file references; binary storage remains Files-owned.",
|
||||||
|
),
|
||||||
|
supported_authority_modes=("native_authoritative", "linked_reference", "governed_sync"),
|
||||||
|
owned_concepts=("ticket", "ticket queue reference", "ticket lifecycle", "ticket resolution", "ticket escalation link"),
|
||||||
|
non_owned_concepts=("formal case", "helpdesk service profile", "file content", "external service-desk transport"),
|
||||||
|
documentation=ModuleArchitectureDocumentation(operations=("docs/TICKETS_DOMAIN_BOUNDARY.md",)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
@@ -135,18 +307,70 @@ manifest = ModuleManifest(
|
|||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
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,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
documentation=DOCUMENTATION,
|
route_factory=_router,
|
||||||
architecture=declared_module_architecture(
|
nav_items=(NavItem(path="/tickets", label="Tickets", icon="ticket-check", required_any=(READ_SCOPE,), order=22, surface_id="tickets.navigation"),),
|
||||||
layer="human_work_procedure",
|
frontend=FrontendModule(
|
||||||
kind="domain",
|
module_id=MODULE_ID,
|
||||||
maturity="scaffold",
|
package_name="@govoplan/tickets-webui",
|
||||||
documentation_ref="docs/TICKETS_DOMAIN_BOUNDARY.md",
|
routes=(FrontendRoute(path="/tickets", component="TicketsPage", required_any=(READ_SCOPE,), order=22, surface_id="tickets.route.workspace"),),
|
||||||
known_limits=("Ticket persistence, queues, SLA, and external service-desk adapters are not implemented yet.",),
|
nav_items=(NavItem(path="/tickets", label="Tickets", icon="ticket-check", required_any=(READ_SCOPE,), order=22, surface_id="tickets.navigation"),),
|
||||||
owned_concepts=("ticket", "ticket queue", "ticket transition"),
|
product_areas=(
|
||||||
non_owned_concepts=("case", "project", "external service-desk record"),
|
ProductAreaContribution(
|
||||||
|
id="work",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.work",
|
||||||
|
icon="list-checks",
|
||||||
|
description="i18n:govoplan-core.product_area.work_description",
|
||||||
|
surface_ids=("tickets.route.workspace",),
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(id="tickets.page.queue", module_id=MODULE_ID, kind="section", label="Ticket queue", parent_id="tickets.route.workspace", order=30),
|
||||||
|
ViewSurface(id="tickets.page.detail", module_id=MODULE_ID, kind="section", label="Ticket details", parent_id="tickets.route.workspace", order=40),
|
||||||
|
ViewSurface(id="tickets.action.report", module_id=MODULE_ID, kind="action", label="Report ticket", parent_id="tickets.page.queue", order=50),
|
||||||
|
ViewSurface(id="tickets.action.resolve", module_id=MODULE_ID, kind="action", label="Resolve ticket", parent_id="tickets.page.detail", order=60),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
capability_factories={CAPABILITY_TICKETS_REGISTRY: _registry, TICKETS_DSAR_CAPABILITY: _dsar_provider},
|
||||||
|
capability_documentation={
|
||||||
|
CAPABILITY_TICKETS_REGISTRY: CapabilityDocumentation(label="Tickets registry", summary="Creates and reads replay-safe tenant ticket records without importing the Tickets implementation.", contract_version="1.0.0"),
|
||||||
|
TICKETS_DSAR_CAPABILITY: CapabilityDocumentation(label="Tickets data-subject request provider", summary="Exports bounded ticket participation and minimized immutable actor attribution.", contract_version="0.1.0"),
|
||||||
|
},
|
||||||
|
search_sources=(SearchSourceProviderRegistration(id="tickets.tickets", factory=create_tickets_search_source),),
|
||||||
|
migration_spec=MigrationSpec(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
metadata=Base.metadata,
|
||||||
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
ticket_models.TicketEscalation,
|
||||||
|
ticket_models.TicketComment,
|
||||||
|
ticket_models.TicketHistory,
|
||||||
|
ticket_models.Ticket,
|
||||||
|
label="Tickets",
|
||||||
|
),
|
||||||
|
retirement_notes="Destructive retirement removes ticket state and evidence only after an explicit database snapshot and retention review.",
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
ticket_models.Ticket,
|
||||||
|
ticket_models.TicketHistory,
|
||||||
|
ticket_models.TicketComment,
|
||||||
|
ticket_models.TicketEscalation,
|
||||||
|
label="Tickets",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation=DOCUMENTATION,
|
||||||
|
architecture=ARCHITECTURE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tickets-owned database migrations."""
|
||||||
+140
@@ -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."""
|
||||||
@@ -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"]
|
||||||
@@ -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
+56
-19
@@ -2,39 +2,76 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
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 (
|
from govoplan_tickets.backend.manifest import (
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
|
ASSIGN_SCOPE,
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
|
REPORT_SCOPE,
|
||||||
|
RESOLVE_SCOPE,
|
||||||
|
TRIAGE_SCOPE,
|
||||||
WRITE_SCOPE,
|
WRITE_SCOPE,
|
||||||
get_manifest,
|
get_manifest,
|
||||||
)
|
)
|
||||||
|
from govoplan_tickets.backend.service import CAPABILITY_TICKETS_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
class ManifestSeedTests(unittest.TestCase):
|
class ManifestTests(unittest.TestCase):
|
||||||
def test_manifest_registers_seed_contract(self) -> None:
|
def test_manifest_registers_the_vertical_slice(self) -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual(manifest.id, "tickets")
|
self.assertEqual("tickets", manifest.id)
|
||||||
self.assertEqual(manifest.name, "Tickets")
|
self.assertEqual("0.1.20", manifest.version)
|
||||||
self.assertEqual(manifest.dependencies, ("access",))
|
self.assertEqual(("access",), manifest.dependencies)
|
||||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
READ_SCOPE,
|
||||||
|
REPORT_SCOPE,
|
||||||
|
TRIAGE_SCOPE,
|
||||||
|
ASSIGN_SCOPE,
|
||||||
|
RESOLVE_SCOPE,
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
},
|
||||||
|
{permission.scope for permission in manifest.permissions},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"tickets_reporter",
|
||||||
|
"tickets_manager",
|
||||||
|
"tickets_viewer",
|
||||||
|
"tickets_administrator",
|
||||||
|
},
|
||||||
{role.slug for role in manifest.role_templates},
|
{role.slug for role in manifest.role_templates},
|
||||||
{"tickets_manager", "tickets_viewer"},
|
|
||||||
)
|
)
|
||||||
self.assertTrue(manifest.documentation)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
topic = manifest.documentation[0]
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
self.assertEqual("reference", topic.metadata["kind"])
|
self.assertIsNotNone(manifest.frontend)
|
||||||
self.assertIn("seed_boundary", topic.metadata["consequence_classes"])
|
self.assertEqual(1, len(manifest.search_sources))
|
||||||
self.assertTrue(
|
self.assertIn(CAPABILITY_TICKETS_REGISTRY, manifest.capability_factories)
|
||||||
all(
|
self.assertIn(TICKETS_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
topic.translations.get("de", {}).get(field)
|
self.assertEqual(
|
||||||
for field in ("title", "summary", "body")
|
{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.assertIsNone(manifest.route_factory)
|
self.assertIn("Helpdesk", optional.body)
|
||||||
self.assertIsNone(manifest.migration_spec)
|
self.assertIn("Search", optional.body)
|
||||||
self.assertIsNone(manifest.frontend)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/tickets-webui",
|
||||||
|
"version": "0.1.20",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/tickets.css": "./src/styles/tickets.css"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.30",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const page = fs.readFileSync("src/features/tickets/TicketsPage.tsx", "utf8");
|
||||||
|
const styles = fs.readFileSync("src/styles/tickets.css", "utf8");
|
||||||
|
|
||||||
|
assert.ok(page.includes("WorkspaceActionBar"), "Tickets uses the semantic shared action bar");
|
||||||
|
assert.ok(page.includes("refreshable"), "The refreshable Tickets workspace exposes Reload");
|
||||||
|
assert.ok(page.includes("DocumentationHelpLink"), "Tickets exposes contextual documentation");
|
||||||
|
assert.ok(page.includes("PageScrollViewport"), "Tickets owns bounded queue and detail scrolling");
|
||||||
|
assert.ok(page.includes("<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("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.");
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
apiFetch,
|
||||||
|
apiPath,
|
||||||
|
type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
|
||||||
|
export type TicketSubject = {
|
||||||
|
kind: string;
|
||||||
|
id: string;
|
||||||
|
label?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketLink = {
|
||||||
|
link_id: string;
|
||||||
|
kind: string;
|
||||||
|
owner_module: string;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
relation: string;
|
||||||
|
label?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
metadata: Record<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 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 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 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> }>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
import {
|
||||||
|
MessageSquarePlus,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Send,
|
||||||
|
TicketCheck,
|
||||||
|
UserRoundCheck
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type FormEvent
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
FieldLabel,
|
||||||
|
FilterBar,
|
||||||
|
FormLayout,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
|
StatusBadge,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
hasScope,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
addTicketComment,
|
||||||
|
assignTicket,
|
||||||
|
createTicket,
|
||||||
|
escalateTicket,
|
||||||
|
getTicketAvailability,
|
||||||
|
listTickets,
|
||||||
|
resolveTicket,
|
||||||
|
triageTicket,
|
||||||
|
type TicketAvailability,
|
||||||
|
type TicketRecord,
|
||||||
|
type TicketSubject
|
||||||
|
} from "../../api/tickets";
|
||||||
|
|
||||||
|
|
||||||
|
type EditorValues = {
|
||||||
|
ticketType: TicketRecord["ticket_type"];
|
||||||
|
priority: TicketRecord["priority"];
|
||||||
|
status: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
visibility: TicketRecord["visibility"];
|
||||||
|
queueRef: string;
|
||||||
|
serviceTargetAt: string;
|
||||||
|
changeReason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATES = ["new", "triaged", "in_progress", "waiting", "cancelled"];
|
||||||
|
|
||||||
|
|
||||||
|
export default function TicketsPage({ settings, auth }: PlatformRouteContext) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState("");
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [availability, setAvailability] = useState<TicketAvailability | null>(null);
|
||||||
|
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 [saving, setSaving] = useState(false);
|
||||||
|
const canReport = hasAny(auth, "tickets:ticket:report", "tickets:ticket:admin", "tickets:ticket:write");
|
||||||
|
const canTriage = hasAny(auth, "tickets:ticket:triage", "tickets:ticket:admin", "tickets:ticket:write");
|
||||||
|
const canAssign = hasAny(auth, "tickets:ticket:assign", "tickets:ticket:admin", "tickets:ticket:write");
|
||||||
|
const canResolve = hasAny(auth, "tickets:ticket:resolve", "tickets:ticket:admin", "tickets:ticket:write");
|
||||||
|
const canCreateCase = hasScope(auth, "cases:case:create");
|
||||||
|
|
||||||
|
function reload(signal?: AbortSignal) {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
return Promise.all([
|
||||||
|
listTickets(settings, {
|
||||||
|
statuses: statusFilter ? [statusFilter] : undefined,
|
||||||
|
query: submittedQuery,
|
||||||
|
limit: 200
|
||||||
|
}, signal),
|
||||||
|
getTicketAvailability(settings, signal)
|
||||||
|
]).
|
||||||
|
then(([result, integrationState]) => {
|
||||||
|
setTickets(result.tickets);
|
||||||
|
setTotal(result.total);
|
||||||
|
setAvailability(integrationState);
|
||||||
|
setSelectedId((current) => result.tickets.some((item) => item.ticket_id === current)
|
||||||
|
? current
|
||||||
|
: result.tickets[0]?.ticket_id ?? "");
|
||||||
|
}).
|
||||||
|
catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Tickets could not be loaded.");
|
||||||
|
}
|
||||||
|
}).
|
||||||
|
finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
void reload(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [settings, statusFilter, submittedQuery]);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => tickets.find((item) => item.ticket_id === selectedId) ?? null,
|
||||||
|
[tickets, selectedId]
|
||||||
|
);
|
||||||
|
|
||||||
|
function submitSearch(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmittedQuery(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditor(values: EditorValues) {
|
||||||
|
setSaving(true);
|
||||||
|
setDialogError("");
|
||||||
|
try {
|
||||||
|
let saved: TicketRecord;
|
||||||
|
if (editing) {
|
||||||
|
const changes: Record<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 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>
|
||||||
|
</>}
|
||||||
|
helpAction={<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}
|
||||||
|
canCreateCase={canCreateCase}
|
||||||
|
saving={saving}
|
||||||
|
onEdit={() => {
|
||||||
|
setEditing(selected);
|
||||||
|
setDialogError("");
|
||||||
|
setEditorOpen(true);
|
||||||
|
}}
|
||||||
|
onAssign={() => setAssignmentOpen(true)}
|
||||||
|
onResolve={() => setResolutionOpen(true)}
|
||||||
|
onEscalate={() => setEscalationOpen(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} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function TicketDetail({ record, availability, canTriage, canAssign, canResolve, canCreateCase, saving, onEdit, onAssign, onResolve, onEscalate, onComment }: {
|
||||||
|
record: TicketRecord;
|
||||||
|
availability: TicketAvailability | null;
|
||||||
|
canTriage: boolean;
|
||||||
|
canAssign: boolean;
|
||||||
|
canResolve: boolean;
|
||||||
|
canCreateCase: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onAssign: () => void;
|
||||||
|
onResolve: () => void;
|
||||||
|
onEscalate: () => void;
|
||||||
|
onComment: (body: string, visibility: "internal" | "external") => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [visibility, setVisibility] = useState<"internal" | "external">("internal");
|
||||||
|
return (
|
||||||
|
<article className="ticket-detail">
|
||||||
|
<header className="ticket-detail-header">
|
||||||
|
<div>
|
||||||
|
<span className="ticket-eyebrow">{record.ticket_number} · {humanize(record.ticket_type)}</span>
|
||||||
|
<h1>{record.title}</h1>
|
||||||
|
</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>}
|
||||||
|
{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}>{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></li>)}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</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"} onClose={onClose} closeDisabled={saving} className="ticket-editor-dialog" 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 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ticketsModule as default, ticketsModule } from "./module";
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import "./styles/tickets.css";
|
||||||
|
|
||||||
|
|
||||||
|
const TicketsPage = lazy(() => import("./features/tickets/TicketsPage"));
|
||||||
|
|
||||||
|
export const ticketsModule: PlatformWebModule = {
|
||||||
|
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 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ticketsModule;
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
.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 h1 {
|
||||||
|
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-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-dialog {
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-editor-form label {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.ticket-detail-header,
|
||||||
|
.ticket-link-list li {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-facts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user