Files
descrybe/docs/admin-roles-support/11-support-api-contract.json
T

522 lines
18 KiB
JSON
Raw Normal View History

{
"$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 }
]
}