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:
@@ -0,0 +1,521 @@
|
||||
{
|
||||
"$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 }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user