Service Ticketing¶
Service ticketing gives Floh a dual UX for support cases: requesters work in the public portal (catalog submit, My Tickets, public replies), and agents work in the admin app (inbox, assignment, status/priority/queue, internal notes, SLA snooze, queues, and SLA policies).
This page is the developer-facing overview of what shipped under Phase 2–3 (epic LSA-9058 and related stories). For a live walkthrough, see Service Ticketing Demo.
Architecture¶
flowchart LR
requester[Requester]
portalSpa[Portal SPA :7073]
portalBff[Portal BFF :7071]
adminSpa[Admin SPA :7072]
api[Floh API :7070]
db[(Postgres)]
emailGw[Email gateway]
requester --> portalSpa
portalSpa --> portalBff
portalBff -->|"whitelist only"| api
adminSpa -->|"ticket:manage"| api
emailGw -->|"HMAC webhook"| api
api --> db
| Surface | Role | Primary entry |
|---|---|---|
| Portal SPA | Authenticated requester | My Tickets, Request Catalog |
| Admin SPA | Agent / admin with ticket:manage |
Tickets hub, queues, SLA policies |
| Email webhook | External gateway | POST /api/email-ticketing/inbound/:connectorId |
Published knowledge articles live in the Tickets hub Knowledge tab (markdown editor: source textarea + live preview). Developer docs remain in this MkDocs site (pnpm docs:serve); the HTTP API reference is Swagger at https://localhost:7070/api/docs.
Permissions¶
| Permission | Who | What it unlocks |
|---|---|---|
| Authenticated session | Portal requester | Catalog submit, list/detail of own tickets, public comments |
ticket:create |
Requestor role (seeded) | Direct POST /api/tickets (API / agent create path; not exposed on the portal BFF) |
ticket:manage |
Admin (and roles that include it) | Admin Tickets hub, assign / status / priority / queue, internal notes, snooze SLA, queues, SLA policies |
ticket:report |
Admin, resource_manager | Read-only ticket reporting aggregates (LSA-9068); also unlocks the Reports hub Ticketing tab |
knowledge:manage |
Admin, resource_manager | Author, publish, and soft-delete knowledge-base articles (Tickets hub Knowledge) |
Catalog submit (POST /api/request-catalog/:id/submit) requires only authentication. When the workflow contains a create_ticket step, the server creates the run and a linked service_ticket in one transaction. An optional ticket queue binding supplies queue and SLA data.
Callers with ticket:manage, ticket:report, or report:read see Reports in the admin sidebar. Only ticket:manage or ticket:report unlocks the Ticketing hub tab and its analytics. Queue / inbox mutation UIs still require ticket:manage.
Feature inventory by story¶
Queues and SLA policies (LSA-9062 / LSA-9064)¶
- Admin routes:
/tickets/queues,/tickets/sla-policies - Queues hold membership and an optional SLA policy
- Policies define response and resolution hours per priority (
low,medium,high,critical) - SLA clocks initialize when a ticket is created into a queue that has a policy
Agent inbox / Tickets hub (LSA-9062 / LSA-9629)¶
- Admin sidebar Tickets (gated on
ticket:manage) - List filters: status, priority, queue, assignee
- Detail assignment card: status, priority, queue, assignee
- Quick Update dialog from the list for status / priority / queue
- Comments dialog with optional Internal note
- Snooze SLA with a future date/time
Portal My Tickets (LSA-9063)¶
- Routes:
/tickets,/tickets/:ticketNumber - Topbar My Tickets
- Tabs: Open (
open,in_progress,on_holdviastatusIn), Pending Your Response (pending_customer), Resolved, All - Detail: details, description, public Conversation; Reply posts a public comment only
- A public requester reply on
pending_customertransitions the ticket back toopen(server-enforced)
Catalog → ticket (LSA-9061)¶
- Optional
workflow_definition.ticket_queue_idset on the workflow’s Catalog Publishing card (Ticket Queue picker; listing/binding queues needsticket:manage— both UI andPATCH /request-catalog/:id) - Floh ticket creation is opt-in: add a Create ticket (
create_ticket) step to the workflow. At most one per workflow. Omit the step for self-service / non-ticketed catalog items (run only). - When the step is present, catalog submit creates run + ticket in one DB transaction, then starts the engine / SLA init after commit
- Response includes numeric
ticketNumberandticketAccessibleonly when a ticket was created; portal links#Nwhen accessible and shows plain#Nwhen inaccessible. Without the step, the portal shows a run-only “request submitted” confirmation - On-behalf-of:
requester_idis the target user; priority is mapped server-side (default medium path); withoutticket:managethe confirmation shows#Nas plain text (no dead link) - Step settings (LSA-9772): the
create_ticketstep Configuration panel lets authors set queue, assignee, priority, title, description, and request type as literals or bind them to input form variables (not both). Resolution: literal → non-blank variable → field-specific fallback. Reserved defaults apply only to title (ticketTitle→ workflow name), description (ticketDescription), and request type (ticketRequestType). Queue falls back to the workflow default ticket queue; assignee to unset; priority to the category default (currently medium). - Image attachments (LSA-9773): when
attachmentsEnabledis set on thecreate_ticketstep config, requesters and agents can attach images to the resulting ticket. Files are validated by magic-byte sniffing — client MIME headers alone do not authorize a file. SVG is always rejected. Defaults: png/jpeg/webp/gif, 5 MiB per file, max 3 per ticket. Authors can overrideallowedMimeTypes,maxSizeBytes, andmaxFilesin the step config panel; each numeric override is floored to a non-negative integer, so0disables attachments in practice and a fractional value rounds down — the ceiling checks compare the floored value, somaxFiles: 60.5saves and is enforced as60. A file of exactly the configured maximum uploads normally; only a file over it is rejected, and it is rejected outright rather than stored as the truncated prefix the parser delivered. Clearing a numeric override returns the field to its default rather than storing an empty value, both in the editor and for definitions already holding one.maxSizeBytesis capped at 50 MiB, and a config above that is rejected when the workflow is saved rather than accepted and then failed at upload (a definition stored above the cap before the check existed keeps working — its reported size clamps down to the cap instead of failing submissions) — the reverse proxy and the server'sMAX_UPLOAD_SIZEare both sized from the same ceiling so an accepted limit is one the deployment can actually carry. Operators who lowerMAX_UPLOAD_SIZEbelow 50 MiB cap every workflow at that value — both the size advertised to the picker and the size the upload route enforces are resolved against it, so the setting is a real ceiling rather than an advisory one; the server logs a warning at startup when the two disagree. When a definition disables attachments through a zero count or a zero maximum size rather than an empty format list, the picker says which of the three settings is responsible, so the setting to change is named rather than guessed at.maxFilesis capped at 60 for the same reason, and the upload endpoint's throttle is derived from that cap rather than set separately (two full queues per minute). Every attachment route buckets its throttle per caller credential rather than per IP, so a shared corporate egress does not put colleagues in one budget — the download route most of all, since each ticket view auto-fetches a preview per image — and a second, much larger per-IP ceiling sits behind that one, because the credential is read from the request before it has been verified and would otherwise let an anonymous caller mint a fresh budget per made-up token; the ceiling is sized for a whole office (ten full queues a minute) so real traffic never meets it — the portal uploads a queue serially with no pacing, so a throttle belowmaxFileswould turn the advertised count into a partial failure. As with size, an above-cap count is rejected at save and an already-stored one clamps down on read. Portal catalog submit offers a file picker when the workflow includes an attachments-enabledcreate_ticketstep; the picker enforces the workflow's effective limits (and disables itself if they leave nothing selectable — e.g.maxFiles: 0, or anallowedMimeTypeslist holding only non-image types), and skips zero-byte files up front since the upload route rejects an empty file after the ticket already exists. A skipped file names the MIME type the browser reported and says that type comes from the file name, since a file saved with the wrong extension is the one case where the picker and the server's byte sniffer can disagree. Files upload one at a time after the ticket is created, and any that fail are reported to the requester so they can retry from the ticket detail page; that warning persists until it is dismissed or a later submission reports its own outcome, so opening another catalog entry does not discard the record of dropped images. Navigating away mid-upload cancels the remaining files — the ticket already exists, so the requester gets a sticky warning naming how many images are missing — including any that failed before the navigation, whose banner would otherwise have died with the page. One narrower window is not yet covered: navigating away in the moment between the submit request committing and its response arriving drops the images with no warning, because the queue has not started yet (issue #1211). The ticket is still created, and the images can be added from its detail page. Both admin and portal ticket detail surfaces show the attachment list and a file upload control; that picker is built from the limitsGET /api/tickets/:id/attachmentsreports for the ticket, so a workflow allowing a non-default format stays retryable there, and the control is hidden entirely for tickets that cannot accept uploads (standalone tickets, or workflows withoutattachmentsEnabled). A preview that fails to fetch can be retried from its placeholder — including one that failed while loading automatically — and a retry that succeeds clears the failure notice. Attachment previews depend on browser support rather than an allow-list — a format the server can store but the browser cannot decode (camera RAW, Photoshop) shows a placeholder and a download prompt instead of a broken image. - Optional reserved workflow variables (add on the catalog workflow when you want requester-supplied text):
ticketTitle(string) → ticket title; blank/missing falls back to the workflow nameticketDescription(string) → ticket description when non-blankticketRequestType(allowlisted) →service_ticket.request_type(email, password_reset, network, hardware_support, software, access, other). Portal renders a dropdown for this variable name.- Ticket
categoryremains the workflow category (user,user_self_service, …). Requester-facing IT taxonomy isrequest_type. - Breaking for authors: catalog workflows that previously always created a ticket must include a
create_ticketstep after this change. - Portal does not expose
POST /api/ticketsor a standalone “New Ticket” button — the catalog is the create path for Floh tickets
Email channel (LSA-9066)¶
- Connector type
email-ticketing(inboxAddress,webhookSecret,adminNotifyEmail) - Inbound HMAC-signed webhook creates or threads tickets (
source = email) - Unknown senders are quarantined (no auto-provision)
- Agent public comments on email-sourced tickets can trigger outbound mail; internal notes never email
- Full reference: Email Ticketing connector
SLA breach notifications (LSA-9067)¶
- Workers notify on response/resolution SLA breach when email/SMTP is configured for the environment
- Demo and local setups may skip live breach mail; the clocks and snooze still show in the UI
Knowledge base (LSA-9070)¶
- Admin routes:
/tickets/knowledge(Tickets hub Knowledge tab)./knowledgeredirects here. - Requires
knowledge:manage. The markdown editor is a source textarea labeled Markdown plus a live Preview pane (renderMarkdown/ DOMPurify). There is no WYSIWYG (p-editor/ Monaco). - Articles have title, markdown body, optional tags, IT request-type category, and published flag. Unpublished rows are hidden from everyone except
knowledge:manage(404 on GET by id). Soft-delete is adeleted_attombstone. - Search uses Postgres full-text on title + body + tags when the database is Postgres. Tokens are matched as prefixes (
to_tsquerytoken:*, ranked withts_rank) so catalog typeahead likemicrohitsMicrosoft. Unit-test and non-Postgres executors fall back to escapedILIKEmatching across title, body, and tags. - Portal catalog forms suggest published articles while the requester types the effective title field (
create_ticket.titleVariable, else reservedticketTitle) or description. Clicking a hit opens a right-sidep-drawer— the form is not navigated away. - Article bodies are rendered as markdown only. Tokens like
{{submitter.email}}stay literal text. Do not paste internal comments, run variables, or secrets into article bodies — there is no content firewall. - Portal BFF proxies GET
/api/knowledgeand GET/api/knowledge/:idonly. Writes stay on the admin API.
Ticket reporting (LSA-9068)¶
- Admin route:
/reports/tickets(Reports hub → Ticketing). Legacy/tickets/reportsredirects here. - API:
GET /api/tickets/reports/summary?from=YYYY-MM-DD&to=YYYY-MM-DD(UTC calendar days; default last 30 inclusive days). Requiresticket:manageorticket:report(report:readalone is not enough). - Sections: volume (status / priority / queue / day / request type), open-by-priority snapshot, SLA compliance %, resolution time (avg / p95), current agent workload
- Request type:
volume.byRequestTypegroups onservice_ticket.request_type(IT taxonomy: email, password reset, network, …). Null and empty-string values appear as Unspecified. Catalog workflows populate this via the reserved form variableticketRequestType(LSA-9061). - Catalog demos: only workflows that include a Create ticket (
create_ticket) step create a Floh ticket on portal submit. Without that step, Ticketing reports stay empty for those catalog requests even though a run succeeded. - Tenancy: aggregates are deployment-scoped (same boundary as the agent inbox). Floh tickets are not partitioned by organization id.
SLA compliance % = tickets with resolved_at IS NOT NULL AND resolved_at <= due_at ÷ tickets with due_at set (in the created-at window) × 100. Breach flags count tickets with sla_breached set by the LSA-9067 breach job — that flag is independent of the compliance % numerator, so a row can show low compliance with zero breach flags when the job has not fired yet.
Key URLs (local HTTPS)¶
| Port | App | Typical start |
|---|---|---|
| 7070 | API (+ /api/docs) |
pnpm dev:all https |
| 7071 | Portal BFF | pnpm dev:portal:https |
| 7072 | Admin web | pnpm dev:all https |
| 7073 | Portal SPA | pnpm dev:portal:https |
| App | Path | Purpose |
|---|---|---|
| Portal | /requests/catalog |
Submit catalog request → ticket #N |
| Portal | /tickets |
My Tickets list |
| Portal | /tickets/:ticketNumber |
Requester case view + reply |
| Admin | /tickets |
Agent inbox |
| Admin | /tickets/:ticketNumber |
Agent case detail |
| Admin | /tickets/queues |
Queue admin |
| Admin | /tickets/sla-policies |
SLA policy admin |
| Admin | /tickets/knowledge |
Knowledge articles (markdown editor) |
| Admin | /reports/tickets |
Ticket analytics (LSA-9068) |
pnpm dev:all https starts API + admin only. The portal is a separate mode — see Service Ticketing Demo.
Data model notes¶
| Concept | Notes |
|---|---|
service_ticket |
Case row; display id is numeric ticket_number / API ticketNumber |
requester_id |
Owner for portal scope; catalog on-behalf-of binds the target user |
queue_id |
Optional FK; catalog path can inherit from workflow_definition.ticket_queue_id |
ticket_attachment |
Image file linked to a ticket; soft-deletable; magic-byte sniffed on upload |
| Statuses | open, in_progress, on_hold, pending_customer, resolved, closed |
| Priorities | low, medium, high, critical |
| Sources | portal, email, api, agent |
| Comments | Public vs isInternal; requesters never see internal notes |
| Attachments | Sniffed images; requester or ticket:manage; soft-delete preserves blob |
| SLA | Response / resolution due timestamps; snooze defers the clock |
Security boundaries¶
- Portal BFF whitelists only requester-safe ticket routes: list, get-by-number, and comments, plus published knowledge GET search and GET-by-id. Assign, status, priority, queue, snooze, create, queue admin, and knowledge write APIs are blocked at the BFF.
- Non-manage list/detail are hard-scoped to
requester_id; non-owners get 404 on detail. - Requesters cannot set
isInternalon comments. - Server-bound identity on catalog / create paths overrides caller-supplied requester fields.
- Email webhook authenticates via HMAC of the raw body; the route is CSRF-exempt and has no session cookie requirement.
- Comment and email bodies are sanitized and are not copied into audit metadata.
Related docs and plans¶
| Doc | Purpose |
|---|---|
| Service Ticketing Demo | Ordered demo runbook |
| Public Portal | Portal architecture and BFF whitelist |
| Email Ticketing connector | Inbound webhook and threading |
docs/plans/2026-07-*-lsa-906*.md |
Implementation plans for queues, SLA, portal, catalog, email |
https://localhost:7070/api/docs |
OpenAPI / Swagger |
Roadmap¶
- Phase 1 triage (LSA-9052) — owned separately; not required for catalog → My Tickets → agent inbox flows.
- Existing Reporting covers workflow report templates; ticket analytics ships under LSA-9068 (
/reports/tickets).