{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Descrybe v2 Support Desk API Contract", "version": "11.0.0-design", "status": "design-only", "related": "docs/admin-roles-support/11-support-design.md", "conventions": { "pagination": { "query": ["limit", "offset"], "defaults": { "limit": 50, "max_limit": 200 }, "response": { "total": "number", "limit": "number", "offset": "number" } }, "timestamps": "RFC3339 UTC", "ids": "UUID string", "errors": { "shape": { "error": "string", "code": "string?" }, "auth": { "401": "unauthorized", "403": "forbidden (capability or scope)", "404": "not found (also used for agent cross-assignee hide)" } } }, "enums": { "ticket_status": ["open", "pending", "resolved", "closed"], "ticket_category": ["billing", "bug", "account", "other"], "ticket_priority": ["low", "normal", "high"], "author_role": ["user", "agent", "system"], "notification_kind": [ "ticket_created", "agent_reply", "status_changed", "user_reply", "ticket_claimed", "csat_requested" ], "staff_list_scope": ["inbox", "mine", "unassigned", "all"], "email_stub_event": [ "ticket_created", "agent_reply", "status_resolved", "ticket_claimed", "user_reply" ] }, "types": { "SupportMessage": { "type": "object", "required": ["id", "ticket_id", "author_role", "body", "is_internal_note", "created_at"], "properties": { "id": { "type": "string", "format": "uuid" }, "ticket_id": { "type": "string", "format": "uuid" }, "author_user_id": { "type": ["string", "null"], "format": "uuid" }, "author_role": { "$ref": "#/enums/author_role" }, "body": { "type": "string", "maxLength": 10000 }, "is_internal_note": { "type": "boolean" }, "created_at": { "type": "string", "format": "date-time" } } }, "SupportCsat": { "type": "object", "required": ["score", "created_at"], "properties": { "score": { "type": "integer", "minimum": 1, "maximum": 5 }, "comment": { "type": "string", "maxLength": 2000 }, "created_at": { "type": "string", "format": "date-time" } } }, "SupportTicket": { "type": "object", "required": [ "id", "company_id", "created_by_user_id", "subject", "category", "status", "priority", "created_at", "updated_at" ], "properties": { "id": { "type": "string", "format": "uuid" }, "company_id": { "type": "string", "format": "uuid" }, "created_by_user_id": { "type": "string", "format": "uuid" }, "subject": { "type": "string", "maxLength": 200 }, "category": { "$ref": "#/enums/ticket_category" }, "status": { "$ref": "#/enums/ticket_status" }, "priority": { "$ref": "#/enums/ticket_priority" }, "assignee_admin_user_id": { "type": ["string", "null"], "format": "uuid" }, "resolved_by_user_id": { "type": ["string", "null"], "format": "uuid" }, "last_message_at": { "type": ["string", "null"], "format": "date-time" }, "last_customer_message_at": { "type": ["string", "null"], "format": "date-time" }, "last_agent_message_at": { "type": ["string", "null"], "format": "date-time" }, "resolved_at": { "type": ["string", "null"], "format": "date-time" }, "closed_at": { "type": ["string", "null"], "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" }, "messages": { "type": "array", "items": { "$ref": "#/types/SupportMessage" }, "description": "Present on GET detail only. Customer GETs omit is_internal_note=true." }, "company_name": { "type": "string", "description": "Staff detail/list only" }, "created_by_email": { "type": "string", "description": "Staff detail/list only" }, "csat": { "oneOf": [ { "$ref": "#/types/SupportCsat" }, { "type": "null" } ], "description": "Present when rated; null/omitted when eligible but unrated" }, "csat_eligible": { "type": "boolean", "description": "Customer detail: true when status resolved|closed and no rating yet" } } }, "SupportNotification": { "type": "object", "required": ["id", "user_id", "ticket_id", "kind", "created_at"], "properties": { "id": { "type": "string", "format": "uuid" }, "user_id": { "type": "string", "format": "uuid" }, "ticket_id": { "type": "string", "format": "uuid" }, "message_id": { "type": ["string", "null"], "format": "uuid" }, "kind": { "$ref": "#/enums/notification_kind" }, "read_at": { "type": ["string", "null"], "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" }, "subject": { "type": "string" } } }, "SupportAgent": { "type": "object", "required": ["id", "email", "is_support_agent", "is_platform_admin"], "properties": { "id": { "type": "string", "format": "uuid" }, "email": { "type": "string" }, "name": { "type": "string" }, "is_support_agent": { "type": "boolean" }, "is_platform_admin": { "type": "boolean" }, "is_active": { "type": "boolean" } } } }, "auth": { "customer": ["RequireSession", "RequireCompany"], "support_desk": ["RequireSession", "RequireSupportDesk"], "platform_admin": ["RequireSession", "RequirePlatformAdmin"], "public_csat": ["none", "rate_limited"], "RequireSupportDesk": "DB check: users.is_platform_admin OR users.is_support_agent", "visibility_policy": "queue_plus_claim", "visibility_rules": { "customer": "created_by_user_id = me AND company_id = selected company; never internal notes", "support_agent_list": "scope inbox|mine|unassigned only; never other assignees", "support_agent_get": "assignee=me OR (unassigned AND status in open|pending); else 404", "platform_admin": "unrestricted desk access; scope=all default" } }, "endpoints": [ { "id": "customer.list_tickets", "method": "GET", "path": "/api/support/tickets", "auth": "customer", "query": { "status": { "enum_ref": "ticket_status", "optional": true }, "limit": { "type": "integer" }, "offset": { "type": "integer" } }, "response": { "200": { "tickets": { "type": "array", "items": "SupportTicket" }, "total": "number", "limit": "number", "offset": "number" } }, "notes": "Existing. No messages embedded." }, { "id": "customer.create_ticket", "method": "POST", "path": "/api/support/tickets", "auth": "customer", "body": { "subject": { "type": "string", "required": true, "maxLength": 200 }, "category": { "enum_ref": "ticket_category", "default": "other" }, "priority": { "enum_ref": "ticket_priority", "default": "normal" }, "body": { "type": "string", "required": true, "maxLength": 10000 } }, "response": { "201": "SupportTicket with first message" }, "side_effects": ["status=open", "email_stub:ticket_created optional"] }, { "id": "customer.get_ticket", "method": "GET", "path": "/api/support/tickets/{id}", "auth": "customer", "response": { "200": "SupportTicket + public messages + csat/csat_eligible", "404": "not found" } }, { "id": "customer.reply", "method": "POST", "path": "/api/support/tickets/{id}/messages", "auth": "customer", "body": { "body": { "type": "string", "required": true, "maxLength": 10000 } }, "response": { "200": "SupportTicket", "400": "closed or validation", "404": "not found" }, "side_effects": [ "reopens to open; clears resolved_at", "notifies assignee if set", "email_stub:user_reply optional" ] }, { "id": "customer.submit_csat", "method": "POST", "path": "/api/support/tickets/{id}/csat", "auth": "customer", "status": "new", "body": { "score": { "type": "integer", "required": true, "minimum": 1, "maximum": 5 }, "comment": { "type": "string", "optional": true, "maxLength": 2000 } }, "response": { "201": "SupportCsat", "400": "not eligible status", "404": "not found", "409": "already rated" }, "rules": [ "only ticket owner", "status must be resolved or closed", "one rating per ticket" ] }, { "id": "customer.list_notifications", "method": "GET", "path": "/api/support/notifications", "auth": "customer", "query": { "unread": { "type": "boolean", "optional": true }, "limit": { "type": "integer" }, "offset": { "type": "integer" } }, "response": { "200": { "notifications": { "type": "array", "items": "SupportNotification" }, "total": "number", "unread": "number", "limit": "number", "offset": "number" } } }, { "id": "customer.mark_notification_read", "method": "POST", "path": "/api/support/notifications/{id}/read", "auth": "customer", "response": { "200": { "status": "ok" }, "404": "not found" } }, { "id": "customer.mark_all_notifications_read", "method": "POST", "path": "/api/support/notifications/read-all", "auth": "customer", "response": { "200": { "status": "ok", "updated": "number" } } }, { "id": "staff.list_tickets", "method": "GET", "path": "/api/admin/support/tickets", "auth": "support_desk", "query": { "scope": { "enum_ref": "staff_list_scope", "default_agent": "inbox", "default_platform_admin": "all" }, "status": { "enum_ref": "ticket_status", "optional": true }, "company_id": { "type": "uuid", "optional": true }, "assignee_id": { "type": "uuid", "optional": true, "platform_admin_only": true }, "q": { "type": "string", "optional": true, "minLength_recommended": 2 }, "limit": { "type": "integer" }, "offset": { "type": "integer" } }, "response": { "200": { "tickets": { "type": "array", "items": "SupportTicket" }, "total": "number", "limit": "number", "offset": "number" }, "403": "agent requested scope=all or admin-only filter" }, "indexes": [ "support_tickets_assignee_queue_idx", "support_tickets_unassigned_queue_idx", "support_tickets_admin_queue_idx" ] }, { "id": "staff.get_ticket", "method": "GET", "path": "/api/admin/support/tickets/{id}", "auth": "support_desk", "response": { "200": "SupportTicket including internal notes + csat", "404": "missing or not visible to agent" } }, { "id": "staff.reply", "method": "POST", "path": "/api/admin/support/tickets/{id}/messages", "auth": "support_desk", "body": { "body": { "type": "string", "required": true, "maxLength": 10000 }, "is_internal_note": { "type": "boolean", "default": false }, "status": { "enum_ref": "ticket_status", "optional": true } }, "response": { "200": "SupportTicket", "404": "not visible", "409": "assigned to another agent" }, "side_effects": [ "public reply on unassigned auto-claims actor if still NULL", "default status open|resolved -> pending on public reply", "customer notification + email_stub:agent_reply when public", "sets resolved_by_user_id when transitioning to resolved" ] }, { "id": "staff.update_ticket", "method": "PATCH", "path": "/api/admin/support/tickets/{id}", "auth": "support_desk", "body": { "status": { "enum_ref": "ticket_status", "optional": true }, "priority": { "enum_ref": "ticket_priority", "optional": true }, "assignee_admin_user_id": { "type": "uuid", "optional": true, "platform_admin_only": true }, "clear_assignee": { "type": "boolean", "optional": true, "platform_admin_only": true } }, "response": { "200": "SupportTicket", "403": "agent attempted assign fields or unowned ticket", "404": "not visible" }, "side_effects": [ "resolved -> csat invite notification + email_stub:status_resolved", "status_changed notification to customer" ] }, { "id": "staff.claim", "method": "POST", "path": "/api/admin/support/tickets/{id}/claim", "auth": "support_desk", "status": "new", "body": {}, "response": { "200": "SupportTicket", "404": "not found / not claimable visibility", "409": { "code": "already_claimed|not_claimable" } }, "sql": "UPDATE … SET assignee=$actor WHERE id=$id AND assignee IS NULL AND status IN ('open','pending') RETURNING id", "side_effects": ["email_stub:ticket_claimed optional", "notification ticket_claimed"] }, { "id": "staff.release", "method": "POST", "path": "/api/admin/support/tickets/{id}/release", "auth": "support_desk", "status": "new", "body": {}, "response": { "200": "SupportTicket", "403": "not assignee (unless platform admin)", "404": "not found" }, "rules": ["agent may release only when assignee=me", "platform admin may force-release via PATCH clear_assignee"] }, { "id": "admin.list_agents", "method": "GET", "path": "/api/admin/support/agents", "auth": "platform_admin", "status": "new", "query": { "include_platform_admins": { "type": "boolean", "default": true }, "limit": { "type": "integer" }, "offset": { "type": "integer" } }, "response": { "200": { "agents": { "type": "array", "items": "SupportAgent" }, "total": "number", "limit": "number", "offset": "number" } } }, { "id": "admin.set_agent", "method": "PUT", "path": "/api/admin/support/agents/{userId}", "auth": "platform_admin", "status": "new", "body": { "is_support_agent": { "type": "boolean", "required": true } }, "response": { "200": "SupportAgent", "404": "user not found" }, "notes": "Does not grant is_platform_admin. Revoking does not clear open ticket assignees (admin should reassign)." }, { "id": "public.submit_csat_token", "method": "POST", "path": "/api/public/support/csat", "auth": "public_csat", "status": "new_optional", "body": { "token": { "type": "string", "required": true }, "score": { "type": "integer", "required": true, "minimum": 1, "maximum": 5 }, "comment": { "type": "string", "optional": true, "maxLength": 2000 } }, "response": { "201": "SupportCsat", "400": "invalid token or score", "409": "already rated", "429": "rate limited" }, "security": [ "compare SHA-256(token) to support_tickets.csat_token_hash", "clear hash after success", "no ticket id in response beyond generic ok/csat" ] } ], "session_me_extension": { "path": "/api/auth/me", "additive_fields": { "is_support_agent": { "type": "boolean", "description": "Expose so web nav can show /admin/support for agents without platform admin" } } }, "email_stubs": { "transport": "apps/api/internal/mail.Mailer", "gate": "SUPPORT_EMAIL_ENABLED=true AND Mailer.Enabled()", "pii": "never log To or Body", "events": { "ticket_created": { "to": "configured support inbox or all is_support_agent emails" }, "agent_reply": { "to": "ticket owner" }, "status_resolved": { "to": "ticket owner", "includes": "CSAT deep link" }, "ticket_claimed": { "to": "claiming agent" }, "user_reply": { "to": "assignee if set" } }, "failure_mode": "log and continue; never fail the HTTP mutating request" }, "indexes_required": [ { "name": "support_tickets_assignee_queue_idx", "sql": "CREATE INDEX support_tickets_assignee_queue_idx ON support_tickets (assignee_admin_user_id, status, last_message_at DESC NULLS LAST)" }, { "name": "support_tickets_unassigned_queue_idx", "sql": "CREATE INDEX support_tickets_unassigned_queue_idx ON support_tickets (status, last_message_at DESC NULLS LAST) WHERE assignee_admin_user_id IS NULL" }, { "name": "support_csat_ratings_created_idx", "sql": "CREATE INDEX support_csat_ratings_created_idx ON support_csat_ratings (created_at DESC)" } ], "breaking_changes": [ { "audience": "support_agent_future", "change": "Staff without platform admin cannot use scope=all or read other agents' tickets", "migration": "N/A today — all desk users are platform admins; document when agents ship" } ], "test_matrix": [ { "actor": "customer", "action": "get other user ticket", "expect": 404 }, { "actor": "customer", "action": "see internal note", "expect": "filtered out" }, { "actor": "customer", "action": "csat twice", "expect": 409 }, { "actor": "agent_a", "action": "get agent_b assigned ticket", "expect": 404 }, { "actor": "agent_a", "action": "claim unassigned", "expect": 200 }, { "actor": "agent_b", "action": "claim same after a", "expect": 409 }, { "actor": "agent_a", "action": "scope=all", "expect": 403 }, { "actor": "platform_admin", "action": "scope=all + assign", "expect": 200 }, { "actor": "platform_admin", "action": "PUT agents grant", "expect": 200 }, { "actor": "support_agent", "action": "PUT agents", "expect": 403 } ] }