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.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
@@ -0,0 +1,464 @@
# 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.