Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f389ff2c | ||
|
|
2be7232c83 | ||
|
|
2cfc013791 | ||
|
|
72ce70ff86 | ||
|
|
cad54fcb9a | ||
|
|
32bd55d5bd | ||
|
|
064ec6158d | ||
|
|
a1ea9a330b | ||
|
|
fdfeb8ed3d | ||
|
|
54d8423fa6 |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+30
-4
@@ -1,8 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/helpdesk",
|
"name": "@govoplan/helpdesk-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.21",
|
||||||
"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": {}
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/helpdesk.css": "./webui/src/styles/helpdesk.css"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.30",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-helpdesk"
|
name = "govoplan-helpdesk"
|
||||||
version = "0.1.15"
|
version = "0.1.21"
|
||||||
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.15",
|
"govoplan-core>=0.1.30",
|
||||||
"govoplan-access>=0.1.15",
|
"govoplan-access>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'helpdesk.module-boundary': {'consequence_classes': {'policy_overlay': 'Wendet Warteschlangen- '
|
||||||
|
'und '
|
||||||
|
'Service-Zielrichtlinien '
|
||||||
|
'an, ohne den '
|
||||||
|
'Ticket-Status zu '
|
||||||
|
'duplizieren.',
|
||||||
|
'reduced_installation': 'Manuelles '
|
||||||
|
'Ticket-Routing '
|
||||||
|
'bleibt verfügbar, '
|
||||||
|
'wenn Helpdesk '
|
||||||
|
'deaktiviert ist.'}}}
|
||||||
@@ -1,12 +1,40 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_helpdesk.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
|
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.15"
|
MODULE_VERSION = "0.1.21"
|
||||||
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,72 +52,117 @@ 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"),
|
||||||
audience=("user", "operator", "module_admin", "product_owner"),
|
audience=("user", "operator", "module_admin", "product_owner"),
|
||||||
order=100,
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Modulgrenze von Helpdesk",
|
||||||
|
"summary": "Serviceprofile, Warteschlangensteuerung und Serviceziele über dem maßgeblichen Tickets-Lebenszyklus.",
|
||||||
|
"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.",
|
||||||
|
}
|
||||||
|
},
|
||||||
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={
|
||||||
"seed": True,
|
"kind": "reference",
|
||||||
"domain_objects": ['internal support tickets', 'service categories', 'assignment and escalation state', 'resolution records', 'SLA-facing timestamps'],
|
"consequence_classes": {
|
||||||
"first_slice": (
|
"policy_overlay": "Applies queue and service-target policy without duplicating Ticket state.",
|
||||||
"Define Helpdesk queue, SLA, and escalation profiles over the "
|
"reduced_installation": "Manual Ticket routing remains available when Helpdesk is disabled.",
|
||||||
"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=(
|
||||||
|
"Open the documentation book immediately beside Helpdesk profiles for routing-profile "
|
||||||
|
"guidance. "
|
||||||
|
"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": "Öffnen Sie das Dokumentationsbuch unmittelbar neben Helpdesk-Profile für Hinweise zu "
|
||||||
|
"Routing-Profilen. "
|
||||||
|
"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,
|
||||||
@@ -97,20 +170,67 @@ 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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Helpdesk-owned database migrations."""
|
||||||
+72
@@ -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."""
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
-11
@@ -2,22 +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.21", 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)
|
||||||
self.assertIsNone(manifest.route_factory)
|
self.assertTrue(any(item.metadata.get("kind") == "workflow" for item in topics))
|
||||||
self.assertIsNone(manifest.migration_spec)
|
|
||||||
self.assertIsNone(manifest.frontend)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/helpdesk-webui",
|
||||||
|
"version": "0.1.21",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
@@ -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,247 @@
|
|||||||
|
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
|
||||||
|
title="Helpdesk profiles"
|
||||||
|
titleHelp={<DocumentationHelpLink reference={{ topicId: "helpdesk.configure-routing-profiles", documentationType: "admin" }} label="Open Helpdesk profile documentation" />}
|
||||||
|
scope="workspace"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reload(), loading }}
|
||||||
|
contextActions={<span className="helpdesk-count">{profiles.length} profiles · ordered first match wins</span>}
|
||||||
|
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"} size="large" onClose={onClose} closeDisabled={saving} 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."; }
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
/** Module-owned translations for contextual headings. */
|
||||||
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
|
en: {
|
||||||
|
"Helpdesk profiles": "Helpdesk profiles",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
"Helpdesk profiles": "Helpdesk-Profile",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { helpdeskModule as default, helpdeskModule } from "./module";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import "./styles/helpdesk.css";
|
||||||
|
|
||||||
|
|
||||||
|
const HelpdeskProfilesPage = lazy(() => import("./features/helpdesk/HelpdeskProfilesPage"));
|
||||||
|
|
||||||
|
export const helpdeskModule: PlatformWebModule = {
|
||||||
|
translations: generatedTranslations,
|
||||||
|
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;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.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-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: 560px) { .helpdesk-detail-header { flex-direction: column; } .helpdesk-facts, .helpdesk-targets > div { grid-template-columns: 1fr; } }
|
||||||
Reference in New Issue
Block a user