feat(helpdesk): configure ticket routing profiles

This commit is contained in:
2026-08-22 11:41:45 +02:00
parent 064ec6158d
commit 32bd55d5bd
24 changed files with 1584 additions and 102 deletions
+21 -11
View File
@@ -4,22 +4,28 @@
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
`govoplan-helpdesk` is the GovOPlaN platform module seed for internal service desk workflows for IT, facilities, HR, finance, procurement, access requests, assignment, escalation, and resolution tracking. `govoplan-helpdesk` is the optional policy layer for internal service-desk
routing and service targets. It configures ordered service profiles for IT,
facilities, HR, finance, procurement, and access-request queues without owning
the underlying operational 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 implemented slice provides tenant-scoped, revision-guarded service
profiles, immutable configuration history, an administrative API and WebUI,
and the optional `tickets.routing` capability. The routing provider returns a
queue and service target to Tickets. If Helpdesk is absent or no active profile
matches, Ticket intake still succeeds and authorized staff route it manually.
## Initial Ownership ## Ownership
- internal support tickets - helpdesk queue and service-profile configuration
- service categories - ordered ticket-type and priority matching
- assignment and escalation state - service-target policy and its immutable change evidence
- resolution records
- SLA-facing timestamps
## Boundaries ## Boundaries
This module does not own: This module does not own:
- canonical ticket identity, messages, assignment, lifecycle, or resolution
- public issue intake forms - public issue intake forms
- domain case adjudication - domain case adjudication
- asset inventory - asset inventory
@@ -28,9 +34,12 @@ Detailed boundary notes are in [docs/HELPDESK_DOMAIN_BOUNDARY.md](docs/HELPDESK_
## Integrations ## Integrations
Expected optional integrations: Implemented optional integration:
- tickets, through the Core-owned `tickets.routing` capability
Future integration candidates:
- tickets
- assets - assets
- facilities - facilities
- mail - mail
@@ -48,11 +57,12 @@ cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -e ../govoplan-helpdesk ./.venv/bin/python -m pip install -e ../govoplan-helpdesk
``` ```
Focused manifest verification: Focused verification:
```bash ```bash
cd /mnt/DATA/git/govoplan-helpdesk cd /mnt/DATA/git/govoplan-helpdesk
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-core/.venv/bin/python -m unittest discover -s tests
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm --prefix webui run test
``` ```
## Gitea Workflow ## Gitea Workflow
+29 -12
View File
@@ -28,20 +28,37 @@ Internal service desk workflows for IT, facilities, HR, finance, procurement, ac
- files - files
- tasks - tasks
## Seed State ## Implemented Policy Overlay
The current repository state is intentionally small: The module persists only tenant configuration:
- module manifest and entry point - ordered, active/inactive service profiles
- tenant-level permission definitions - ticket-type and priority match criteria
- manager and viewer role templates - canonical queue references
- documentation topic describing the module boundary - per-priority and default service-target minutes
- Gitea issue workflow templates - immutable, replay-safe configuration history and platform events
- manifest contract test
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 administrative API and `/helpdesk` workspace require
`helpdesk:workspace:admin`. Each save carries an expected revision,
idempotency key, and explicit change reason. The shared action bar provides
Reload and New-profile actions, while create/edit dialogs keep dirty state
local until Save or Cancel.
## First Implementation Slice ## Ticket Routing Contract
Define Helpdesk queue, SLA, and escalation profiles over `govoplan-tickets`. Helpdesk provides Core's optional `tickets.routing` capability. Tickets sends a
Do not create a second ticket persistence model. bounded tenant, ticket, type, priority, title, receive time, optional queue
hint, and non-secret attributes. Helpdesk evaluates active profiles in sort
order and returns the first match's queue and calculated service target.
An explicit queue hint narrows matching to that queue. No match is a successful
result with an explanation; it does not reject or delay Ticket intake. Tickets
owns the resulting queue and target snapshot, lifecycle, assignment, and
history. Helpdesk never imports Tickets or persists a second ticket model.
## Data-subject Requests
`privacy.dsar.helpdesk` covers only Helpdesk-owned configuration actor
attribution. Profiles and their immutable history contain institutional policy,
not customer ticket data; evidence is retained and any correction is made by a
new authorized profile revision.
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "@govoplan/helpdesk", "name": "@govoplan/helpdesk",
"version": "0.1.19", "version": "0.1.20",
"private": true, "private": true,
"description": "GovOPlaN Helpdesk platform module seed.", "description": "GovOPlaN Helpdesk service-profile and ticket-routing policy module.",
"type": "module", "type": "module",
"peerDependencies": {} "peerDependencies": {}
} }
+3 -3
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-helpdesk" name = "govoplan-helpdesk"
version = "0.1.19" version = "0.1.20"
description = "GovOPlaN Helpdesk platform module seed." description = "GovOPlaN Helpdesk service-profile and ticket-routing policy 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,3 @@
from govoplan_helpdesk.backend.db.models import HelpdeskProfileHistory, HelpdeskServiceProfile
__all__ = ["HelpdeskProfileHistory", "HelpdeskServiceProfile"]
@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import Boolean, 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 HelpdeskServiceProfile(Base, TimestampMixin):
__tablename__ = "helpdesk_service_profiles"
__table_args__ = (
UniqueConstraint("tenant_id", "profile_key", name="uq_helpdesk_service_profile_key"),
Index("ix_helpdesk_service_profile_routing", "tenant_id", "active", "sort_order"),
)
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)
profile_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
queue_ref: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
ticket_types: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
priorities: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
target_minutes: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
default_target_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
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 HelpdeskProfileHistory(Base, TimestampMixin):
__tablename__ = "helpdesk_profile_history"
__table_args__ = (
UniqueConstraint("tenant_id", "profile_id", "revision", name="uq_helpdesk_profile_history_revision"),
UniqueConstraint("tenant_id", "idempotency_key", name="uq_helpdesk_profile_history_idempotency"),
Index("ix_helpdesk_profile_history_timeline", "tenant_id", "profile_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)
profile_id: Mapped[str] = mapped_column(ForeignKey("helpdesk_service_profiles.id", ondelete="RESTRICT"), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, 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)
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)
__all__ = ["HelpdeskProfileHistory", "HelpdeskServiceProfile"]
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any
TICKET_TYPES = frozenset({"request", "incident", "problem", "report"})
PRIORITIES = frozenset({"low", "normal", "high", "urgent"})
class HelpdeskDomainError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ServiceProfile:
tenant_id: str
profile_id: str
profile_key: str
revision: int
label: str
queue_ref: str
ticket_types: tuple[str, ...]
priorities: tuple[str, ...]
target_minutes: Mapping[str, int]
active: bool
sort_order: int
recorded_at: datetime
change_reason: str
description: str | None = None
default_target_minutes: int | None = None
def __post_init__(self) -> None:
for value, label, maximum in (
(self.tenant_id, "Helpdesk profile tenant", 255),
(self.profile_id, "Helpdesk profile identifier", 255),
(self.profile_key, "Helpdesk profile key", 120),
(self.label, "Helpdesk profile label", 255),
(self.queue_ref, "Helpdesk queue reference", 255),
(self.change_reason, "Helpdesk profile change reason", 1_000),
):
_required(value, label, maximum)
if self.revision < 1:
raise HelpdeskDomainError("Helpdesk profile revisions start at one.")
if self.description is not None and (not self.description.strip() or len(self.description) > 10_000):
raise HelpdeskDomainError("Helpdesk profile description is limited to 10,000 characters.")
types = tuple(dict.fromkeys(self.ticket_types))
priorities = tuple(dict.fromkeys(self.priorities))
if set(types) - TICKET_TYPES:
raise HelpdeskDomainError("Helpdesk profile contains unsupported ticket types.")
if set(priorities) - PRIORITIES:
raise HelpdeskDomainError("Helpdesk profile contains unsupported priorities.")
if not types:
raise HelpdeskDomainError("Helpdesk profiles require at least one ticket type.")
if not priorities:
raise HelpdeskDomainError("Helpdesk profiles require at least one priority.")
object.__setattr__(self, "ticket_types", types)
object.__setattr__(self, "priorities", priorities)
normalized_targets: dict[str, int] = {}
for priority, minutes in self.target_minutes.items():
if priority not in PRIORITIES or not 1 <= int(minutes) <= 525_600:
raise HelpdeskDomainError("Helpdesk target minutes must be between 1 and 525,600 for a supported priority.")
normalized_targets[priority] = int(minutes)
object.__setattr__(self, "target_minutes", normalized_targets)
if self.default_target_minutes is not None and not 1 <= self.default_target_minutes <= 525_600:
raise HelpdeskDomainError("Default Helpdesk target minutes must be between 1 and 525,600.")
if self.sort_order < 0 or self.sort_order > 100_000:
raise HelpdeskDomainError("Helpdesk profile sort order must be between 0 and 100,000.")
if self.recorded_at.tzinfo is None or self.recorded_at.utcoffset() is None:
raise HelpdeskDomainError("Helpdesk profile recorded_at must include a timezone.")
def matches(self, ticket_type: str, priority: str) -> bool:
return ticket_type in self.ticket_types and priority in self.priorities
def target_for(self, priority: str) -> int | None:
return self.target_minutes.get(priority, self.default_target_minutes)
def to_dict(self) -> dict[str, Any]:
return {
"tenant_id": self.tenant_id,
"profile_id": self.profile_id,
"profile_key": self.profile_key,
"revision": self.revision,
"label": self.label,
"description": self.description,
"queue_ref": self.queue_ref,
"ticket_types": list(self.ticket_types),
"priorities": list(self.priorities),
"target_minutes": dict(self.target_minutes),
"default_target_minutes": self.default_target_minutes,
"active": self.active,
"sort_order": self.sort_order,
"recorded_at": self.recorded_at.isoformat(),
"change_reason": self.change_reason,
}
@classmethod
def from_mapping(cls, value: Mapping[str, object]) -> "ServiceProfile":
target_minutes = value.get("target_minutes") or {}
if not isinstance(target_minutes, Mapping):
raise HelpdeskDomainError("Helpdesk target minutes must be an object.")
return cls(
tenant_id=_text(value.get("tenant_id")),
profile_id=_text(value.get("profile_id")),
profile_key=_text(value.get("profile_key")),
revision=int(value.get("revision") or 0),
label=_text(value.get("label")),
description=_optional(value.get("description")),
queue_ref=_text(value.get("queue_ref")),
ticket_types=_strings(value.get("ticket_types")),
priorities=_strings(value.get("priorities")),
target_minutes={str(key): int(item) for key, item in target_minutes.items()},
default_target_minutes=(int(value["default_target_minutes"]) if value.get("default_target_minutes") is not None else None),
active=bool(value.get("active", True)),
sort_order=int(value.get("sort_order") or 0),
recorded_at=_datetime(value.get("recorded_at")),
change_reason=_text(value.get("change_reason")),
)
def _required(value: str, label: str, maximum: int) -> None:
if not value.strip() or len(value) > maximum:
raise HelpdeskDomainError(f"{label} must contain 1 to {maximum} characters.")
def _text(value: object) -> str:
return str(value or "").strip()
def _optional(value: object) -> str | None:
return str(value).strip() if value is not None else None
def _strings(value: object) -> tuple[str, ...]:
if not isinstance(value, (list, tuple)):
raise HelpdeskDomainError("Helpdesk profile selections must be lists.")
return tuple(str(item) for item in value)
def _datetime(value: object) -> datetime:
if isinstance(value, datetime):
return value
try:
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError as exc:
raise HelpdeskDomainError("Helpdesk profile timestamp is invalid.") from exc
__all__ = ["HelpdeskDomainError", "ServiceProfile"]
@@ -0,0 +1,148 @@
from __future__ import annotations
from collections.abc import Sequence
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_helpdesk.backend.db.models import HelpdeskProfileHistory, HelpdeskServiceProfile
HELPDESK_DSAR_CAPABILITY = dsar_capability_name("helpdesk")
_CONFLICT = object()
class HelpdeskDsarProvider:
provider_id = "helpdesk"
module_id = "helpdesk"
def search_subject(self, session: object, *, tenant_id: str, subject: DsarSubjectRef) -> Sequence[DsarRecordRef]:
db = _session(session)
actor_ids = _actor_ids(subject)
if actor_ids is None:
return ()
rows = (
db.query(HelpdeskProfileHistory, HelpdeskServiceProfile)
.join(HelpdeskServiceProfile, HelpdeskProfileHistory.profile_id == HelpdeskServiceProfile.id)
.filter(
HelpdeskProfileHistory.tenant_id == tenant_id,
HelpdeskProfileHistory.actor_id.in_(actor_ids),
)
.order_by(HelpdeskProfileHistory.occurred_at.asc())
.limit(5_001)
.all()
)
if len(rows) > 5_000:
raise ValueError("Helpdesk DSAR attribution limit exceeded; narrow the selectors.")
return tuple(
DsarRecordRef(
provider_id="helpdesk",
module_id="helpdesk",
resource_type="service_profile_actor_attribution",
resource_id=history.id,
category="configuration_accountability_evidence",
title=f"Helpdesk profile lifecycle attribution: {profile.profile_key}",
data={
"activity": "configured_helpdesk_service_profile",
"profile_id": profile.id,
"profile_key": profile.profile_key,
"revision": history.revision,
"occurred_at": _iso(history.occurred_at),
},
observed_at=_aware(history.occurred_at),
immutable_evidence=True,
retention_reason="Helpdesk service-profile change attribution is immutable configuration accountability evidence.",
)
for history, profile in rows
)
def plan_erasure(self, session: object, *, tenant_id: str, subject: DsarSubjectRef, records: Sequence[DsarRecordRef]) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _actor_ids(subject) is None:
raise ValueError("Helpdesk DSAR subject selectors conflict.")
return tuple(
DsarErasureActionRef(
action_id=f"helpdesk:retain:{record.resource_id}",
provider_id="helpdesk",
module_id="helpdesk",
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=record.retention_reason or "Configuration attribution is immutable evidence.",
executable=False,
)
for record in records
if _valid_record(record)
)
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 _actor_ids(subject) is None:
raise ValueError("Helpdesk DSAR subject selectors conflict.")
results = []
for action in actions:
if action.provider_id != "helpdesk" or action.module_id != "helpdesk" or action.kind != "retain" or action.executable:
raise ValueError("Helpdesk DSAR publishes retain-only actions.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="Helpdesk configuration attribution remains immutable evidence.",
evidence={"request_id": request_id},
)
)
return tuple(results)
def _actor_ids(subject: DsarSubjectRef) -> tuple[str, ...] | None:
refs = subject.external_references
values = (
_coalesce(subject.account_id, refs.get("helpdesk.account"), refs.get("access.account")),
_coalesce(subject.identity_id, refs.get("helpdesk.identity"), refs.get("identity.id")),
_coalesce(subject.membership_id, refs.get("helpdesk.membership"), refs.get("tenancy.membership")),
)
if any(value is _CONFLICT for value in values):
return None
result = tuple(dict.fromkeys(value for value in values if isinstance(value, str) and value))
return result or None
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
return _CONFLICT if len(normalized) > 1 else next(iter(normalized), None)
def _valid_record(record: DsarRecordRef) -> bool:
if record.provider_id != "helpdesk" or record.module_id != "helpdesk" or record.resource_type != "service_profile_actor_attribution":
raise ValueError("Helpdesk DSAR record identity is invalid.")
return True
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("Helpdesk DSAR requires a SQLAlchemy Session.")
return value
__all__ = ["HELPDESK_DSAR_CAPABILITY", "HelpdeskDsarProvider"]
+151 -54
View File
@@ -1,12 +1,37 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate from govoplan_core.core.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 declared_module_architecture from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.tickets import CAPABILITY_TICKET_ROUTING
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_helpdesk.backend.db import models as helpdesk_models
from govoplan_helpdesk.backend.dsar_provider import HELPDESK_DSAR_CAPABILITY, HelpdeskDsarProvider
from govoplan_helpdesk.backend.service import SqlHelpdeskRoutingProvider
MODULE_ID = "helpdesk" MODULE_ID = "helpdesk"
MODULE_NAME = "Helpdesk" MODULE_NAME = "Helpdesk"
MODULE_VERSION = "0.1.19" MODULE_VERSION = "0.1.20"
READ_SCOPE = "helpdesk:workspace:read" READ_SCOPE = "helpdesk:workspace:read"
WRITE_SCOPE = "helpdesk:workspace:write" WRITE_SCOPE = "helpdesk:workspace:write"
ADMIN_SCOPE = "helpdesk:workspace:admin" ADMIN_SCOPE = "helpdesk:workspace:admin"
@@ -24,48 +49,44 @@ OPTIONAL_DEPENDENCIES = (
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2) module_id, resource, action = scope.split(":", 2)
return PermissionDefinition( return PermissionDefinition(scope=scope, label=label, description=description, category=MODULE_NAME, level="tenant", module_id=module_id, resource=resource, action=action)
scope=scope,
label=label,
description=description, def _router(context: ModuleContext):
category="Helpdesk", from govoplan_helpdesk.backend.router import create_router
level="tenant",
module_id=module_id, return create_router(context.registry)
resource=resource,
action=action,
) def _routing(_context: ModuleContext) -> SqlHelpdeskRoutingProvider:
return SqlHelpdeskRoutingProvider()
def _dsar(_context: ModuleContext) -> HelpdeskDsarProvider:
return HelpdeskDsarProvider()
PERMISSIONS = ( PERMISSIONS = (
_permission(READ_SCOPE, "View helpdesk workspace", "Read helpdesk records, configuration, and workflow context."), _permission(READ_SCOPE, "View Helpdesk workspace", "Read configured Helpdesk service, queue, and target profiles."),
_permission(WRITE_SCOPE, "Manage helpdesk workspace", "Create and update helpdesk records and workflow state."), _permission(WRITE_SCOPE, "Manage Helpdesk workspace", "Participate in Helpdesk-governed ticket work without configuring policy."),
_permission(ADMIN_SCOPE, "Administer helpdesk workspace", "Configure helpdesk policies, templates, and tenant-level administration."), _permission(ADMIN_SCOPE, "Administer Helpdesk workspace", "Configure routing, queue, priority, and service-target profiles for canonical Tickets."),
) )
ROLE_TEMPLATES = ( ROLE_TEMPLATES = (
RoleTemplate( RoleTemplate(slug="helpdesk_manager", name="Helpdesk manager", description="Manage canonical Tickets under configured Helpdesk queue and service policies.", permissions=(READ_SCOPE, WRITE_SCOPE)),
slug="helpdesk_manager", RoleTemplate(slug="helpdesk_viewer", name="Helpdesk viewer", description="Read Helpdesk service-profile and routing context.", permissions=(READ_SCOPE,)),
name="Helpdesk manager", RoleTemplate(slug="helpdesk_administrator", name="Helpdesk administrator", description="Configure Helpdesk routing and service-target profiles.", permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)),
description="Manage helpdesk records and workflow state.",
permissions=(READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate(
slug="helpdesk_viewer",
name="Helpdesk viewer",
description="Read helpdesk records and workflow context.",
permissions=(READ_SCOPE,),
),
) )
DOCUMENTATION = ( DOCUMENTATION = (
DocumentationTopic( DocumentationTopic(
id=f"{MODULE_ID}.module-boundary", id="helpdesk.module-boundary",
title=f"{MODULE_NAME} module boundary", title="Helpdesk module boundary",
summary="Internal service desk workflows for IT, facilities, HR, finance, procurement, access requests, assignment, escalation, and resolution tracking.", summary="Service profiles, queue routing, and service targets over the canonical Tickets lifecycle.",
body=( body=(
"This repository is currently a platform module seed. It registers the domain boundary, " "Helpdesk owns service-desk policy: profile matching, queue semantics, routing explanation, service targets, and future escalation clocks. "
"permission surface, role templates, and documentation metadata before runtime APIs, " "Tickets owns every operational request, incident, problem, report, assignment, comment, resolution, and immutable ticket history. "
"database models, migrations, and WebUI routes are introduced." "Helpdesk never creates a parallel ticket store. Disabling Helpdesk leaves Tickets usable with manual queue and target selection."
), ),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -73,34 +94,68 @@ DOCUMENTATION = (
translations={ translations={
"de": { "de": {
"title": "Modulgrenze von Helpdesk", "title": "Modulgrenze von Helpdesk",
"summary": "Interne Servicedesk-Abläufe für IT, Gebäudemanagement, Personal, Finanzen, Beschaffung, Zugriffsanfragen, Zuordnung, Eskalation und Lösungsnachverfolgung.", "summary": "Serviceprofile, Warteschlangensteuerung und Serviceziele über dem maßgeblichen Tickets-Lebenszyklus.",
"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": "Helpdesk verantwortet Servicedesk-Regeln: Profilabgleich, Warteschlangenbedeutung, Weiterleitungsbegründung, Serviceziele und künftig Eskalationsuhren. Tickets verantwortet jede operative Anfrage, Störung, jedes Problem und jede Meldung sowie Zuweisung, Kommentar, Lösung und unveränderliche Historie. Helpdesk legt keinen parallelen Ticketspeicher an. Wird Helpdesk deaktiviert, bleiben Tickets mit manueller Wahl von Warteschlange und Ziel nutzbar.",
} }
}, },
order=100,
related_modules=OPTIONAL_DEPENDENCIES, related_modules=OPTIONAL_DEPENDENCIES,
links=( links=(DocumentationLink(label="Repository domain boundary", href="govoplan-helpdesk/docs/HELPDESK_DOMAIN_BOUNDARY.md", kind="repository"),),
DocumentationLink(
label="Repository domain boundary",
href="govoplan-helpdesk/docs/HELPDESK_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.", "policy_overlay": "Applies queue and service-target policy without duplicating Ticket state.",
"reduced_installation": "Manual Ticket routing remains available when Helpdesk is disabled.",
}, },
"domain_objects": ['internal support tickets', 'service categories', 'assignment and escalation state', 'resolution records', 'SLA-facing timestamps'],
"first_slice": (
"Define Helpdesk queue, SLA, and escalation profiles over the "
"canonical Tickets model without introducing duplicate ticket storage."
),
}, },
), ),
DocumentationTopic(
id="helpdesk.configure-routing-profiles",
title="Configure Helpdesk routing and service-target profiles",
summary="Match bounded Ticket types and priorities to one queue and a priority-specific target.",
body=(
"Each active tenant profile has a stable key, ordered precedence, supported Ticket types and priorities, a queue reference, and optional target minutes by priority or default. "
"At Ticket intake Helpdesk evaluates active profiles in order. An explicit profile key or queue hint narrows the match. The selected profile revision, queue, target duration, and explanation are recorded with the Ticket. "
"If no profile matches, intake succeeds without an automatic target and authorized Ticket staff choose queue and target manually. Profile edits are revision-guarded, replay-safe, and append immutable configuration history."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("helpdesk_administrator", "operator", "auditor"),
conditions=(DocumentationCondition(required_scopes=(ADMIN_SCOPE,)),),
translations={
"de": {
"title": "Helpdesk-Profile für Weiterleitung und Serviceziele konfigurieren",
"summary": "Begrenzte Ticketarten und Prioritäten einer Warteschlange und einem prioritätsabhängigen Ziel zuordnen.",
"body": "Jedes aktive Mandantenprofil besitzt einen stabilen Schlüssel, eine Reihenfolge, unterstützte Ticketarten und Prioritäten, einen Warteschlangenverweis und optionale Zielminuten je Priorität oder als Standard. Bei der Ticketaufnahme wertet Helpdesk aktive Profile der Reihe nach aus. Ein ausdrücklicher Profilschlüssel oder Warteschlangenhinweis grenzt die Auswahl ein. Profilrevision, Warteschlange, Zieldauer und Begründung werden am Ticket festgehalten. Trifft kein Profil zu, gelingt die Aufnahme ohne automatisches Ziel; berechtigte Ticket-Bearbeitende wählen Warteschlange und Ziel manuell. Profiländerungen sind revisionsgeschützt, wiederholungssicher und erzeugen unveränderliche Konfigurationshistorie.",
}
},
related_modules=("tickets",),
metadata={"kind": "workflow", "help_contexts": ["helpdesk.route.profiles", "helpdesk.action.save-profile"]},
),
DocumentationTopic(
id="helpdesk.data-subject-requests",
title="Helpdesk data-subject requests",
summary="Export minimized service-profile change attribution while keeping policy values outside subject data.",
body=(
"Helpdesk stores tenant configuration and immutable profile-change attribution, not Ticket reporters, requesters, participants, comments, or resolutions. "
"The provider matches exact account, identity, and membership actor identifiers and exports only profile identity, revision, action, and time. Rule values, request hashes, and idempotency keys are excluded. "
"Change attribution is retained as configuration accountability evidence; Ticket subject data remains covered by Tickets."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("privacy_officer", "auditor", "tenant_admin", "user"),
conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, ADMIN_SCOPE)),),
translations={
"de": {
"title": "Datenschutzanfragen für Helpdesk",
"summary": "Minimierte Zuordnung von Serviceprofil-Änderungen exportieren, ohne Regelwerte als Betroffenendaten zu behandeln.",
"body": "Helpdesk speichert Mandantenkonfiguration und unveränderliche Zuordnung von Profiländerungen, aber keine meldenden, anfragenden oder beteiligten Personen, Kommentare oder Lösungen aus Tickets. Der Anbieter gleicht exakte Konto-, Identitäts- und Mitgliedschaftskennungen ab und exportiert nur Profilidentität, Revision, Aktion und Zeitpunkt. Regelwerte, Anfrage-Hashes und Idempotenzschlüssel werden ausgeschlossen. Die Änderungszuordnung bleibt als Rechenschaftsnachweis erhalten; Ticket-Betroffenendaten deckt Tickets ab.",
}
},
metadata={"kind": "workflow", "help_contexts": ["helpdesk.admin.dsar"]},
),
) )
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
@@ -108,17 +163,59 @@ 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),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_TICKET_ROUTING, version="1.0.0"),
ModuleInterfaceProvider(name=HELPDESK_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
route_factory=_router,
nav_items=(NavItem(path="/helpdesk", label="Helpdesk profiles", icon="headset", required_any=(ADMIN_SCOPE,), order=82, surface_id="helpdesk.navigation"),),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/helpdesk-webui",
routes=(FrontendRoute(path="/helpdesk", component="HelpdeskProfilesPage", required_any=(ADMIN_SCOPE,), order=82, surface_id="helpdesk.route.profiles"),),
nav_items=(NavItem(path="/helpdesk", label="Helpdesk profiles", icon="headset", required_any=(ADMIN_SCOPE,), order=82, surface_id="helpdesk.navigation"),),
product_areas=(
ProductAreaContribution(
id="work",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.work",
icon="list-checks",
description="i18n:govoplan-core.product_area.work_description",
surface_ids=("helpdesk.route.profiles",),
order=10,
),
),
view_surfaces=(
ViewSurface(id="helpdesk.action.save-profile", module_id=MODULE_ID, kind="action", label="Save Helpdesk service profile", parent_id="helpdesk.route.profiles", order=30),
),
),
capability_factories={CAPABILITY_TICKET_ROUTING: _routing, HELPDESK_DSAR_CAPABILITY: _dsar},
capability_documentation={
CAPABILITY_TICKET_ROUTING: CapabilityDocumentation(label="Helpdesk Ticket routing", summary="Matches tenant service profiles and returns only applied queue, service target, and explanation facts.", contract_version="1.0.0"),
HELPDESK_DSAR_CAPABILITY: CapabilityDocumentation(label="Helpdesk data-subject request provider", summary="Exports minimized immutable service-profile change attribution.", contract_version="0.1.0"),
},
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(helpdesk_models.HelpdeskProfileHistory, helpdesk_models.HelpdeskServiceProfile, label="Helpdesk"),
retirement_notes="Destructive retirement removes Helpdesk policy profiles and their configuration history after a database snapshot.",
),
uninstall_guard_providers=(persistent_table_uninstall_guard(helpdesk_models.HelpdeskServiceProfile, helpdesk_models.HelpdeskProfileHistory, label="Helpdesk"),),
documentation=DOCUMENTATION, documentation=DOCUMENTATION,
architecture=declared_module_architecture( architecture=declared_module_architecture(
layer="human_work_procedure", layer="human_work_procedure",
kind="domain", kind="domain",
maturity="scaffold", maturity="vertical_slice",
documentation_ref="docs/HELPDESK_DOMAIN_BOUNDARY.md", documentation_ref="docs/HELPDESK_DOMAIN_BOUNDARY.md",
known_limits=("Helpdesk intake, queue, SLA, and resolution lifecycles are not implemented yet.",), test_ref="tests/test_helpdesk_routing.py",
owned_concepts=("helpdesk request", "service queue", "resolution"), known_limits=("Business calendars and pause/resume escalation clocks remain a later Helpdesk policy slice.",),
non_owned_concepts=("case", "project work item", "mailbox message"), supported_authority_modes=("native_authoritative", "governance_overlay"),
owned_concepts=("helpdesk service profile", "service queue policy", "service target policy"),
non_owned_concepts=("ticket", "case", "project work item", "mailbox message"),
), ),
) )
@@ -0,0 +1 @@
"""Helpdesk-owned database migrations."""
@@ -0,0 +1,72 @@
"""v0.1.20 Helpdesk routing and service-target profiles.
Revision ID: 9e2a5c8f1b4d
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "9e2a5c8f1b4d"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"helpdesk_service_profiles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("profile_key", sa.String(length=120), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("queue_ref", sa.String(length=255), nullable=False),
sa.Column("ticket_types", sa.JSON(), nullable=False),
sa.Column("priorities", sa.JSON(), nullable=False),
sa.Column("target_minutes", sa.JSON(), nullable=False),
sa.Column("default_target_minutes", sa.Integer(), nullable=True),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
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_helpdesk_service_profiles")),
sa.UniqueConstraint("tenant_id", "profile_key", name="uq_helpdesk_service_profile_key"),
)
for column in ("tenant_id", "profile_key", "queue_ref", "active", "sort_order", "created_by", "updated_by"):
op.create_index(op.f(f"ix_helpdesk_service_profiles_{column}"), "helpdesk_service_profiles", [column], unique=False)
op.create_index("ix_helpdesk_service_profile_routing", "helpdesk_service_profiles", ["tenant_id", "active", "sort_order"], unique=False)
op.create_table(
"helpdesk_profile_history",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("profile_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), 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("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["profile_id"], ["helpdesk_service_profiles.id"], name=op.f("fk_helpdesk_profile_history_profile_id_helpdesk_service_profiles"), ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_helpdesk_profile_history")),
sa.UniqueConstraint("tenant_id", "profile_id", "revision", name="uq_helpdesk_profile_history_revision"),
sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_helpdesk_profile_history_idempotency"),
)
for column in ("tenant_id", "profile_id", "occurred_at", "actor_id"):
op.create_index(op.f(f"ix_helpdesk_profile_history_{column}"), "helpdesk_profile_history", [column], unique=False)
op.create_index("ix_helpdesk_profile_history_timeline", "helpdesk_profile_history", ["tenant_id", "profile_id", "occurred_at"], unique=False)
def downgrade() -> None:
op.drop_table("helpdesk_profile_history")
op.drop_table("helpdesk_service_profiles")
@@ -0,0 +1 @@
"""Helpdesk migration revisions."""
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_helpdesk.backend.domain import HelpdeskDomainError, ServiceProfile
from govoplan_helpdesk.backend.schemas import ServiceProfileListResponse, ServiceProfileWriteRequest
from govoplan_helpdesk.backend.service import (
ADMIN_SCOPE,
HelpdeskConflictError,
HelpdeskStoreError,
list_service_profiles,
upsert_service_profile,
)
def create_router(registry: object | None) -> APIRouter:
router = APIRouter(prefix="/helpdesk", tags=["helpdesk"])
@router.get("/service-profiles", response_model=ServiceProfileListResponse)
def api_list_profiles(
include_inactive: bool = True,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ServiceProfileListResponse:
_require(principal)
items = list_service_profiles(session, principal, include_inactive=include_inactive)
return ServiceProfileListResponse(profiles=[item.to_dict() for item in items])
@router.put("/service-profiles/{profile_id}", response_model=dict)
def api_write_profile(
profile_id: str,
payload: ServiceProfileWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict:
_require(principal)
if str(payload.profile.get("profile_id") or "") != profile_id:
raise HTTPException(status_code=400, detail="Helpdesk profile path and payload identifiers differ")
try:
item = upsert_service_profile(
session,
principal,
profile=ServiceProfile.from_mapping(payload.profile),
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
registry=registry,
)
session.commit()
except (HelpdeskStoreError, HelpdeskDomainError, IntegrityError) as exc:
session.rollback()
code = 409 if isinstance(exc, (HelpdeskConflictError, IntegrityError)) or "conflict" in str(exc).casefold() else 400
raise HTTPException(status_code=code, detail=str(exc)) from exc
return item.to_dict()
return router
def _require(principal: ApiPrincipal) -> None:
if not has_scope(principal, ADMIN_SCOPE):
raise HTTPException(status_code=403, detail=f"Missing scope: {ADMIN_SCOPE}")
__all__ = ["create_router"]
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class ServiceProfileWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
profile: dict[str, Any]
expected_revision: int | None = Field(default=None, ge=1)
idempotency_key: str = Field(min_length=1, max_length=255)
class ServiceProfileListResponse(BaseModel):
profiles: list[dict[str, Any]]
__all__ = ["ServiceProfileListResponse", "ServiceProfileWriteRequest"]
+285
View File
@@ -0,0 +1,285 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
import hashlib
import json
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
from govoplan_core.core.tickets import TicketRoutingPlan, TicketRoutingRequest
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_helpdesk.backend.db.models import HelpdeskProfileHistory, HelpdeskServiceProfile
from govoplan_helpdesk.backend.domain import ServiceProfile
class HelpdeskStoreError(ValueError):
pass
class HelpdeskConflictError(HelpdeskStoreError):
pass
ADMIN_SCOPE = "helpdesk:workspace:admin"
def upsert_service_profile(
session: Session,
principal: object,
*,
profile: ServiceProfile,
expected_revision: int | None,
idempotency_key: str,
registry: object | None = None,
) -> ServiceProfile:
if not _has_scope(principal, ADMIN_SCOPE):
raise PermissionError("Helpdesk profile changes require administration access.")
tenant_id = _tenant(principal)
if profile.tenant_id != tenant_id:
raise HelpdeskStoreError("Helpdesk profiles cannot cross tenants.")
clean_key = _bounded(idempotency_key, "Helpdesk idempotency key", 255)
digest = _digest({"profile": profile.to_dict(), "expected_revision": expected_revision})
replay = _replay(session, tenant_id, clean_key, digest)
if replay is not None:
return ServiceProfile.from_mapping(replay.snapshot)
row = (
session.query(HelpdeskServiceProfile)
.filter(
HelpdeskServiceProfile.tenant_id == tenant_id,
HelpdeskServiceProfile.id == profile.profile_id,
)
.with_for_update()
.one_or_none()
)
if row is None:
if expected_revision is not None or profile.revision != 1:
raise HelpdeskConflictError("A new Helpdesk profile must start at revision 1 without an expected revision.")
duplicate = session.query(HelpdeskServiceProfile.id).filter(
HelpdeskServiceProfile.tenant_id == tenant_id,
HelpdeskServiceProfile.profile_key == profile.profile_key,
).first()
if duplicate is not None:
raise HelpdeskConflictError("A Helpdesk profile with this key already exists.")
persisted = profile
row = HelpdeskServiceProfile(
id=profile.profile_id,
tenant_id=tenant_id,
profile_key=profile.profile_key,
created_by=_actor(principal),
)
operation = "created"
else:
if expected_revision != row.revision or profile.revision != row.revision + 1:
raise HelpdeskConflictError("Helpdesk profile revision conflict: the expected revision is stale.")
if profile.profile_key != row.profile_key:
raise HelpdeskStoreError("Helpdesk profile keys are immutable.")
persisted = profile
operation = "updated"
_write(row, persisted)
row.updated_by = _actor(principal)
session.add(row)
session.flush()
session.add(
HelpdeskProfileHistory(
tenant_id=tenant_id,
profile_id=row.id,
revision=persisted.revision,
occurred_at=persisted.recorded_at,
actor_id=row.updated_by,
reason=persisted.change_reason,
idempotency_key=clean_key,
request_sha256=digest,
snapshot=persisted.to_dict(),
)
)
emit_platform_event(
session,
PlatformEvent(
type=f"helpdesk.service_profile.{operation}",
module_id="helpdesk",
payload={"profile_id": row.id, "profile_key": row.profile_key, "revision": row.revision, "active": row.active},
occurred_at=persisted.recorded_at,
actor=EventActorRef(type="account", id=row.updated_by),
tenant=EventTenantRef(id=tenant_id),
resource=EventObjectRef(type="helpdesk_service_profile", id=row.id, label=row.label),
classification="internal",
),
registry=registry,
)
session.flush()
return persisted
def list_service_profiles(
session: Session,
principal: object,
*,
include_inactive: bool = True,
) -> tuple[ServiceProfile, ...]:
query = session.query(HelpdeskServiceProfile).filter(HelpdeskServiceProfile.tenant_id == _tenant(principal))
if not include_inactive:
query = query.filter(HelpdeskServiceProfile.active.is_(True))
rows = query.order_by(HelpdeskServiceProfile.sort_order.asc(), HelpdeskServiceProfile.label.asc()).all()
return tuple(_profile(row) for row in rows)
class SqlHelpdeskRoutingProvider:
"""Apply Helpdesk-owned routing and service-target policy to a Ticket intake."""
def route_ticket(
self,
session: object,
principal: object,
*,
request: TicketRoutingRequest,
) -> TicketRoutingPlan:
db = _session(session)
if request.tenant_id != _tenant(principal):
raise HelpdeskStoreError("Helpdesk routing cannot cross tenants.")
profiles = list_service_profiles(db, principal, include_inactive=False)
requested_key = str(request.attributes.get("helpdesk_profile_key") or "").strip()
candidates = tuple(
profile
for profile in profiles
if profile.matches(request.ticket_type, request.priority)
and (not requested_key or profile.profile_key == requested_key)
and (not request.queue_hint or profile.queue_ref == request.queue_hint)
)
selected = candidates[0] if candidates else None
if selected is None:
explanation = (
"No active Helpdesk profile matched; the submitted queue hint is retained."
if request.queue_hint
else "No active Helpdesk profile matched; manual queue and target selection remains available."
)
return TicketRoutingPlan(
provider_id="helpdesk",
queue_ref=request.queue_hint,
explanation=explanation,
metadata={"matched": False},
)
minutes = selected.target_for(request.priority)
return TicketRoutingPlan(
provider_id="helpdesk",
queue_ref=selected.queue_ref,
service_target_at=(request.received_at + timedelta(minutes=minutes) if minutes is not None else None),
explanation=f"Matched Helpdesk service profile {selected.label}.",
metadata={
"matched": True,
"profile_id": selected.profile_id,
"profile_key": selected.profile_key,
"profile_revision": selected.revision,
"target_minutes": minutes,
},
)
def _write(row: HelpdeskServiceProfile, profile: ServiceProfile) -> None:
row.revision = profile.revision
row.label = profile.label
row.description = profile.description
row.queue_ref = profile.queue_ref
row.ticket_types = list(profile.ticket_types)
row.priorities = list(profile.priorities)
row.target_minutes = dict(profile.target_minutes)
row.default_target_minutes = profile.default_target_minutes
row.active = profile.active
row.sort_order = profile.sort_order
def _profile(row: HelpdeskServiceProfile) -> ServiceProfile:
occurred = row.updated_at or row.created_at
if occurred.tzinfo is None:
occurred = occurred.replace(tzinfo=UTC)
return ServiceProfile(
tenant_id=row.tenant_id,
profile_id=row.id,
profile_key=row.profile_key,
revision=row.revision,
label=row.label,
description=row.description,
queue_ref=row.queue_ref,
ticket_types=tuple(row.ticket_types or ()),
priorities=tuple(row.priorities or ()),
target_minutes={str(key): int(value) for key, value in (row.target_minutes or {}).items()},
default_target_minutes=row.default_target_minutes,
active=row.active,
sort_order=row.sort_order,
recorded_at=occurred,
change_reason="Loaded current Helpdesk service profile.",
)
def _replay(session: Session, tenant_id: str, key: str, digest: str) -> HelpdeskProfileHistory | None:
row = session.query(HelpdeskProfileHistory).filter(
HelpdeskProfileHistory.tenant_id == tenant_id,
HelpdeskProfileHistory.idempotency_key == key,
).one_or_none()
if row is not None and row.request_sha256 != digest:
raise HelpdeskConflictError("Helpdesk idempotency key was reused for another request.")
return row
def _tenant(principal: object) -> str:
value = str(getattr(principal, "tenant_id", "") or "").strip()
if not value:
raise HelpdeskStoreError("Helpdesk operations require a tenant-bound principal.")
return value
def _has_scope(principal: object, scope: str) -> bool:
method = getattr(principal, "has", None)
if callable(method):
return bool(method(scope))
return scopes_grant_compatible(
frozenset(getattr(principal, "scopes", ()) or ()),
scope,
)
def _actor(principal: object) -> str | None:
user = getattr(principal, "user", None)
for value in (getattr(principal, "account_id", None), getattr(principal, "identity_id", None), getattr(principal, "membership_id", None), getattr(user, "id", None)):
if str(value or "").strip():
return str(value)
return None
def _bounded(value: object, label: str, maximum: int) -> str:
clean = str(value or "").strip()
if not clean or len(clean) > maximum:
raise HelpdeskStoreError(f"{label} must contain 1 to {maximum} characters.")
return clean
def _digest(value: object) -> str:
encoded = json.dumps(_json(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _json(value: object) -> Any:
if isinstance(value, datetime):
return value.astimezone(UTC).isoformat()
if isinstance(value, Mapping):
return {str(key): _json(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json(item) for item in value]
return value
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Helpdesk requires a SQLAlchemy session.")
return value
__all__ = [
"HelpdeskConflictError",
"HelpdeskStoreError",
"SqlHelpdeskRoutingProvider",
"list_service_profiles",
"upsert_service_profile",
]
+155
View File
@@ -0,0 +1,155 @@
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.tickets import TicketRoutingRequest
from govoplan_core.db.base import Base
from govoplan_helpdesk.backend.domain import ServiceProfile
from govoplan_helpdesk.backend.service import (
HelpdeskConflictError,
SqlHelpdeskRoutingProvider,
list_service_profiles,
upsert_service_profile,
)
NOW = datetime(2026, 8, 22, 11, 0, tzinfo=UTC)
class _Principal:
tenant_id = "tenant-1"
account_id = "admin-1"
identity_id = None
membership_id = "membership-1"
user = SimpleNamespace(id="admin-1")
scopes = frozenset({"helpdesk:workspace:admin"})
def has(self, scope: str) -> bool:
return scope in self.scopes
class HelpdeskRoutingTests(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.principal = _Principal()
def test_profile_is_replay_safe_and_routes_ticket_without_storing_it(self) -> None:
with self.Session() as session:
profile = _profile()
created = upsert_service_profile(
session,
self.principal,
profile=profile,
expected_revision=None,
idempotency_key="profile-create-1",
)
session.commit()
replay = upsert_service_profile(
session,
self.principal,
profile=profile,
expected_revision=None,
idempotency_key="profile-create-1",
)
self.assertEqual(created.to_dict(), replay.to_dict())
self.assertEqual(1, len(list_service_profiles(session, self.principal)))
plan = SqlHelpdeskRoutingProvider().route_ticket(
session,
self.principal,
request=TicketRoutingRequest(
tenant_id="tenant-1",
ticket_id="ticket-1",
ticket_type="incident",
priority="urgent",
title="Network outage",
received_at=NOW,
),
)
self.assertEqual("it-operations", plan.queue_ref)
self.assertEqual(NOW + timedelta(minutes=30), plan.service_target_at)
self.assertEqual("it-support", plan.metadata["profile_key"])
self.assertEqual(0, len([table for table in Base.metadata.tables if table == "tickets"]))
def test_ordered_matching_no_match_and_revision_conflicts_are_explicit(self) -> None:
with self.Session() as session:
upsert_service_profile(
session,
self.principal,
profile=_profile(),
expected_revision=None,
idempotency_key="profile-create-2",
)
session.commit()
provider = SqlHelpdeskRoutingProvider()
unmatched = provider.route_ticket(
session,
self.principal,
request=TicketRoutingRequest(
tenant_id="tenant-1",
ticket_id="ticket-2",
ticket_type="report",
priority="low",
title="General feedback",
received_at=NOW,
queue_hint="citizen-service",
),
)
self.assertEqual("citizen-service", unmatched.queue_ref)
self.assertIsNone(unmatched.service_target_at)
self.assertFalse(unmatched.metadata["matched"])
with self.assertRaises(HelpdeskConflictError):
upsert_service_profile(
session,
self.principal,
profile=ServiceProfile.from_mapping({**_profile().to_dict(), "revision": 2, "label": "Changed"}),
expected_revision=9,
idempotency_key="profile-stale",
)
def test_profile_writes_recheck_administration_permission(self) -> None:
principal = SimpleNamespace(
tenant_id="tenant-1",
account_id="viewer-1",
scopes=frozenset({"helpdesk:workspace:read"}),
)
with self.Session() as session, self.assertRaises(PermissionError):
upsert_service_profile(
session,
principal,
profile=_profile(),
expected_revision=None,
idempotency_key="unauthorized-profile-write",
)
def _profile() -> ServiceProfile:
return ServiceProfile(
tenant_id="tenant-1",
profile_id="profile-1",
profile_key="it-support",
revision=1,
label="IT support",
description="IT incidents and requests.",
queue_ref="it-operations",
ticket_types=("incident", "problem", "request"),
priorities=("low", "normal", "high", "urgent"),
target_minutes={"urgent": 30, "high": 240},
default_target_minutes=1_440,
active=True,
sort_order=10,
recorded_at=NOW,
change_reason="Created IT support profile.",
)
if __name__ == "__main__":
unittest.main()
+21 -20
View File
@@ -2,31 +2,32 @@ from __future__ import annotations
import unittest import unittest
from govoplan_core.core.tickets import CAPABILITY_TICKET_ROUTING
from govoplan_helpdesk.backend.dsar_provider import HELPDESK_DSAR_CAPABILITY
from govoplan_helpdesk.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest from govoplan_helpdesk.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
class ManifestSeedTests(unittest.TestCase): class ManifestTests(unittest.TestCase):
def test_manifest_registers_seed_contract(self) -> None: def test_manifest_registers_helpdesk_policy_overlay(self) -> None:
manifest = get_manifest() manifest = get_manifest()
self.assertEqual("helpdesk", manifest.id)
self.assertEqual("0.1.20", manifest.version)
self.assertEqual({READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}, {item.scope for item in manifest.permissions})
self.assertEqual({"helpdesk_manager", "helpdesk_viewer", "helpdesk_administrator"}, {item.slug for item in manifest.role_templates})
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertIn(CAPABILITY_TICKET_ROUTING, manifest.capability_factories)
self.assertIn(HELPDESK_DSAR_CAPABILITY, manifest.capability_factories)
self.assertEqual("vertical_slice", manifest.architecture.maturity)
self.assertEqual(manifest.id, "helpdesk") def test_documentation_is_complete_in_german_and_denies_duplicate_ticket_storage(self) -> None:
self.assertEqual(manifest.name, "Helpdesk") topics = get_manifest().documentation
self.assertEqual(manifest.dependencies, ("access",)) for topic in topics:
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}) self.assertTrue(all(topic.translations.get("de", {}).get(field) for field in ("title", "summary", "body")), topic.id)
self.assertEqual({role.slug for role in manifest.role_templates}, {"helpdesk_manager", "helpdesk_viewer"}) boundary = next(item for item in topics if item.id == "helpdesk.module-boundary")
self.assertTrue(manifest.documentation) self.assertIn("never creates a parallel ticket store", boundary.body)
topic = manifest.documentation[0] self.assertTrue(any(item.metadata.get("kind") == "workflow" for item in topics))
self.assertEqual("reference", topic.metadata["kind"])
self.assertIn("seed_boundary", topic.metadata["consequence_classes"])
self.assertTrue(
all(
topic.translations.get("de", {}).get(field)
for field in ("title", "summary", "body")
)
)
self.assertIsNone(manifest.route_factory)
self.assertIsNone(manifest.migration_spec)
self.assertIsNone(manifest.frontend)
if __name__ == "__main__": if __name__ == "__main__":
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@govoplan/helpdesk-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/helpdesk.css": "./src/styles/helpdesk.css"
},
"scripts": {
"test": "npm run test:interface-pattern",
"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
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/helpdesk/HelpdeskProfilesPage.tsx", "utf8");
const styles = fs.readFileSync("src/styles/helpdesk.css", "utf8");
assert.ok(page.includes("WorkspaceActionBar"), "Helpdesk uses the semantic shared action bar");
assert.ok(page.includes("refreshable"), "The Helpdesk profile page exposes Reload");
assert.ok(page.includes("DocumentationHelpLink"), "Helpdesk exposes contextual documentation");
assert.ok(page.includes("PageScrollViewport"), "Helpdesk owns bounded profile and detail scrolling");
assert.ok(page.includes("<Dialog"), "Helpdesk profile edits use the shared dialog");
assert.ok(page.includes("FieldLabel"), "Helpdesk profile fields use shared labels and help");
assert.ok(page.includes("StatusBadge"), "Profile activation is not conveyed by color alone");
assert.ok(!page.includes("window.alert("), "Helpdesk must not use browser alerts");
assert.ok(styles.includes("@media (max-width: 760px)"), "Helpdesk retains a responsive profile layout");
console.log("Helpdesk interface pattern contract passed.");
+35
View File
@@ -0,0 +1,35 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type ServiceProfile = {
tenant_id: string;
profile_id: string;
profile_key: string;
revision: number;
label: string;
description?: string | null;
queue_ref: string;
ticket_types: string[];
priorities: string[];
target_minutes: Record<string, number>;
default_target_minutes?: number | null;
active: boolean;
sort_order: number;
recorded_at: string;
change_reason: string;
};
export function listServiceProfiles(settings: ApiSettings, signal?: AbortSignal): Promise<{ profiles: ServiceProfile[] }> {
return apiFetch(settings, "/api/v1/helpdesk/service-profiles", { signal });
}
export function saveServiceProfile(settings: ApiSettings, profile: ServiceProfile, expectedRevision: number | null): Promise<ServiceProfile> {
return apiFetch(settings, `/api/v1/helpdesk/service-profiles/${encodeURIComponent(profile.profile_id)}`, {
method: "PUT",
body: JSON.stringify({
profile,
expected_revision: expectedRevision,
idempotency_key: crypto.randomUUID()
})
});
}
@@ -0,0 +1,246 @@
import { Headset, Pencil, Plus } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
Button,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FieldLabel,
FormLayout,
LoadingIndicator,
PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext
} from "@govoplan/core-webui";
import { listServiceProfiles, saveServiceProfile, type ServiceProfile } from "../../api/helpdesk";
type ProfileValues = {
key: string;
label: string;
description: string;
queueRef: string;
ticketTypes: string[];
priorities: string[];
lowMinutes: string;
normalMinutes: string;
highMinutes: string;
urgentMinutes: string;
defaultMinutes: string;
active: boolean;
sortOrder: string;
changeReason: string;
};
const TYPES = ["request", "incident", "problem", "report"];
const PRIORITIES = ["low", "normal", "high", "urgent"];
export default function HelpdeskProfilesPage({ settings, auth }: PlatformRouteContext) {
const [profiles, setProfiles] = useState<ServiceProfile[]>([]);
const [selectedId, setSelectedId] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [editorError, setEditorError] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<ServiceProfile | null>(null);
function reload(signal?: AbortSignal) {
setLoading(true);
setError("");
return listServiceProfiles(settings, signal).
then((result) => {
setProfiles(result.profiles);
setSelectedId((current) => result.profiles.some((item) => item.profile_id === current)
? current
: result.profiles[0]?.profile_id ?? "");
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
}).
finally(() => setLoading(false));
}
useEffect(() => {
const controller = new AbortController();
void reload(controller.signal);
return () => controller.abort();
}, [settings]);
const selected = useMemo(() => profiles.find((item) => item.profile_id === selectedId) ?? null, [profiles, selectedId]);
async function save(values: ProfileValues) {
setSaving(true);
setEditorError("");
try {
const now = new Date().toISOString();
const profile: ServiceProfile = {
tenant_id: auth.active_tenant?.id ?? auth.tenant.id,
profile_id: editing?.profile_id ?? crypto.randomUUID(),
profile_key: values.key,
revision: editing ? editing.revision + 1 : 1,
label: values.label,
description: values.description || null,
queue_ref: values.queueRef,
ticket_types: values.ticketTypes,
priorities: values.priorities,
target_minutes: targetMinutes(values),
default_target_minutes: positiveNumber(values.defaultMinutes),
active: values.active,
sort_order: Number(values.sortOrder),
recorded_at: now,
change_reason: values.changeReason
};
const saved = await saveServiceProfile(settings, profile, editing?.revision ?? null);
setEditorOpen(false);
setEditing(null);
await reload();
setSelectedId(saved.profile_id);
} catch (reason) {
setEditorError(message(reason));
} finally {
setSaving(false);
}
}
return (
<main className="helpdesk-page">
<WorkspaceFrame className="helpdesk-shell" label="Helpdesk profiles" interfaceId="helpdesk.route.profiles" helpContextId="helpdesk.route.profiles" helpModuleId="helpdesk">
<WorkspaceActionBar
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(), loading }}
contextActions={<span className="helpdesk-count">{profiles.length} profiles · ordered first match wins</span>}
helpAction={<DocumentationHelpLink reference={{ topicId: "helpdesk.configure-routing-profiles", documentationType: "admin" }} label="Open Helpdesk profile documentation" />}
createAction={<Button type="button" variant="primary" onClick={() => { setEditing(null); setEditorError(""); setEditorOpen(true); }}><Plus size={16} /> New profile</Button>}
/>
{error && <DismissibleAlert tone="danger" onDismiss={() => setError("")}>{error}</DismissibleAlert>}
<div className="helpdesk-workspace">
<PageScrollViewport className="helpdesk-list-viewport">
{loading && <LoadingIndicator label="Loading Helpdesk profiles" />}
{!loading && profiles.length === 0 && <StatePanel size="compact" title="No routing profiles" description="Create a profile or continue using manual Ticket queues and targets." />}
<SelectionList variant="navigation" label="Helpdesk service profiles">
{profiles.map((profile) =>
<SelectionListItem key={profile.profile_id} selected={profile.profile_id === selectedId} onClick={() => setSelectedId(profile.profile_id)}>
<SelectionListItemContent leading={<Headset size={18} />} title={profile.label} description={`${profile.queue_ref} · order ${profile.sort_order} · revision ${profile.revision}`} />
<StatusBadge status={profile.active ? "active" : "inactive"} label={profile.active ? "Active" : "Inactive"} />
</SelectionListItem>
)}
</SelectionList>
</PageScrollViewport>
<PageScrollViewport className="helpdesk-detail-viewport">
{selected ? <ProfileDetail profile={selected} onEdit={() => { setEditing(selected); setEditorError(""); setEditorOpen(true); }} /> : <StatePanel size="fill" title="Helpdesk routing profiles" description="Select a profile to inspect its match and target semantics." />}
</PageScrollViewport>
</div>
</WorkspaceFrame>
<ProfileEditorDialog open={editorOpen} profile={editing} saving={saving} error={editorError} onClose={() => { setEditorOpen(false); setEditorError(""); }} onSave={save} />
</main>
);
}
function ProfileDetail({ profile, onEdit }: { profile: ServiceProfile; onEdit: () => void }) {
return <article className="helpdesk-detail">
<header className="helpdesk-detail-header">
<div><span className="helpdesk-eyebrow">{profile.profile_key} · order {profile.sort_order}</span><h1>{profile.label}</h1></div>
<div className="helpdesk-detail-actions"><StatusBadge status={profile.active ? "active" : "inactive"} label={profile.active ? "Active" : "Inactive"} /><Button type="button" onClick={onEdit}><Pencil size={16} /> Edit</Button></div>
</header>
{profile.description && <p className="helpdesk-description">{profile.description}</p>}
<div className="helpdesk-facts">
<Fact label="Queue" value={profile.queue_ref} />
<Fact label="Revision" value={String(profile.revision)} />
<Fact label="Ticket types" value={profile.ticket_types.map(humanize).join(", ")} />
<Fact label="Priorities" value={profile.priorities.map(humanize).join(", ")} />
</div>
<section className="helpdesk-targets"><h2>Service targets</h2><div>{PRIORITIES.map((priority) => <Fact key={priority} label={humanize(priority)} value={duration(profile.target_minutes[priority] ?? profile.default_target_minutes)} />)}</div></section>
<section className="helpdesk-policy-note"><h2>Applied consequence</h2><p>The first active matching profile sets the Ticket queue and target. If no profile matches, Ticket intake succeeds and authorized staff route it manually.</p></section>
</article>;
}
function ProfileEditorDialog({ open, profile, saving, error, onClose, onSave }: {
open: boolean;
profile: ServiceProfile | null;
saving: boolean;
error: string;
onClose: () => void;
onSave: (values: ProfileValues) => Promise<void>;
}) {
const [values, setValues] = useState(() => profileValues(profile));
useEffect(() => { if (open) setValues(profileValues(profile)); }, [open, profile]);
function set<K extends keyof ProfileValues>(key: K, value: ProfileValues[K]) { setValues((current) => ({ ...current, [key]: value })); }
function submit(event: FormEvent) { event.preventDefault(); void onSave(values); }
return <Dialog open={open} title={profile ? "Edit Helpdesk profile" : "New Helpdesk profile"} onClose={onClose} closeDisabled={saving} className="helpdesk-editor-dialog" footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="helpdesk-profile-form" variant="primary" disabled={saving}>{saving ? "Saving..." : "Save"}</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="helpdesk-profile-form" columns={2} gap="compact" collapseAt="narrow" className="helpdesk-editor-form" onSubmit={submit}>
<label><FieldLabel help="Stable and immutable after creation.">Profile key</FieldLabel><input value={values.key} disabled={Boolean(profile)} required maxLength={120} onChange={(event) => set("key", event.target.value)} /></label>
<label><FieldLabel>Label</FieldLabel><input value={values.label} required maxLength={255} onChange={(event) => set("label", event.target.value)} /></label>
<label><FieldLabel>Queue reference</FieldLabel><input value={values.queueRef} required maxLength={255} onChange={(event) => set("queueRef", event.target.value)} /></label>
<label><FieldLabel help="Lower values are evaluated first.">Sort order</FieldLabel><input type="number" min="0" max="100000" value={values.sortOrder} required onChange={(event) => set("sortOrder", event.target.value)} /></label>
<label><FieldLabel>Ticket types</FieldLabel><select multiple value={values.ticketTypes} required onChange={(event) => set("ticketTypes", selectedValues(event.currentTarget))}>{TYPES.map((item) => <option key={item} value={item}>{humanize(item)}</option>)}</select></label>
<label><FieldLabel>Priorities</FieldLabel><select multiple value={values.priorities} required onChange={(event) => set("priorities", selectedValues(event.currentTarget))}>{PRIORITIES.map((item) => <option key={item} value={item}>{humanize(item)}</option>)}</select></label>
<label className="wide"><FieldLabel>Description</FieldLabel><textarea rows={3} value={values.description} maxLength={10_000} onChange={(event) => set("description", event.target.value)} /></label>
<TargetField label="Low target (minutes)" value={values.lowMinutes} onChange={(value) => set("lowMinutes", value)} />
<TargetField label="Normal target (minutes)" value={values.normalMinutes} onChange={(value) => set("normalMinutes", value)} />
<TargetField label="High target (minutes)" value={values.highMinutes} onChange={(value) => set("highMinutes", value)} />
<TargetField label="Urgent target (minutes)" value={values.urgentMinutes} onChange={(value) => set("urgentMinutes", value)} />
<TargetField label="Default target (minutes)" value={values.defaultMinutes} onChange={(value) => set("defaultMinutes", value)} />
<label className="helpdesk-active-field"><input type="checkbox" checked={values.active} onChange={(event) => set("active", event.target.checked)} /> Active for new Ticket routing</label>
<label className="wide"><FieldLabel help="Stored with immutable configuration history.">Change reason</FieldLabel><input value={values.changeReason} required maxLength={1_000} onChange={(event) => set("changeReason", event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function TargetField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return <label><FieldLabel>{label}</FieldLabel><input type="number" min="1" max="525600" value={value} onChange={(event) => onChange(event.target.value)} /></label>;
}
function Fact({ label, value }: { label: string; value: string }) { return <div><span>{label}</span><strong>{value}</strong></div>; }
function profileValues(profile: ServiceProfile | null): ProfileValues {
return {
key: profile?.profile_key ?? "",
label: profile?.label ?? "",
description: profile?.description ?? "",
queueRef: profile?.queue_ref ?? "",
ticketTypes: profile?.ticket_types ?? ["request"],
priorities: profile?.priorities ?? ["normal"],
lowMinutes: numberText(profile?.target_minutes.low),
normalMinutes: numberText(profile?.target_minutes.normal),
highMinutes: numberText(profile?.target_minutes.high),
urgentMinutes: numberText(profile?.target_minutes.urgent),
defaultMinutes: numberText(profile?.default_target_minutes),
active: profile?.active ?? true,
sortOrder: String(profile?.sort_order ?? 100),
changeReason: profile ? "Updated Helpdesk routing policy." : "Created Helpdesk routing policy."
};
}
function targetMinutes(values: ProfileValues): Record<string, number> {
const result: Record<string, number> = {};
for (const [priority, minutes] of [
["low", positiveNumber(values.lowMinutes)],
["normal", positiveNumber(values.normalMinutes)],
["high", positiveNumber(values.highMinutes)],
["urgent", positiveNumber(values.urgentMinutes)]
] as const) {
if (minutes !== null) result[priority] = minutes;
}
return result;
}
function positiveNumber(value: string): number | null { return value ? Number(value) : null; }
function numberText(value?: number | null): string { return value ? String(value) : ""; }
function selectedValues(select: HTMLSelectElement): string[] { return Array.from(select.selectedOptions, (option) => option.value); }
function humanize(value: string): string { return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
function duration(minutes?: number | null): string { return minutes ? `${minutes} minutes` : "No automatic target"; }
function message(reason: unknown): string { return reason instanceof Error ? reason.message : "Helpdesk profiles could not be updated."; }
+1
View File
@@ -0,0 +1 @@
export { helpdeskModule as default, helpdeskModule } from "./module";
+39
View File
@@ -0,0 +1,39 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/helpdesk.css";
const HelpdeskProfilesPage = lazy(() => import("./features/helpdesk/HelpdeskProfilesPage"));
export const helpdeskModule: PlatformWebModule = {
id: "helpdesk",
label: "Helpdesk",
version: "0.1.20",
optionalDependencies: ["tickets"],
routes: [
{
path: "/helpdesk",
anyOf: ["helpdesk:workspace:admin"],
order: 82,
surfaceId: "helpdesk.route.profiles",
render: (context) => createElement(HelpdeskProfilesPage, context)
}
],
navItems: [
{
to: "/helpdesk",
label: "Helpdesk profiles",
iconName: "headset",
anyOf: ["helpdesk:workspace:admin"],
order: 82,
surfaceId: "helpdesk.navigation"
}
],
viewSurfaces: [
{ id: "helpdesk.navigation", moduleId: "helpdesk", kind: "navigation", label: "Helpdesk navigation", order: 10 },
{ id: "helpdesk.route.profiles", moduleId: "helpdesk", kind: "route", label: "Helpdesk service profiles", order: 20 },
{ id: "helpdesk.action.save-profile", moduleId: "helpdesk", kind: "action", label: "Save Helpdesk service profile", parentId: "helpdesk.route.profiles", order: 30 }
]
};
export default helpdeskModule;
+24
View File
@@ -0,0 +1,24 @@
.helpdesk-page { height: 100%; min-height: 0; overflow: hidden; }
.helpdesk-count { color: var(--text-soft); font-size: 0.8rem; }
.helpdesk-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(340px, 38%) minmax(0, 1fr); }
.helpdesk-list-viewport, .helpdesk-detail-viewport { min-height: 0; padding: 14px; }
.helpdesk-list-viewport { border-right: 1px solid var(--border); background: var(--surface-subtle, var(--surface)); }
.helpdesk-detail { width: min(100%, 920px); margin: 0 auto; padding: 4px 8px 30px; }
.helpdesk-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 15px; border-bottom: 1px solid var(--border); }
.helpdesk-detail-header h1 { margin: 4px 0 0; font-size: 1.4rem; letter-spacing: 0; }
.helpdesk-eyebrow { color: var(--text-soft); font-size: 0.8rem; }
.helpdesk-detail-actions { display: flex; align-items: center; gap: 8px; }
.helpdesk-description { max-width: 76ch; margin: 18px 0; white-space: pre-wrap; }
.helpdesk-facts, .helpdesk-targets > div { 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); }
.helpdesk-facts > div, .helpdesk-targets > div > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; padding: 11px; background: var(--surface-raised); }
.helpdesk-facts span, .helpdesk-targets span { color: var(--text-soft); font-size: 0.76rem; }
.helpdesk-facts strong, .helpdesk-targets strong { overflow: hidden; text-overflow: ellipsis; font-size: 0.86rem; }
.helpdesk-targets, .helpdesk-policy-note { margin-top: 24px; }
.helpdesk-targets h2, .helpdesk-policy-note h2 { margin: 0 0 10px; font-size: 0.98rem; letter-spacing: 0; }
.helpdesk-editor-dialog { width: min(760px, calc(100vw - 32px)); }
.helpdesk-editor-form label { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
.helpdesk-editor-form select[multiple] { min-height: 116px; }
.helpdesk-active-field { align-self: end; flex-direction: row !important; align-items: center; min-height: 38px; }
@media (max-width: 900px) { .helpdesk-facts, .helpdesk-targets > div { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) { .helpdesk-workspace { grid-template-columns: 1fr; } .helpdesk-list-viewport { max-height: 40vh; border-right: 0; border-bottom: 1px solid var(--border); } }
@media (max-width: 520px) { .helpdesk-detail-header { flex-direction: column; } .helpdesk-facts, .helpdesk-targets > div { grid-template-columns: 1fr; } }