Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
19 KiB
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
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 viaCOALESCE(assignee, actor)on public reply; defaults statusopen|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:
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)
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_claimedornot_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
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):
-- on support_tickets
csat_token_hash BYTEA, -- NULL until resolved
csat_invite_sent_at TIMESTAMPTZ
5.2 Rules
- Only
created_by_user_idmay rate. - Ticket status must be
resolvedorclosed. - 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_idcolumn 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_replycustomer notifications (existing). - Add notification kind
internal_notefor 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:
// 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)
-- +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.
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
- AuthZ server-side — never trust client
scope/ assignee; re-check on every GET/PATCH/reply. - 404 vs 403 on cross-assignee reads for agents (anti-enumeration).
- Internal notes — filter at SQL (
is_internal_note = false) for customer paths; double-filter in TS serializers. - CSAT token — store only SHA-256 hash; single-use invalidate on submit; rate-limit public endpoint.
- Staff grant — platform admin only; audit log recommended (who granted whom).
- PII — list search already ILIKE email/company; keep limit/offset caps; no unbounded export in MVP.
- Claim races — single
UPDATE … WHERE assignee IS NULLin a transaction; no read-modify-write. - Closed tickets — customers cannot reply; agents notes-only or admin reopen.
- AI — keep
TryAutoReplyLLMdisabled; human desk only. - 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)
- Migration:
is_support_agent, assignee indexes, CSAT table,resolved_by_user_id. - Middleware
RequireSupportDesk; expose flag on/me. - Service visibility + claim/release; tighten admin list filters.
- CSAT POST + ticket payload field.
- Staff agents admin API + UI.
- Email stubs behind flag.
- 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
csatonly. - Existing admin paths stay; new claim/release/agents/csat endpoints are additive.
- Status/category/priority enums unchanged.
TryAutoReplyLLMstub 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 | Confirms gaps this design fills (CSAT, staff-only gate, claim inbox). |
| 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 | 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.