Skip to content

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_hold via statusIn), Pending Your Response (pending_customer), Resolved, All
  • Detail: details, description, public Conversation; Reply posts a public comment only
  • A public requester reply on pending_customer transitions the ticket back to open (server-enforced)

Catalog → ticket (LSA-9061)

  • Optional workflow_definition.ticket_queue_id set on the workflow’s Catalog Publishing card (Ticket Queue picker; listing/binding queues needs ticket:manage — both UI and PATCH /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 ticketNumber and ticketAccessible only when a ticket was created; portal links #N when accessible and shows plain #N when inaccessible. Without the step, the portal shows a run-only “request submitted” confirmation
  • On-behalf-of: requester_id is the target user; priority is mapped server-side (default medium path); without ticket:manage the confirmation shows #N as plain text (no dead link)
  • Step settings (LSA-9772): the create_ticket step 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 attachmentsEnabled is set on the create_ticket step 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 override allowedMimeTypes, maxSizeBytes, and maxFiles in the step config panel; each numeric override is floored to a non-negative integer, so 0 disables attachments in practice and a fractional value rounds down — the ceiling checks compare the floored value, so maxFiles: 60.5 saves and is enforced as 60. 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. maxSizeBytes is 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's MAX_UPLOAD_SIZE are both sized from the same ceiling so an accepted limit is one the deployment can actually carry. Operators who lower MAX_UPLOAD_SIZE below 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. maxFiles is 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 below maxFiles would 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-enabled create_ticket step; the picker enforces the workflow's effective limits (and disables itself if they leave nothing selectable — e.g. maxFiles: 0, or an allowedMimeTypes list 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 limits GET /api/tickets/:id/attachments reports 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 without attachmentsEnabled). 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 name
  • ticketDescription (string) → ticket description when non-blank
  • ticketRequestType (allowlisted) → service_ticket.request_type (email, password_reset, network, hardware_support, software, access, other). Portal renders a dropdown for this variable name.
  • Ticket category remains the workflow category (user, user_self_service, …). Requester-facing IT taxonomy is request_type.
  • Breaking for authors: catalog workflows that previously always created a ticket must include a create_ticket step after this change.
  • Portal does not expose POST /api/tickets or 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). /knowledge redirects 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 a deleted_at tombstone.
  • Search uses Postgres full-text on title + body + tags when the database is Postgres. Tokens are matched as prefixes (to_tsquery token:*, ranked with ts_rank) so catalog typeahead like micro hits Microsoft. Unit-test and non-Postgres executors fall back to escaped ILIKE matching across title, body, and tags.
  • Portal catalog forms suggest published articles while the requester types the effective title field (create_ticket.titleVariable, else reserved ticketTitle) or description. Clicking a hit opens a right-side p-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/knowledge and GET /api/knowledge/:id only. Writes stay on the admin API.

Ticket reporting (LSA-9068)

  • Admin route: /reports/tickets (Reports hub → Ticketing). Legacy /tickets/reports redirects here.
  • API: GET /api/tickets/reports/summary?from=YYYY-MM-DD&to=YYYY-MM-DD (UTC calendar days; default last 30 inclusive days). Requires ticket:manage or ticket:report (report:read alone 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.byRequestType groups on service_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 variable ticketRequestType (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 isInternal on 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.
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).