Files
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

19 KiB
Raw Permalink Blame History

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_roleuser|agent|system; is_internal_note
support_notifications In-app bell; kinds: ticket_created, agent_reply, status_changed, user_reply

Statuses: openpendingresolvedclosed (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 / ListAdminall companies, includes internal notes; no assignee filter.
  • ReplyAsAgent — auto-sets assignee via COALESCE(assignee, actor) on public reply; defaults status open|resolvedpending.
  • UpdateAdmin — patch status / priority / assignee / clear_assignee.
  • TryAutoReplyLLMsafe 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=all403.

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_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
resolvedclosed agent assignee, platform admin, or optional job after N days
resolved|pendingopen 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_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:

// 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

  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 Confirms gaps this design fills (CSAT, staff-only gate, claim inbox).
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 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_agentstaff_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.