Files
descrybe/docs/admin-roles-support/11-support-design.md
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

465 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 23 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 designs `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.