# 11 — Support desk design (complete) **Status:** Design only — no implementation in this document. **Agent:** 11/20 (`admin-roles-support`) **Date:** 2026-08-05 **Repo:** `F:/laragon/www/_MY/descrybe-v2` **API contract:** [11-support-api-contract.json](./11-support-api-contract.json) --- ## 0. North star Extend the **existing** Support Center (`025_support_center.sql` + `internal/support`) into a least-privilege staff desk: - Customers open/reply on **their** tickets. - Platform admins **grant** support-agent capability and may **assign** tickets. - Support agents work a **shared unassigned queue** + **claim** model; they only see assigned or claimable tickets. - After **resolve**, customers may leave a **CSAT** rating (once). - Outbound email is **optional stubs** on top of `internal/mail` (noop when SMTP disabled). ``` Customer (/support) │ create / reply / rate (CSAT) ▼ support_tickets ──◄── support_messages (public + internal notes) │ ├── unassigned queue ── claim ──► assignee inbox └── platform admin: assign / reassign / revoke staff ``` **Principle:** Prefer extending `ListAdmin` / `GetAdmin` / `UpdateAdmin` / `ReplyAsAgent` with **actor-scoped filters** over a parallel ticket system. --- ## 1. Inventory — what exists today ### 1.1 Schema (`apps/api/sql/schema/025_support_center.sql`) | Table | Purpose | |-------|---------| | `support_tickets` | Subject, category, status, priority, `assignee_admin_user_id`, message timestamps, resolved/closed | | `support_messages` | Thread; `author_role` ∈ `user\|agent\|system`; `is_internal_note` | | `support_notifications` | In-app bell; kinds: `ticket_created`, `agent_reply`, `status_changed`, `user_reply` | **Statuses:** `open` → `pending` → `resolved` → `closed` (CHECK). **Categories:** `billing`, `bug`, `account`, `other`. **Priorities:** `low`, `normal`, `high`. **Indexes today:** - `support_tickets_admin_queue_idx (status, last_message_at DESC)` - `support_tickets_company_user_idx (company_id, created_by_user_id, updated_at DESC)` - `support_tickets_company_status_idx (company_id, status)` - `support_messages_ticket_idx (ticket_id, created_at)` - notification user unread / created indexes **Missing for this design:** assignee queue indexes, CSAT table, support-agent capability, claim audit, email event stubs. ### 1.2 API (Go) | Surface | Mount | Auth today | |---------|-------|------------| | User tickets | `/api/support/tickets` (+ messages) | `RequireSession` + `RequireCompany` | | Notifications | `/api/support/notifications*` | same | | Admin desk | `/api/admin/support/tickets*` | `RequireSession` + **`RequirePlatformAdmin`** | Handlers: `apps/api/internal/httpapi/support_handlers.go`. Domain: `apps/api/internal/support/{tickets,types,validate,notifications,ai_auto_reply}.go`. Notable behavior already shipped: - `GetForUser` / `ListForUser` — owner-only; **strips internal notes**. - `GetAdmin` / `ListAdmin` — **all companies**, includes internal notes; no assignee filter. - `ReplyAsAgent` — auto-sets assignee via `COALESCE(assignee, actor)` on public reply; defaults status `open|resolved` → `pending`. - `UpdateAdmin` — patch status / priority / assignee / `clear_assignee`. - `TryAutoReplyLLM` — **safe stub** (`ErrAIAutoReplyDisabled`); keep unused for human desk. Pagination: `ParseLimitOffset` — default **50**, max **200**. ### 1.3 Web UI | Route | Client | |-------|--------| | `/support`, `/support/new`, `/support/[ticketId]` | `$lib/support/api.ts` → `/api/support/tickets` | | `/admin/support`, `/admin/support/[id]` | `$lib/support/admin-api.ts` → `/api/admin/support/tickets` | Plan gates (catalog): `support.center`, `support.ticket_create`, `support.ticket_thread` — fail-open for Free (`docs/plan-permissions`). ### 1.4 Gaps vs goal | Goal | Today | |------|-------| | Support staff ≠ full platform admin | Only `users.is_platform_admin`; admin support routes are platform-admin-only | | Staff inbox (assigned + queue) | Flat global list for every platform admin | | Claim / least privilege | Assignee column exists; no claim race, no visibility rules | | CSAT after resolve | None | | Email on events | In-app notifications only; platform SMTP (`internal/mail`) unused by support | | Admin assigns staff | Can set `assignee_admin_user_id` on a ticket, but cannot grant a support-only role | --- ## 2. Roles & capability model ### 2.1 Capabilities (additive flags) Keep `users.is_platform_admin`. Add: ```sql ALTER TABLE users ADD COLUMN is_support_agent BOOLEAN NOT NULL DEFAULT false; ``` | Actor | Capability | |-------|------------| | **Customer** (company member) | Own tickets only (`created_by_user_id` + `company_id`) | | **Support agent** (`is_support_agent`) | Queue + claim + own inbox; internal notes; reply/status on allowed tickets | | **Platform admin** (`is_platform_admin`) | Everything agents can do **plus** global list, force-assign/reassign, grant/revoke `is_support_agent`, CSAT aggregates | **ASSUMPTION:** `is_platform_admin` implies support access (no need to also set `is_support_agent`). Middleware: `is_platform_admin OR is_support_agent`. **ASSUMPTION:** Support agents are Descrybe platform users (not tenant company roles). They may still belong to a company for login, but support desk is platform-scoped. ### 2.2 Auth middleware | Middleware | Gate | |------------|------| | `RequirePlatformAdmin` | unchanged — billing, plans, impersonation, staff grants | | **`RequireSupportDesk`** (new) | session + (`is_platform_admin` OR `is_support_agent`) from DB | Mount **desk** routes under `/api/admin/support/*` with `RequireSupportDesk` (not full admin). Mount **staff management** under `/api/admin/support/agents*` with `RequirePlatformAdmin` only. --- ## 3. Visibility policy — **queue + claim** (chosen) Least-privilege default: agents never list or open tickets assigned to someone else. ### 3.1 List scopes Query param `scope` on staff list: | `scope` | Who | Filter | |---------|-----|--------| | `inbox` (default for agents) | agent | `assignee_admin_user_id = me OR assignee IS NULL` and `status IN ('open','pending')` | | `mine` | agent | `assignee = me` | | `unassigned` | agent | `assignee IS NULL` and status open/pending | | `all` | **platform admin only** | optional filters: status, company_id, assignee_id, q | Agents requesting `scope=all` → **403**. ### 3.2 Get / reply / update | Action | Agent allowed when | |--------|--------------------| | GET ticket | assignee = me **OR** (unassigned AND status ∈ open/pending) | | Public reply / internal note | assignee = me **OR** claim-first on unassigned | | PATCH status/priority | assignee = me | | Force assign / clear / reassign | **platform admin only** | | Claim | unassigned + open/pending; atomic | | Release | assignee = me → set NULL (optional system note) | **Get on another agent's ticket → 404** (not 403) to avoid ticket-id probing. Platform admin gets 200 always. ### 3.3 Claim (atomic) ```sql UPDATE support_tickets SET assignee_admin_user_id = $actor, updated_at = now() WHERE id = $id AND assignee_admin_user_id IS NULL AND status IN ('open', 'pending') RETURNING id; ``` - 0 rows → `409 conflict` (`already_claimed` or `not_claimable`). - On success: optional internal system message + notification to claimant; email stub `ticket_claimed`. **First public agent reply** may continue to auto-claim via existing `COALESCE(assignee, actor)` **only if** still unassigned; if assigned to another agent → **403/404**. ### 3.4 Alternatives considered (rejected for MVP) | Model | Why not | |-------|---------| | Shared team inbox (all agents see all) | Violates least privilege; leaks tenant PII across staff | | Assigned-only (no queue) | Admin must hand-assign every ticket; high latency | | Round-robin auto-assign | Needs fair staffing metrics; defer | --- ## 4. Status workflow Keep existing enum; tighten **transitions** in service layer (today: mostly free-form normalize). ``` ┌──────────────┐ │ open │◄──── customer reply (from pending/resolved) └──────┬───────┘ │ agent public reply (default) ▼ ┌──────────────┐ │ pending │ waiting on customer └──────┬───────┘ agent resolve │ ▼ ┌──────────────┐ │ resolved │ CSAT window opens └──────┬───────┘ close / idle │ ▼ ┌──────────────┐ │ closed │ no customer replies └──────────────┘ ``` | From → To | Who | |-----------|-----| | * → `pending` | agent (public reply default) | | * → `resolved` | agent assignee or platform admin | | `resolved` → `closed` | agent assignee, platform admin, or optional job after N days | | `resolved\|pending` → `open` | customer reply (existing) | | `closed` → * | platform admin only (reopen); customer blocked (`ErrTicketClosed`) | Set `resolved_at` / `closed_at` as today. On resolve: enqueue CSAT invite (in-app + email stub). Internal notes **must not** change status unless `status` is explicitly sent (existing `ReplyAsAgent` behavior). --- ## 5. CSAT / rating ### 5.1 Schema ```sql CREATE TABLE support_csat_ratings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ticket_id UUID NOT NULL UNIQUE REFERENCES support_tickets(id) ON DELETE CASCADE, company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, score SMALLINT NOT NULL CHECK (score BETWEEN 1 AND 5), comment TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX support_csat_ratings_created_idx ON support_csat_ratings (created_at DESC); ``` Optional opaque token for email deep-link (hash-at-rest): ```sql -- on support_tickets csat_token_hash BYTEA, -- NULL until resolved csat_invite_sent_at TIMESTAMPTZ ``` ### 5.2 Rules - Only `created_by_user_id` may rate. - Ticket status must be `resolved` or `closed`. - One rating per ticket (`UNIQUE ticket_id`); duplicate → `409`. - Comment max 2000 runes; strip NUL; optional. - Rating is **customer-visible** on own ticket; staff see score on GET (admin/agent). - No edits after submit (MVP). ### 5.3 UX - Banner on `/support/[ticketId]` when resolved and unrated. - Admin analytics later: avg score by agent (join assignee at resolve time — optional `resolved_by_user_id` column if needed for fairness after reassignment). **ASSUMPTION:** Store `resolved_by_user_id` (nullable) set when transitioning to `resolved` so CSAT attributes to the resolver, not a later reassignment. --- ## 6. Internal notes Already modeled: `support_messages.is_internal_note` + `author_role='agent'`. Preserve: - Never return internal notes from `GetForUser` / user list serializers. - Staff GET includes notes. - Notes do not trigger `agent_reply` customer notifications (existing). - Add notification kind `internal_note` for **other staff on same ticket**? **Defer** — with claim model only assignee (+ admins) see the ticket. --- ## 7. Email — optional stubs Reuse platform `internal/mail.Mailer` (SMTP or noop). Do **not** use tenant marketing email (`internal/email`). | Event | Recipient | Stub behavior | |-------|-----------|---------------| | `ticket_created` | support agents (or shared inbox address) | noop logs subject if SMTP off | | `agent_reply` | ticket owner | link to `/support/{id}` | | `status_resolved` | ticket owner | CSAT CTA + tokenized link | | `ticket_claimed` | claiming agent | optional | | `user_reply` | assignee | if assigned | Implementation sketch: ```go // internal/support/notify_email.go func (s *Service) enqueueSupportMail(ctx, kind, ticketID) { if s.Mail == nil || !s.Mail.Enabled() { return } // build Message; never log To/Body PII } ``` Feature flag: `SUPPORT_EMAIL_ENABLED` (default false) **or** rely solely on `Mailer.Enabled()`. Prefer explicit flag so ops can enable SMTP for invites without support mail spam. River/async: optional later; MVP sync-best-effort after commit (errors logged, not failed request). --- ## 8. Schema deltas (implementation later) ```sql -- +goose Up (sketch — new migration, e.g. 0xx_support_desk.sql) ALTER TABLE users ADD COLUMN IF NOT EXISTS is_support_agent BOOLEAN NOT NULL DEFAULT false; ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS resolved_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS csat_token_hash BYTEA, ADD COLUMN IF NOT EXISTS csat_invite_sent_at TIMESTAMPTZ; CREATE INDEX support_tickets_assignee_queue_idx ON support_tickets (assignee_admin_user_id, status, last_message_at DESC NULLS LAST); CREATE INDEX support_tickets_unassigned_queue_idx ON support_tickets (status, last_message_at DESC NULLS LAST) WHERE assignee_admin_user_id IS NULL; -- support_csat_ratings as in §5.1 ALTER TABLE support_notifications DROP CONSTRAINT IF EXISTS support_notifications_kind_check; -- recreate CHECK to include: ticket_claimed, csat_requested (if used) ``` --- ## 9. API surface (summary) Full OpenAPI-ish shapes: [11-support-api-contract.json](./11-support-api-contract.json). ### 9.1 Customer (existing + CSAT) | Method | Path | Change | |--------|------|--------| | GET/POST | `/api/support/tickets` | unchanged contract | | GET | `/api/support/tickets/{id}` | add optional `csat` object | | POST | `/api/support/tickets/{id}/messages` | unchanged | | **POST** | **`/api/support/tickets/{id}/csat`** | **new** — `{ score, comment? }` | | GET/POST | `/api/support/notifications*` | unchanged | ### 9.2 Staff desk (`RequireSupportDesk`) | Method | Path | Notes | |--------|------|-------| | GET | `/api/admin/support/tickets` | add `scope`, `assignee_id`; enforce §3 | | GET | `/api/admin/support/tickets/{id}` | visibility §3.2 | | POST | `/api/admin/support/tickets/{id}/messages` | visibility + auto-claim rules | | PATCH | `/api/admin/support/tickets/{id}` | agent: status/priority only if assigned; assign fields admin-only | | **POST** | **`/api/admin/support/tickets/{id}/claim`** | atomic claim | | **POST** | **`/api/admin/support/tickets/{id}/release`** | assignee self-clear | ### 9.3 Staff directory (`RequirePlatformAdmin`) | Method | Path | Notes | |--------|------|-------| | GET | `/api/admin/support/agents` | list users with `is_support_agent` (+ admins optional) | | PUT | `/api/admin/support/agents/{userId}` | `{ is_support_agent: bool }` | ### 9.4 Public CSAT token (optional) | Method | Path | Notes | |--------|------|-------| | POST | `/api/public/support/csat` | `{ token, score, comment? }` — rate-limited; no session | --- ## 10. Web UI (later) | Route | Audience | |-------|----------| | `/support*` | customer — add CSAT panel | | `/admin/support` | rename mentally to “desk”; agents see Inbox tabs: Unassigned / Mine | | `/admin/support/[id]` | hide assign controls for agents; show Claim if unassigned | | `/admin/support/agents` | platform admin — grant/revoke agents | Nav: show Support desk link if `is_platform_admin || is_support_agent` (session `/api/auth/me` must expose `is_support_agent`). --- ## 11. Security notes 1. **AuthZ server-side** — never trust client `scope` / assignee; re-check on every GET/PATCH/reply. 2. **404 vs 403** on cross-assignee reads for agents (anti-enumeration). 3. **Internal notes** — filter at SQL (`is_internal_note = false`) for customer paths; double-filter in TS serializers. 4. **CSAT token** — store only SHA-256 hash; single-use invalidate on submit; rate-limit public endpoint. 5. **Staff grant** — platform admin only; audit log recommended (who granted whom). 6. **PII** — list search already ILIKE email/company; keep limit/offset caps; no unbounded export in MVP. 7. **Claim races** — single `UPDATE … WHERE assignee IS NULL` in a transaction; no read-modify-write. 8. **Closed tickets** — customers cannot reply; agents notes-only or admin reopen. 9. **AI** — keep `TryAutoReplyLLM` disabled; human desk only. 10. **Email** — no logging of To/Body; header-break validation via existing mailer. --- ## 12. Performance notes | Path | Guidance | |------|----------| | Customer list | existing `(company_id, created_by_user_id, updated_at)` | | Agent inbox | **partial** `WHERE assignee IS NULL` + `(assignee, status, last_message_at)` | | Admin global | existing status queue idx; avoid `ILIKE %q%` without length floor (min 2–3 chars) | | Messages | always by `ticket_id` + order; do not load all messages for list endpoints | | Notifications | existing unread partial-friendly indexes | | CSAT aggregates | index `created_at`; optional later `(resolved_by_user_id)` | | Pagination | keep default 50 / max 200; return `total` via count query (already) | | N+1 | list endpoints return ticket rows only — no nested messages (already) | Claim and resolve paths use `FOR UPDATE` on the ticket row (already in Reply/Update) — keep that for status transitions. --- ## 13. Implementation phasing (for later agents) 1. Migration: `is_support_agent`, assignee indexes, CSAT table, `resolved_by_user_id`. 2. Middleware `RequireSupportDesk`; expose flag on `/me`. 3. Service visibility + claim/release; tighten admin list filters. 4. CSAT POST + ticket payload field. 5. Staff agents admin API + UI. 6. Email stubs behind flag. 7. Tests: authz matrix (customer / agent A / agent B / platform admin), claim conflict, CSAT once, internal notes leak check. **Out of scope for this design doc:** SLA timers, macros, attachments, full-text search, multi-assignee, customer-visible agent names beyond email, AI auto-reply enablement. --- ## 14. Contracts preserved - Customer ticket JSON fields remain stable; additive `csat` only. - Existing admin paths stay; new claim/release/agents/csat endpoints are additive. - Status/category/priority enums unchanged. - `TryAutoReplyLLM` stub remains refuse-by-default. **BREAKING (intentional, staff-only):** agents lose unrestricted `ListAdmin`/`GetAdmin` visibility — platform admins unaffected. Document in release notes when implementing. --- ## 15. Coordination with sibling agents | Doc | Alignment | |-----|-----------| | [02-current-inventory.md](./02-current-inventory.md) | Confirms gaps this design fills (CSAT, staff-only gate, claim inbox). | | [03-roles-matrix.md](./03-roles-matrix.md) | Staff role name **`support_staff`**. This design’s `users.is_support_agent` is the **MVP column** that implements that role until a unified `staff_role` enum lands. Treat names as aliases. | | [18-performance.md](./18-performance.md) | Prefers assignee/activity indexes in `027_capabilities_support_perf.sql`. **Implementers:** reuse those index names if 027 ships first; do not duplicate. Partial unassigned index from §8 remains additive if not covered. | **ASSUMPTION:** If agent 03 later ships `staff_role TEXT` (`admin|developer|support_staff`), migrate `is_support_agent` → `staff_role = 'support_staff'` and keep `RequireSupportDesk` as `is_platform_admin OR staff_role IN ('support_staff','admin','developer')` per final matrix — do not invent a second parallel flag.