From fb80499858d10654b58c127fb3a0061c7afe4e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Melih=20S=C3=BCnb=C3=BCl?= <67157758+melihsunbul@users.noreply.github.com> Date: Wed, 20 May 2026 15:48:43 +0200 Subject: [PATCH 01/35] first draft of the CRM --- CLAUDE.md | 1 + apps/web/openapi.json | 17478 ++++++++++++++++ apps/web/scripts/backfill-ticket-contacts.ts | 319 + .../src/components/admin/admin-sidebar.tsx | 35 +- .../admin/contacts/contact-create-dialog.tsx | 177 + .../contacts/contact-linked-users-tab.tsx | 235 + .../admin/contacts/contact-list.tsx | 89 + .../admin/contacts/contact-overview-tab.tsx | 202 + .../admin/contacts/contact-tickets-tab.tsx | 71 + .../contacts/organization-contacts-tab.tsx | 93 + .../contacts/organization-create-dialog.tsx | 159 + .../admin/contacts/organization-list.tsx | 87 + .../contacts/organization-overview-tab.tsx | 205 + .../contacts/organization-tickets-tab.tsx | 71 + .../my-ticket-subscriptions-panel.tsx | 189 + .../api-keys/api-key-detail-panel.tsx | 152 + .../settings/api-keys/api-keys-settings.tsx | 265 +- .../api-keys/create-api-key-dialog.tsx | 67 +- .../settings/api-keys/edit-api-key-dialog.tsx | 166 + .../admin/settings/api-keys/scope-picker.tsx | 111 + .../settings/audit/audit-diff-viewer.tsx | 72 + .../settings/audit/audit-event-table.tsx | 198 + .../admin/settings/audit/audit-filter-bar.tsx | 206 + .../settings/inboxes/inbox-channel-dialog.tsx | 278 + .../settings/inboxes/inbox-channels-tab.tsx | 216 + .../settings/inboxes/inbox-create-dialog.tsx | 245 + .../admin/settings/inboxes/inbox-list.tsx | 97 + .../settings/inboxes/inbox-members-tab.tsx | 267 + .../settings/inboxes/inbox-overview-tab.tsx | 297 + .../github/github-add-repo-dialog.tsx | 154 + .../integrations/github/github-config.tsx | 249 +- .../github/github-connection-actions.tsx | 49 +- .../github/github-connection-card.tsx | 133 + .../github/github-sync-history.tsx | 121 + .../github/github-user-mappings.tsx | 152 + .../admin/settings/mcp/mcp-setup-guide.tsx | 33 + .../settings/roles/delete-role-dialog.tsx | 81 + .../settings/roles/role-create-dialog.tsx | 158 + .../settings/roles/role-detail-panel.tsx | 80 + .../admin/settings/roles/role-edit-dialog.tsx | 111 + .../admin/settings/roles/role-list.tsx | 50 + .../settings/roles/role-permission-matrix.tsx | 88 + .../admin/settings/roles/roles-settings.tsx | 59 + .../routing/routing-actions-builder.tsx | 176 + .../routing/routing-conditions-builder.tsx | 274 + .../routing/routing-rule-editor-sheet.tsx | 279 + .../settings/routing/routing-rule-list.tsx | 259 + .../admin/settings/settings-nav.tsx | 36 + .../settings/sla/business-hours-dialog.tsx | 333 + .../settings/sla/business-hours-list.tsx | 166 + .../settings/sla/sla-escalation-dialog.tsx | 298 + .../settings/sla/sla-escalations-tab.tsx | 213 + .../settings/sla/sla-policy-create-dialog.tsx | 305 + .../admin/settings/sla/sla-policy-list.tsx | 148 + .../settings/sla/sla-policy-overview-tab.tsx | 264 + .../admin/settings/sla/sla-targets-tab.tsx | 90 + .../admin/settings/sla/sla-tick-trigger.tsx | 38 + .../team/manage-principal-roles-dialog.tsx | 221 + .../admin/settings/team/member-actions.tsx | 15 + .../settings/teams/team-create-dialog.tsx | 183 + .../admin/settings/teams/team-list.tsx | 109 + .../admin/settings/teams/team-members-tab.tsx | 264 + .../settings/teams/team-overview-tab.tsx | 209 + .../webhook-event-picker.defaults.test.tsx | 72 + .../webhooks/create-webhook-dialog.tsx | 49 +- .../settings/webhooks/edit-webhook-dialog.tsx | 49 +- .../settings/webhooks/test-webhook-dialog.tsx | 145 + .../webhooks/webhook-deliveries-drawer.tsx | 78 + .../webhooks/webhook-deliveries-table.tsx | 242 + .../webhooks/webhook-event-picker.tsx | 163 + .../webhooks/webhook-inbox-picker.tsx | 84 + .../settings/webhooks/webhooks-settings.tsx | 27 +- .../admin/shared/contact-picker.tsx | 43 + .../components/admin/shared/inbox-picker.tsx | 69 + apps/web/src/components/admin/shared/index.ts | 16 + .../components/admin/shared/org-picker.tsx | 41 + .../admin/shared/permission-gate.tsx | 33 + .../admin/shared/principal-picker.tsx | 202 + .../admin/shared/resource-picker.tsx | 165 + .../admin/shared/route-error-boundary.tsx | 44 + .../admin/shared/scope-selector.tsx | 68 + .../components/admin/shared/status-picker.tsx | 43 + .../components/admin/shared/team-picker.tsx | 77 + .../admin/tickets/sla-clock-chip.tsx | 90 + .../tickets/ticket-activity-timeline.tsx | 77 + .../admin/tickets/ticket-channel-icon.tsx | 29 + .../admin/tickets/ticket-detail-header.tsx | 148 + .../admin/tickets/ticket-linked-issues.tsx | 84 + .../tickets/ticket-participants-list.tsx | 163 + .../admin/tickets/ticket-priority-chip.tsx | 32 + .../admin/tickets/ticket-properties-panel.tsx | 296 + .../admin/tickets/ticket-queue-sidebar.tsx | 123 + .../admin/tickets/ticket-queue-table.tsx | 317 + .../admin/tickets/ticket-shares-panel.tsx | 127 + .../admin/tickets/ticket-sla-panel.tsx | 49 + .../admin/tickets/ticket-status-pill.tsx | 35 + .../tickets/ticket-subscription-menu.tsx | 210 + .../admin/tickets/ticket-thread-composer.tsx | 167 + .../admin/tickets/ticket-thread-feed.tsx | 101 + apps/web/src/components/auth/auth-dialog.tsx | 7 +- .../auth/portal-auth-form-inline.tsx | 12 + .../notifications/notification-item.tsx | 12 + .../notifications/notification-type-config.ts | 57 + .../__tests__/portal-header-nav.test.ts | 19 +- .../components/public/portal-header-nav.ts | 26 +- .../src/components/public/portal-header.tsx | 2 +- .../tickets/portal-ticket-detail-header.tsx | 41 + .../tickets/portal-ticket-reply-composer.tsx | 83 + .../public/tickets/portal-ticket-row.tsx | 38 + .../tickets/portal-ticket-status-filter.tsx | 44 + .../tickets/portal-ticket-thread-feed.tsx | 216 + .../__tests__/widget-support-card.test.tsx | 30 + .../__tests__/widget-support-new.test.tsx | 122 + .../widget/widget-home-animated.tsx | 4 + .../components/widget/widget-support-card.tsx | 32 + .../widget/widget-support-detail.tsx | 325 + .../components/widget/widget-support-list.tsx | 139 + .../components/widget/widget-support-new.tsx | 225 + .../src/lib/client/hooks/use-audit-queries.ts | 44 + .../src/lib/client/hooks/use-authz-queries.ts | 49 + .../lib/client/hooks/use-inboxes-queries.ts | 67 + .../client/hooks/use-notifications-queries.ts | 17 +- .../client/hooks/use-orgs-contacts-queries.ts | 87 + .../lib/client/hooks/use-routing-queries.ts | 37 + .../src/lib/client/hooks/use-sla-queries.ts | 96 + .../src/lib/client/hooks/use-teams-queries.ts | 41 + .../hooks/use-ticket-subscriptions-queries.ts | 24 + .../lib/client/hooks/use-tickets-queries.ts | 103 + .../hooks/use-webhook-deliveries-queries.ts | 40 + apps/web/src/lib/client/mutations/index.ts | 2 + .../src/lib/client/mutations/integrations.ts | 32 + apps/web/src/lib/client/queries/admin.ts | 36 +- apps/web/src/lib/client/queries/audit.ts | 56 + .../src/lib/client/queries/business-hours.ts | 21 + apps/web/src/lib/client/queries/contacts.ts | 64 + apps/web/src/lib/client/queries/inboxes.ts | 40 + .../src/lib/client/queries/organizations.ts | 31 + .../src/lib/client/queries/portal-tickets.ts | 100 + .../src/lib/client/queries/routing-rules.ts | 23 + apps/web/src/lib/client/queries/signals.ts | 11 +- apps/web/src/lib/client/queries/sla.ts | 31 + apps/web/src/lib/client/queries/teams.ts | 30 + apps/web/src/lib/client/queries/tickets.ts | 99 + .../lib/client/queries/webhook-deliveries.ts | 48 + .../client/utils/handle-ticket-conflict.ts | 35 + .../widget/__tests__/tickets-api.test.ts | 131 + apps/web/src/lib/client/widget/tickets-api.ts | 199 + .../auth/__tests__/link-contact.test.ts | 116 + .../__tests__/widget-link-contact.test.ts | 103 + apps/web/src/lib/server/auth/index.ts | 38 +- apps/web/src/lib/server/auth/link-contact.ts | 88 + apps/web/src/lib/server/db.ts | 89 +- .../domains/api-keys/api-key.service.ts | 121 +- .../server/domains/api-keys/api-key.types.ts | 18 + .../src/lib/server/domains/api-keys/index.ts | 8 +- .../server/domains/api/__tests__/auth.test.ts | 48 +- .../api/__tests__/scope-assertions.test.ts | 116 + apps/web/src/lib/server/domains/api/auth.ts | 110 +- .../web/src/lib/server/domains/api/openapi.ts | 15 + .../lib/server/domains/api/schemas/admin.ts | 401 + .../lib/server/domains/api/schemas/index.ts | 5 + .../domains/api/schemas/support-config.ts | 402 + .../domains/api/schemas/ticket-statuses.ts | 192 + .../lib/server/domains/api/schemas/tickets.ts | 742 + .../lib/server/domains/audit/audit.context.ts | 45 + .../lib/server/domains/audit/audit.queries.ts | 144 + .../lib/server/domains/audit/audit.service.ts | 82 + .../web/src/lib/server/domains/audit/index.ts | 15 + .../domains/authz/__tests__/authz.test.ts | 221 + .../server/domains/authz/authz.permissions.ts | 180 + .../lib/server/domains/authz/authz.scopes.ts | 72 + .../lib/server/domains/authz/authz.service.ts | 230 + .../web/src/lib/server/domains/authz/index.ts | 18 + .../lib/server/domains/authz/role.service.ts | 418 + .../__tests__/inbox.service.events.test.ts | 210 + .../server/domains/inboxes/inbox.channels.ts | 128 + .../domains/inboxes/inbox.memberships.ts | 94 + .../server/domains/inboxes/inbox.service.ts | 216 + .../src/lib/server/domains/inboxes/index.ts | 6 + .../server/domains/inboxes/routing.engine.ts | 165 + .../server/domains/inboxes/routing.service.ts | 156 + .../server/domains/inboxes/routing.types.ts | 84 + .../notifications/notification.service.ts | 3 + .../notifications/notification.types.ts | 14 +- .../__tests__/contact.service.events.test.ts | 268 + .../__tests__/contact.service.test.ts | 160 + .../organizations/__tests__/normalize.test.ts | 67 + .../organization.service.events.test.ts | 191 + .../__tests__/organization.service.test.ts | 127 + .../domains/organizations/contact.service.ts | 411 + .../lib/server/domains/organizations/index.ts | 8 + .../server/domains/organizations/normalize.ts | 62 + .../organizations/organization.service.ts | 312 + .../settings/__tests__/widget-config.test.ts | 12 + .../domains/settings/settings.service.ts | 2 + .../server/domains/settings/settings.types.ts | 20 +- .../domains/settings/settings.widget.ts | 1 + .../sla/__tests__/business-hours.calc.test.ts | 225 + .../server/domains/sla/business-hours.calc.ts | 403 + .../domains/sla/business-hours.service.ts | 135 + apps/web/src/lib/server/domains/sla/index.ts | 10 + .../src/lib/server/domains/sla/sla.engine.ts | 429 + .../lib/server/domains/sla/sla.escalations.ts | 250 + .../domains/sla/sla.policies.service.ts | 357 + .../src/lib/server/domains/sla/sla.queries.ts | 54 + .../src/lib/server/domains/sla/sla.types.ts | 36 + .../__tests__/team.service.events.test.ts | 181 + .../web/src/lib/server/domains/teams/index.ts | 15 + .../lib/server/domains/teams/team.service.ts | 208 + .../ticket-statuses.service.events.test.ts | 179 + .../__tests__/ticket.attachments.test.ts | 155 + .../tickets/__tests__/ticket.bulk.test.ts | 100 + .../__tests__/ticket.notifications.test.ts | 189 + .../__tests__/ticket.participants.test.ts | 107 + .../__tests__/ticket.permissions.test.ts | 173 + .../__tests__/ticket.portal-query.test.ts | 290 + .../tickets/__tests__/ticket.query.test.ts | 166 + .../__tests__/ticket.recipients.test.ts | 161 + .../tickets/__tests__/ticket.service.test.ts | 453 + .../tickets/__tests__/ticket.share.test.ts | 139 + .../__tests__/ticket.subscriptions.test.ts | 165 + .../tickets/__tests__/ticket.threads.test.ts | 183 + .../tickets/__tests__/tiptap-text.test.ts | 39 + .../src/lib/server/domains/tickets/index.ts | 12 + .../tickets/ticket-statuses.service.ts | 191 + .../server/domains/tickets/ticket.activity.ts | 62 + .../domains/tickets/ticket.attachments.ts | 151 + .../lib/server/domains/tickets/ticket.bulk.ts | 195 + .../domains/tickets/ticket.notifications.ts | 514 + .../domains/tickets/ticket.participants.ts | 204 + .../domains/tickets/ticket.permissions.ts | 71 + .../domains/tickets/ticket.portal-query.ts | 404 + .../server/domains/tickets/ticket.query.ts | 262 + .../domains/tickets/ticket.recipients.ts | 106 + .../server/domains/tickets/ticket.service.ts | 656 + .../server/domains/tickets/ticket.share.ts | 188 + .../domains/tickets/ticket.subscriptions.ts | 379 + .../domains/tickets/ticket.take-return.ts | 34 + .../server/domains/tickets/ticket.threads.ts | 338 + .../lib/server/domains/tickets/tiptap-text.ts | 50 + .../__tests__/webhook.deliveries.test.ts | 73 + .../webhook.operator-actions.test.ts | 180 + .../domains/webhooks/webhook.deliveries.ts | 148 + .../webhooks/webhook.operator-actions.ts | 144 + .../domains/webhooks/webhook.service.ts | 3 + .../server/domains/webhooks/webhook.types.ts | 3 + .../__tests__/dispatch-enrichment.test.ts | 204 + .../sample-payloads.contact-org.test.ts | 64 + .../events/__tests__/sample-payloads.test.ts | 40 + .../__tests__/targets-inbox-filter.test.ts | 218 + .../__tests__/updated-payload-shape.test.ts | 53 + .../__tests__/webhook-event-config.test.ts | 41 + .../webhook-handler.deliveries.test.ts | 160 + apps/web/src/lib/server/events/dispatch.ts | 173 +- .../src/lib/server/events/handlers/webhook.ts | 112 +- .../events/integrations/webhook/constants.ts | 248 +- apps/web/src/lib/server/events/process.ts | 33 +- .../src/lib/server/events/sample-payloads.ts | 459 + apps/web/src/lib/server/events/targets.ts | 58 +- apps/web/src/lib/server/events/types.ts | 532 + .../functions/__tests__/uploads.test.ts | 1 + .../functions/__tests__/widget-auth.test.ts | 49 + apps/web/src/lib/server/functions/api-keys.ts | 163 +- apps/web/src/lib/server/functions/audit.ts | 80 + .../src/lib/server/functions/auth-helpers.ts | 98 + apps/web/src/lib/server/functions/authz.ts | 35 + apps/web/src/lib/server/functions/contacts.ts | 186 + .../src/lib/server/functions/feature-flags.ts | 1 + apps/web/src/lib/server/functions/inboxes.ts | 292 + .../src/lib/server/functions/integrations.ts | 146 +- .../src/lib/server/functions/notifications.ts | 230 +- .../src/lib/server/functions/organizations.ts | 137 + .../lib/server/functions/portal-tickets.ts | 290 + .../src/lib/server/functions/principals.ts | 119 + apps/web/src/lib/server/functions/roles.ts | 149 + apps/web/src/lib/server/functions/routing.ts | 126 + apps/web/src/lib/server/functions/settings.ts | 5 + apps/web/src/lib/server/functions/sla.ts | 367 + apps/web/src/lib/server/functions/teams.ts | 186 + apps/web/src/lib/server/functions/tickets.ts | 589 + .../server/functions/webhook-deliveries.ts | 83 + apps/web/src/lib/server/functions/webhooks.ts | 70 + .../src/lib/server/functions/widget-auth.ts | 45 +- .../server/integrations/github/functions.ts | 98 +- .../lib/server/integrations/github/hook.ts | 624 +- .../lib/server/integrations/github/inbound.ts | 31 +- .../lib/server/integrations/github/oauth.ts | 2 +- .../integrations/github/ticket-inbound.ts | 477 + .../integrations/github/ticket-message.ts | 102 + .../lib/server/integrations/github/types.ts | 50 + .../github/webhook-registration.ts | 2 +- .../integrations/inbound-webhook-handler.ts | 149 +- .../lib/server/integrations/oauth-handlers.ts | 18 +- apps/web/src/lib/server/integrations/save.ts | 83 +- .../lib/server/mcp/__tests__/handler.test.ts | 278 +- apps/web/src/lib/server/mcp/handler.ts | 13 +- apps/web/src/lib/server/mcp/server.ts | 107 + apps/web/src/lib/server/mcp/tools.ts | 1538 +- apps/web/src/lib/server/mcp/types.ts | 5 + apps/web/src/lib/server/widget/cors.ts | 50 + .../src/lib/server/widget/ticketing-gate.ts | 15 + apps/web/src/lib/shared/webhook-events.ts | 2 + apps/web/src/lib/shared/widget/types.ts | 25 +- apps/web/src/locales/ar.json | 572 +- apps/web/src/locales/de.json | 572 +- apps/web/src/locales/en.json | 657 +- apps/web/src/locales/es.json | 572 +- apps/web/src/locales/fr.json | 572 +- apps/web/src/locales/ru.json | 572 +- .../[.]well-known.oauth-protected-resource.ts | 5 + .../src/routes/_portal/tickets.$ticketId.tsx | 183 + apps/web/src/routes/_portal/tickets.index.tsx | 89 + apps/web/src/routes/admin.tsx | 11 + apps/web/src/routes/admin/contacts.index.tsx | 10 + .../routes/admin/contacts.organizations.tsx | 73 + ...ontacts.organizations_.$organizationId.tsx | 91 + apps/web/src/routes/admin/contacts.people.tsx | 73 + .../admin/contacts.people_.$contactId.tsx | 92 + apps/web/src/routes/admin/contacts.tsx | 49 + apps/web/src/routes/admin/feedback.index.tsx | 17 +- apps/web/src/routes/admin/notifications.tsx | 64 +- apps/web/src/routes/admin/settings.audit.tsx | 46 + .../routes/admin/settings.business-hours.tsx | 54 + .../web/src/routes/admin/settings.inboxes.tsx | 51 + .../admin/settings.inboxes_.$inboxId.tsx | 94 + .../routes/admin/settings.portal-widget.tsx | 34 + apps/web/src/routes/admin/settings.roles.tsx | 49 + .../routes/admin/settings.routing-rules.tsx | 99 + apps/web/src/routes/admin/settings.sla.tsx | 61 + .../routes/admin/settings.sla_.$policyId.tsx | 94 + apps/web/src/routes/admin/settings.teams.tsx | 54 + .../routes/admin/settings.teams_.$teamId.tsx | 95 + .../admin/settings/integrations/github.tsx | 65 +- .../src/routes/admin/tickets.$ticketId.tsx | 179 + apps/web/src/routes/admin/tickets.index.tsx | 138 + apps/web/src/routes/admin/tickets.new.tsx | 264 + apps/web/src/routes/admin/tickets.tsx | 68 + .../api-keys/$apiKeyId.acknowledge-legacy.ts | 56 + .../api/v1/api-keys/$apiKeyId.rotate.ts | 57 + .../src/routes/api/v1/api-keys/$apiKeyId.ts | 125 + apps/web/src/routes/api/v1/api-keys/index.ts | 99 + .../src/routes/api/v1/audit-events/index.ts | 71 + .../src/routes/api/v1/business-hours/$id.ts | 107 + .../src/routes/api/v1/business-hours/index.ts | 83 + .../api/v1/contacts/$contactId.links.ts | 108 + .../src/routes/api/v1/contacts/$contactId.ts | 111 + apps/web/src/routes/api/v1/contacts/index.ts | 101 + .../v1/help-center/__tests__/articles.test.ts | 38 +- .../help-center/__tests__/categories.test.ts | 55 +- .../inboxes/$inboxId.channels.$channelId.ts | 67 + .../api/v1/inboxes/$inboxId.channels.ts | 69 + .../$inboxId.memberships.$membershipId.ts | 71 + .../api/v1/inboxes/$inboxId.memberships.ts | 70 + .../web/src/routes/api/v1/inboxes/$inboxId.ts | 93 + apps/web/src/routes/api/v1/inboxes/index.ts | 74 + .../src/routes/api/v1/internal/sla-tick.ts | 45 + .../organizations/$organizationId.contacts.ts | 47 + .../api/v1/organizations/$organizationId.ts | 107 + .../src/routes/api/v1/organizations/index.ts | 90 + .../routes/api/v1/routing-rules/$ruleId.ts | 88 + .../src/routes/api/v1/routing-rules/index.ts | 72 + .../routes/api/v1/routing-rules/reorder.ts | 45 + .../$policyId.escalation-rules.$ruleId.ts | 72 + .../$policyId.escalation-rules.ts | 75 + .../api/v1/sla-policies/$policyId.targets.ts | 68 + .../routes/api/v1/sla-policies/$policyId.ts | 98 + .../src/routes/api/v1/sla-policies/index.ts | 73 + .../api/v1/ticket-statuses/$statusId.ts | 124 + .../routes/api/v1/ticket-statuses/index.ts | 104 + .../api/v1/tickets/$ticketId.activity.ts | 97 + .../routes/api/v1/tickets/$ticketId.assign.ts | 68 + .../$ticketId.participants.$participantId.ts | 54 + .../api/v1/tickets/$ticketId.participants.ts | 97 + .../api/v1/tickets/$ticketId.restore.ts | 29 + .../routes/api/v1/tickets/$ticketId.return.ts | 52 + .../v1/tickets/$ticketId.shares.$shareId.ts | 50 + .../routes/api/v1/tickets/$ticketId.shares.ts | 94 + .../routes/api/v1/tickets/$ticketId.sla.ts | 41 + .../routes/api/v1/tickets/$ticketId.take.ts | 51 + ...ads.$threadId.attachments.$attachmentId.ts | 101 + ...$ticketId.threads.$threadId.attachments.ts | 194 + .../v1/tickets/$ticketId.threads.$threadId.ts | 148 + .../api/v1/tickets/$ticketId.threads.ts | 113 + .../api/v1/tickets/$ticketId.transition.ts | 65 + .../src/routes/api/v1/tickets/$ticketId.ts | 124 + .../src/routes/api/v1/tickets/bulk.assign.ts | 59 + .../api/v1/tickets/bulk.change-inbox.ts | 54 + .../routes/api/v1/tickets/bulk.transition.ts | 54 + apps/web/src/routes/api/v1/tickets/index.ts | 133 + ...hookId.deliveries.$deliveryId.redeliver.ts | 44 + .../api/v1/webhooks/$webhookId.deliveries.ts | 107 + .../routes/api/v1/webhooks/$webhookId.test.ts | 53 + .../routes/api/v1/webhooks/sample-payloads.ts | 26 + .../tickets.$ticketId.replies.test.ts | 129 + .../tickets.$ticketId.resolve.test.ts | 200 + .../__tests__/tickets.$ticketId.test.ts | 192 + .../api/widget/__tests__/tickets.test.ts | 269 + .../__tests__/widget-ticket-fixtures.ts | 49 + .../src/routes/api/widget/config[.]json.ts | 2 + apps/web/src/routes/api/widget/identify.ts | 12 + apps/web/src/routes/api/widget/session.ts | 5 + .../api/widget/tickets.$ticketId.reopen.ts | 166 + .../api/widget/tickets.$ticketId.replies.ts | 93 + .../api/widget/tickets.$ticketId.resolve.ts | 126 + ...$ticketId.threads.$threadId.attachments.ts | 174 + .../routes/api/widget/tickets.$ticketId.ts | 222 + apps/web/src/routes/api/widget/tickets.ts | 227 + apps/web/src/routes/api/widget/upload.ts | 3 +- apps/web/src/routes/oauth/consent.tsx | 30 +- apps/web/src/routes/widget/index.tsx | 70 +- apps/web/vite.config.ts | 1 + deploy/self-hosted/README.md | 60 + docker-compose.yml | 2 +- .../0048_ticketing_rbac_foundation.sql | 306 + .../drizzle/0049_organizations_contacts.sql | 106 + packages/db/drizzle/0050_ticket_core.sql | 291 + packages/db/drizzle/0051_inboxes_routing.sql | 181 + packages/db/drizzle/0052_sla_escalations.sql | 264 + packages/db/drizzle/0053_api_key_scopes.sql | 46 + ...t_subscriptions_and_webhook_deliveries.sql | 114 + ...055_webhook_deliveries_request_payload.sql | 14 + .../db/drizzle/0056_webhook_inbox_filter.sql | 13 + .../db/drizzle/0057_github_ticket_sync.sql | 42 + .../db/drizzle/0058_integration_sync_log.sql | 30 + packages/db/drizzle/0059_old_cloak.sql | 17 + packages/db/drizzle/meta/0057_snapshot.json | 12472 +++++++++++ packages/db/drizzle/meta/0059_snapshot.json | 12618 +++++++++++ packages/db/drizzle/meta/_journal.json | 86 +- packages/db/src/schema/api-keys.ts | 39 +- packages/db/src/schema/audit-events.ts | 70 + packages/db/src/schema/inboxes.ts | 163 + packages/db/src/schema/index.ts | 30 + .../db/src/schema/integration-sync-log.ts | 51 + .../src/schema/integration-user-mappings.ts | 52 + packages/db/src/schema/integrations.ts | 11 +- packages/db/src/schema/notifications.ts | 10 + packages/db/src/schema/organizations.ts | 156 + packages/db/src/schema/roles.ts | 118 + packages/db/src/schema/routing-rules.ts | 57 + packages/db/src/schema/sla.ts | 395 + packages/db/src/schema/teams.ts | 80 + .../db/src/schema/ticket-external-links.ts | 65 + packages/db/src/schema/ticket-statuses.ts | 99 + .../db/src/schema/ticket-subscriptions.ts | 72 + packages/db/src/schema/tickets.ts | 408 + packages/db/src/schema/webhook-deliveries.ts | 74 + packages/db/src/schema/webhooks.ts | 2 + packages/db/src/types.ts | 118 + packages/email/src/index.ts | 70 + .../src/templates/tickets/ticket-event.tsx | 98 + packages/ids/src/index.ts | 35 + packages/ids/src/prefixes.ts | 41 + packages/ids/src/types.ts | 148 + packages/widget/CHANGELOG.md | 21 + packages/widget/README.md | 26 +- packages/widget/__tests__/tickets.test.ts | 164 + packages/widget/package.json | 2 +- packages/widget/src/core/sdk.ts | 10 +- packages/widget/src/index.ts | 8 + packages/widget/src/types.ts | 30 +- 460 files changed, 99169 insertions(+), 2043 deletions(-) create mode 100644 apps/web/openapi.json create mode 100644 apps/web/scripts/backfill-ticket-contacts.ts create mode 100644 apps/web/src/components/admin/contacts/contact-create-dialog.tsx create mode 100644 apps/web/src/components/admin/contacts/contact-linked-users-tab.tsx create mode 100644 apps/web/src/components/admin/contacts/contact-list.tsx create mode 100644 apps/web/src/components/admin/contacts/contact-overview-tab.tsx create mode 100644 apps/web/src/components/admin/contacts/contact-tickets-tab.tsx create mode 100644 apps/web/src/components/admin/contacts/organization-contacts-tab.tsx create mode 100644 apps/web/src/components/admin/contacts/organization-create-dialog.tsx create mode 100644 apps/web/src/components/admin/contacts/organization-list.tsx create mode 100644 apps/web/src/components/admin/contacts/organization-overview-tab.tsx create mode 100644 apps/web/src/components/admin/contacts/organization-tickets-tab.tsx create mode 100644 apps/web/src/components/admin/notifications/my-ticket-subscriptions-panel.tsx create mode 100644 apps/web/src/components/admin/settings/api-keys/api-key-detail-panel.tsx create mode 100644 apps/web/src/components/admin/settings/api-keys/edit-api-key-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/api-keys/scope-picker.tsx create mode 100644 apps/web/src/components/admin/settings/audit/audit-diff-viewer.tsx create mode 100644 apps/web/src/components/admin/settings/audit/audit-event-table.tsx create mode 100644 apps/web/src/components/admin/settings/audit/audit-filter-bar.tsx create mode 100644 apps/web/src/components/admin/settings/inboxes/inbox-channel-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/inboxes/inbox-channels-tab.tsx create mode 100644 apps/web/src/components/admin/settings/inboxes/inbox-create-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/inboxes/inbox-list.tsx create mode 100644 apps/web/src/components/admin/settings/inboxes/inbox-members-tab.tsx create mode 100644 apps/web/src/components/admin/settings/inboxes/inbox-overview-tab.tsx create mode 100644 apps/web/src/components/admin/settings/integrations/github/github-add-repo-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/integrations/github/github-connection-card.tsx create mode 100644 apps/web/src/components/admin/settings/integrations/github/github-sync-history.tsx create mode 100644 apps/web/src/components/admin/settings/integrations/github/github-user-mappings.tsx create mode 100644 apps/web/src/components/admin/settings/roles/delete-role-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/roles/role-create-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/roles/role-detail-panel.tsx create mode 100644 apps/web/src/components/admin/settings/roles/role-edit-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/roles/role-list.tsx create mode 100644 apps/web/src/components/admin/settings/roles/role-permission-matrix.tsx create mode 100644 apps/web/src/components/admin/settings/roles/roles-settings.tsx create mode 100644 apps/web/src/components/admin/settings/routing/routing-actions-builder.tsx create mode 100644 apps/web/src/components/admin/settings/routing/routing-conditions-builder.tsx create mode 100644 apps/web/src/components/admin/settings/routing/routing-rule-editor-sheet.tsx create mode 100644 apps/web/src/components/admin/settings/routing/routing-rule-list.tsx create mode 100644 apps/web/src/components/admin/settings/sla/business-hours-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/sla/business-hours-list.tsx create mode 100644 apps/web/src/components/admin/settings/sla/sla-escalation-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/sla/sla-escalations-tab.tsx create mode 100644 apps/web/src/components/admin/settings/sla/sla-policy-create-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/sla/sla-policy-list.tsx create mode 100644 apps/web/src/components/admin/settings/sla/sla-policy-overview-tab.tsx create mode 100644 apps/web/src/components/admin/settings/sla/sla-targets-tab.tsx create mode 100644 apps/web/src/components/admin/settings/sla/sla-tick-trigger.tsx create mode 100644 apps/web/src/components/admin/settings/team/manage-principal-roles-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/teams/team-create-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/teams/team-list.tsx create mode 100644 apps/web/src/components/admin/settings/teams/team-members-tab.tsx create mode 100644 apps/web/src/components/admin/settings/teams/team-overview-tab.tsx create mode 100644 apps/web/src/components/admin/settings/webhooks/__tests__/webhook-event-picker.defaults.test.tsx create mode 100644 apps/web/src/components/admin/settings/webhooks/test-webhook-dialog.tsx create mode 100644 apps/web/src/components/admin/settings/webhooks/webhook-deliveries-drawer.tsx create mode 100644 apps/web/src/components/admin/settings/webhooks/webhook-deliveries-table.tsx create mode 100644 apps/web/src/components/admin/settings/webhooks/webhook-event-picker.tsx create mode 100644 apps/web/src/components/admin/settings/webhooks/webhook-inbox-picker.tsx create mode 100644 apps/web/src/components/admin/shared/contact-picker.tsx create mode 100644 apps/web/src/components/admin/shared/inbox-picker.tsx create mode 100644 apps/web/src/components/admin/shared/index.ts create mode 100644 apps/web/src/components/admin/shared/org-picker.tsx create mode 100644 apps/web/src/components/admin/shared/permission-gate.tsx create mode 100644 apps/web/src/components/admin/shared/principal-picker.tsx create mode 100644 apps/web/src/components/admin/shared/resource-picker.tsx create mode 100644 apps/web/src/components/admin/shared/route-error-boundary.tsx create mode 100644 apps/web/src/components/admin/shared/scope-selector.tsx create mode 100644 apps/web/src/components/admin/shared/status-picker.tsx create mode 100644 apps/web/src/components/admin/shared/team-picker.tsx create mode 100644 apps/web/src/components/admin/tickets/sla-clock-chip.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-activity-timeline.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-channel-icon.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-detail-header.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-linked-issues.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-participants-list.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-priority-chip.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-properties-panel.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-queue-sidebar.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-queue-table.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-shares-panel.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-sla-panel.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-status-pill.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-subscription-menu.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-thread-composer.tsx create mode 100644 apps/web/src/components/admin/tickets/ticket-thread-feed.tsx create mode 100644 apps/web/src/components/public/tickets/portal-ticket-detail-header.tsx create mode 100644 apps/web/src/components/public/tickets/portal-ticket-reply-composer.tsx create mode 100644 apps/web/src/components/public/tickets/portal-ticket-row.tsx create mode 100644 apps/web/src/components/public/tickets/portal-ticket-status-filter.tsx create mode 100644 apps/web/src/components/public/tickets/portal-ticket-thread-feed.tsx create mode 100644 apps/web/src/components/widget/__tests__/widget-support-card.test.tsx create mode 100644 apps/web/src/components/widget/__tests__/widget-support-new.test.tsx create mode 100644 apps/web/src/components/widget/widget-support-card.tsx create mode 100644 apps/web/src/components/widget/widget-support-detail.tsx create mode 100644 apps/web/src/components/widget/widget-support-list.tsx create mode 100644 apps/web/src/components/widget/widget-support-new.tsx create mode 100644 apps/web/src/lib/client/hooks/use-audit-queries.ts create mode 100644 apps/web/src/lib/client/hooks/use-authz-queries.ts create mode 100644 apps/web/src/lib/client/hooks/use-inboxes-queries.ts create mode 100644 apps/web/src/lib/client/hooks/use-orgs-contacts-queries.ts create mode 100644 apps/web/src/lib/client/hooks/use-routing-queries.ts create mode 100644 apps/web/src/lib/client/hooks/use-sla-queries.ts create mode 100644 apps/web/src/lib/client/hooks/use-teams-queries.ts create mode 100644 apps/web/src/lib/client/hooks/use-ticket-subscriptions-queries.ts create mode 100644 apps/web/src/lib/client/hooks/use-tickets-queries.ts create mode 100644 apps/web/src/lib/client/hooks/use-webhook-deliveries-queries.ts create mode 100644 apps/web/src/lib/client/queries/audit.ts create mode 100644 apps/web/src/lib/client/queries/business-hours.ts create mode 100644 apps/web/src/lib/client/queries/contacts.ts create mode 100644 apps/web/src/lib/client/queries/inboxes.ts create mode 100644 apps/web/src/lib/client/queries/organizations.ts create mode 100644 apps/web/src/lib/client/queries/portal-tickets.ts create mode 100644 apps/web/src/lib/client/queries/routing-rules.ts create mode 100644 apps/web/src/lib/client/queries/sla.ts create mode 100644 apps/web/src/lib/client/queries/teams.ts create mode 100644 apps/web/src/lib/client/queries/tickets.ts create mode 100644 apps/web/src/lib/client/queries/webhook-deliveries.ts create mode 100644 apps/web/src/lib/client/utils/handle-ticket-conflict.ts create mode 100644 apps/web/src/lib/client/widget/__tests__/tickets-api.test.ts create mode 100644 apps/web/src/lib/client/widget/tickets-api.ts create mode 100644 apps/web/src/lib/server/auth/__tests__/link-contact.test.ts create mode 100644 apps/web/src/lib/server/auth/__tests__/widget-link-contact.test.ts create mode 100644 apps/web/src/lib/server/auth/link-contact.ts create mode 100644 apps/web/src/lib/server/domains/api/__tests__/scope-assertions.test.ts create mode 100644 apps/web/src/lib/server/domains/api/schemas/admin.ts create mode 100644 apps/web/src/lib/server/domains/api/schemas/support-config.ts create mode 100644 apps/web/src/lib/server/domains/api/schemas/ticket-statuses.ts create mode 100644 apps/web/src/lib/server/domains/api/schemas/tickets.ts create mode 100644 apps/web/src/lib/server/domains/audit/audit.context.ts create mode 100644 apps/web/src/lib/server/domains/audit/audit.queries.ts create mode 100644 apps/web/src/lib/server/domains/audit/audit.service.ts create mode 100644 apps/web/src/lib/server/domains/audit/index.ts create mode 100644 apps/web/src/lib/server/domains/authz/__tests__/authz.test.ts create mode 100644 apps/web/src/lib/server/domains/authz/authz.permissions.ts create mode 100644 apps/web/src/lib/server/domains/authz/authz.scopes.ts create mode 100644 apps/web/src/lib/server/domains/authz/authz.service.ts create mode 100644 apps/web/src/lib/server/domains/authz/index.ts create mode 100644 apps/web/src/lib/server/domains/authz/role.service.ts create mode 100644 apps/web/src/lib/server/domains/inboxes/__tests__/inbox.service.events.test.ts create mode 100644 apps/web/src/lib/server/domains/inboxes/inbox.channels.ts create mode 100644 apps/web/src/lib/server/domains/inboxes/inbox.memberships.ts create mode 100644 apps/web/src/lib/server/domains/inboxes/inbox.service.ts create mode 100644 apps/web/src/lib/server/domains/inboxes/index.ts create mode 100644 apps/web/src/lib/server/domains/inboxes/routing.engine.ts create mode 100644 apps/web/src/lib/server/domains/inboxes/routing.service.ts create mode 100644 apps/web/src/lib/server/domains/inboxes/routing.types.ts create mode 100644 apps/web/src/lib/server/domains/organizations/__tests__/contact.service.events.test.ts create mode 100644 apps/web/src/lib/server/domains/organizations/__tests__/contact.service.test.ts create mode 100644 apps/web/src/lib/server/domains/organizations/__tests__/normalize.test.ts create mode 100644 apps/web/src/lib/server/domains/organizations/__tests__/organization.service.events.test.ts create mode 100644 apps/web/src/lib/server/domains/organizations/__tests__/organization.service.test.ts create mode 100644 apps/web/src/lib/server/domains/organizations/contact.service.ts create mode 100644 apps/web/src/lib/server/domains/organizations/index.ts create mode 100644 apps/web/src/lib/server/domains/organizations/normalize.ts create mode 100644 apps/web/src/lib/server/domains/organizations/organization.service.ts create mode 100644 apps/web/src/lib/server/domains/sla/__tests__/business-hours.calc.test.ts create mode 100644 apps/web/src/lib/server/domains/sla/business-hours.calc.ts create mode 100644 apps/web/src/lib/server/domains/sla/business-hours.service.ts create mode 100644 apps/web/src/lib/server/domains/sla/index.ts create mode 100644 apps/web/src/lib/server/domains/sla/sla.engine.ts create mode 100644 apps/web/src/lib/server/domains/sla/sla.escalations.ts create mode 100644 apps/web/src/lib/server/domains/sla/sla.policies.service.ts create mode 100644 apps/web/src/lib/server/domains/sla/sla.queries.ts create mode 100644 apps/web/src/lib/server/domains/sla/sla.types.ts create mode 100644 apps/web/src/lib/server/domains/teams/__tests__/team.service.events.test.ts create mode 100644 apps/web/src/lib/server/domains/teams/index.ts create mode 100644 apps/web/src/lib/server/domains/teams/team.service.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket-statuses.service.events.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.attachments.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.bulk.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.notifications.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.participants.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.permissions.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.portal-query.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.query.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.recipients.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.service.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.share.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.subscriptions.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/ticket.threads.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/__tests__/tiptap-text.test.ts create mode 100644 apps/web/src/lib/server/domains/tickets/index.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket-statuses.service.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.activity.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.attachments.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.bulk.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.notifications.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.participants.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.permissions.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.portal-query.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.query.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.recipients.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.service.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.share.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.subscriptions.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.take-return.ts create mode 100644 apps/web/src/lib/server/domains/tickets/ticket.threads.ts create mode 100644 apps/web/src/lib/server/domains/tickets/tiptap-text.ts create mode 100644 apps/web/src/lib/server/domains/webhooks/__tests__/webhook.deliveries.test.ts create mode 100644 apps/web/src/lib/server/domains/webhooks/__tests__/webhook.operator-actions.test.ts create mode 100644 apps/web/src/lib/server/domains/webhooks/webhook.deliveries.ts create mode 100644 apps/web/src/lib/server/domains/webhooks/webhook.operator-actions.ts create mode 100644 apps/web/src/lib/server/events/__tests__/dispatch-enrichment.test.ts create mode 100644 apps/web/src/lib/server/events/__tests__/sample-payloads.contact-org.test.ts create mode 100644 apps/web/src/lib/server/events/__tests__/sample-payloads.test.ts create mode 100644 apps/web/src/lib/server/events/__tests__/targets-inbox-filter.test.ts create mode 100644 apps/web/src/lib/server/events/__tests__/updated-payload-shape.test.ts create mode 100644 apps/web/src/lib/server/events/__tests__/webhook-event-config.test.ts create mode 100644 apps/web/src/lib/server/events/__tests__/webhook-handler.deliveries.test.ts create mode 100644 apps/web/src/lib/server/events/sample-payloads.ts create mode 100644 apps/web/src/lib/server/functions/audit.ts create mode 100644 apps/web/src/lib/server/functions/authz.ts create mode 100644 apps/web/src/lib/server/functions/contacts.ts create mode 100644 apps/web/src/lib/server/functions/inboxes.ts create mode 100644 apps/web/src/lib/server/functions/organizations.ts create mode 100644 apps/web/src/lib/server/functions/portal-tickets.ts create mode 100644 apps/web/src/lib/server/functions/principals.ts create mode 100644 apps/web/src/lib/server/functions/roles.ts create mode 100644 apps/web/src/lib/server/functions/routing.ts create mode 100644 apps/web/src/lib/server/functions/sla.ts create mode 100644 apps/web/src/lib/server/functions/teams.ts create mode 100644 apps/web/src/lib/server/functions/tickets.ts create mode 100644 apps/web/src/lib/server/functions/webhook-deliveries.ts create mode 100644 apps/web/src/lib/server/integrations/github/ticket-inbound.ts create mode 100644 apps/web/src/lib/server/integrations/github/ticket-message.ts create mode 100644 apps/web/src/lib/server/integrations/github/types.ts create mode 100644 apps/web/src/lib/server/widget/cors.ts create mode 100644 apps/web/src/lib/server/widget/ticketing-gate.ts create mode 100644 apps/web/src/routes/_portal/tickets.$ticketId.tsx create mode 100644 apps/web/src/routes/_portal/tickets.index.tsx create mode 100644 apps/web/src/routes/admin/contacts.index.tsx create mode 100644 apps/web/src/routes/admin/contacts.organizations.tsx create mode 100644 apps/web/src/routes/admin/contacts.organizations_.$organizationId.tsx create mode 100644 apps/web/src/routes/admin/contacts.people.tsx create mode 100644 apps/web/src/routes/admin/contacts.people_.$contactId.tsx create mode 100644 apps/web/src/routes/admin/contacts.tsx create mode 100644 apps/web/src/routes/admin/settings.audit.tsx create mode 100644 apps/web/src/routes/admin/settings.business-hours.tsx create mode 100644 apps/web/src/routes/admin/settings.inboxes.tsx create mode 100644 apps/web/src/routes/admin/settings.inboxes_.$inboxId.tsx create mode 100644 apps/web/src/routes/admin/settings.roles.tsx create mode 100644 apps/web/src/routes/admin/settings.routing-rules.tsx create mode 100644 apps/web/src/routes/admin/settings.sla.tsx create mode 100644 apps/web/src/routes/admin/settings.sla_.$policyId.tsx create mode 100644 apps/web/src/routes/admin/settings.teams.tsx create mode 100644 apps/web/src/routes/admin/settings.teams_.$teamId.tsx create mode 100644 apps/web/src/routes/admin/tickets.$ticketId.tsx create mode 100644 apps/web/src/routes/admin/tickets.index.tsx create mode 100644 apps/web/src/routes/admin/tickets.new.tsx create mode 100644 apps/web/src/routes/admin/tickets.tsx create mode 100644 apps/web/src/routes/api/v1/api-keys/$apiKeyId.acknowledge-legacy.ts create mode 100644 apps/web/src/routes/api/v1/api-keys/$apiKeyId.rotate.ts create mode 100644 apps/web/src/routes/api/v1/api-keys/$apiKeyId.ts create mode 100644 apps/web/src/routes/api/v1/api-keys/index.ts create mode 100644 apps/web/src/routes/api/v1/audit-events/index.ts create mode 100644 apps/web/src/routes/api/v1/business-hours/$id.ts create mode 100644 apps/web/src/routes/api/v1/business-hours/index.ts create mode 100644 apps/web/src/routes/api/v1/contacts/$contactId.links.ts create mode 100644 apps/web/src/routes/api/v1/contacts/$contactId.ts create mode 100644 apps/web/src/routes/api/v1/contacts/index.ts create mode 100644 apps/web/src/routes/api/v1/inboxes/$inboxId.channels.$channelId.ts create mode 100644 apps/web/src/routes/api/v1/inboxes/$inboxId.channels.ts create mode 100644 apps/web/src/routes/api/v1/inboxes/$inboxId.memberships.$membershipId.ts create mode 100644 apps/web/src/routes/api/v1/inboxes/$inboxId.memberships.ts create mode 100644 apps/web/src/routes/api/v1/inboxes/$inboxId.ts create mode 100644 apps/web/src/routes/api/v1/inboxes/index.ts create mode 100644 apps/web/src/routes/api/v1/internal/sla-tick.ts create mode 100644 apps/web/src/routes/api/v1/organizations/$organizationId.contacts.ts create mode 100644 apps/web/src/routes/api/v1/organizations/$organizationId.ts create mode 100644 apps/web/src/routes/api/v1/organizations/index.ts create mode 100644 apps/web/src/routes/api/v1/routing-rules/$ruleId.ts create mode 100644 apps/web/src/routes/api/v1/routing-rules/index.ts create mode 100644 apps/web/src/routes/api/v1/routing-rules/reorder.ts create mode 100644 apps/web/src/routes/api/v1/sla-policies/$policyId.escalation-rules.$ruleId.ts create mode 100644 apps/web/src/routes/api/v1/sla-policies/$policyId.escalation-rules.ts create mode 100644 apps/web/src/routes/api/v1/sla-policies/$policyId.targets.ts create mode 100644 apps/web/src/routes/api/v1/sla-policies/$policyId.ts create mode 100644 apps/web/src/routes/api/v1/sla-policies/index.ts create mode 100644 apps/web/src/routes/api/v1/ticket-statuses/$statusId.ts create mode 100644 apps/web/src/routes/api/v1/ticket-statuses/index.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.activity.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.assign.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.participants.$participantId.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.participants.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.restore.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.return.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.shares.$shareId.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.shares.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.sla.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.take.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.threads.$threadId.attachments.$attachmentId.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.threads.$threadId.attachments.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.threads.$threadId.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.threads.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.transition.ts create mode 100644 apps/web/src/routes/api/v1/tickets/$ticketId.ts create mode 100644 apps/web/src/routes/api/v1/tickets/bulk.assign.ts create mode 100644 apps/web/src/routes/api/v1/tickets/bulk.change-inbox.ts create mode 100644 apps/web/src/routes/api/v1/tickets/bulk.transition.ts create mode 100644 apps/web/src/routes/api/v1/tickets/index.ts create mode 100644 apps/web/src/routes/api/v1/webhooks/$webhookId.deliveries.$deliveryId.redeliver.ts create mode 100644 apps/web/src/routes/api/v1/webhooks/$webhookId.deliveries.ts create mode 100644 apps/web/src/routes/api/v1/webhooks/$webhookId.test.ts create mode 100644 apps/web/src/routes/api/v1/webhooks/sample-payloads.ts create mode 100644 apps/web/src/routes/api/widget/__tests__/tickets.$ticketId.replies.test.ts create mode 100644 apps/web/src/routes/api/widget/__tests__/tickets.$ticketId.resolve.test.ts create mode 100644 apps/web/src/routes/api/widget/__tests__/tickets.$ticketId.test.ts create mode 100644 apps/web/src/routes/api/widget/__tests__/tickets.test.ts create mode 100644 apps/web/src/routes/api/widget/__tests__/widget-ticket-fixtures.ts create mode 100644 apps/web/src/routes/api/widget/tickets.$ticketId.reopen.ts create mode 100644 apps/web/src/routes/api/widget/tickets.$ticketId.replies.ts create mode 100644 apps/web/src/routes/api/widget/tickets.$ticketId.resolve.ts create mode 100644 apps/web/src/routes/api/widget/tickets.$ticketId.threads.$threadId.attachments.ts create mode 100644 apps/web/src/routes/api/widget/tickets.$ticketId.ts create mode 100644 apps/web/src/routes/api/widget/tickets.ts create mode 100644 packages/db/drizzle/0048_ticketing_rbac_foundation.sql create mode 100644 packages/db/drizzle/0049_organizations_contacts.sql create mode 100644 packages/db/drizzle/0050_ticket_core.sql create mode 100644 packages/db/drizzle/0051_inboxes_routing.sql create mode 100644 packages/db/drizzle/0052_sla_escalations.sql create mode 100644 packages/db/drizzle/0053_api_key_scopes.sql create mode 100644 packages/db/drizzle/0054_ticket_subscriptions_and_webhook_deliveries.sql create mode 100644 packages/db/drizzle/0055_webhook_deliveries_request_payload.sql create mode 100644 packages/db/drizzle/0056_webhook_inbox_filter.sql create mode 100644 packages/db/drizzle/0057_github_ticket_sync.sql create mode 100644 packages/db/drizzle/0058_integration_sync_log.sql create mode 100644 packages/db/drizzle/0059_old_cloak.sql create mode 100644 packages/db/drizzle/meta/0057_snapshot.json create mode 100644 packages/db/drizzle/meta/0059_snapshot.json create mode 100644 packages/db/src/schema/audit-events.ts create mode 100644 packages/db/src/schema/inboxes.ts create mode 100644 packages/db/src/schema/integration-sync-log.ts create mode 100644 packages/db/src/schema/integration-user-mappings.ts create mode 100644 packages/db/src/schema/organizations.ts create mode 100644 packages/db/src/schema/roles.ts create mode 100644 packages/db/src/schema/routing-rules.ts create mode 100644 packages/db/src/schema/sla.ts create mode 100644 packages/db/src/schema/teams.ts create mode 100644 packages/db/src/schema/ticket-external-links.ts create mode 100644 packages/db/src/schema/ticket-statuses.ts create mode 100644 packages/db/src/schema/ticket-subscriptions.ts create mode 100644 packages/db/src/schema/tickets.ts create mode 100644 packages/db/src/schema/webhook-deliveries.ts create mode 100644 packages/email/src/templates/tickets/ticket-event.tsx create mode 100644 packages/widget/__tests__/tickets.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3e3ce5e57..e53200b79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ bun run setup # One-time setup (deps, Docker, migrations, seed) bun run dev # Dev server at localhost:3000 (login: demo@example.com / password) bun run build && bun run db:generate && bun run db:migrate bun run test && bun run test:e2e && bun run lint && bun run typecheck +bun apps/web/scripts/backfill-ticket-contacts.ts [--dry-run] # One-shot: link existing portal users to contacts and backfill tickets.requesterContactId ``` ## Rules diff --git a/apps/web/openapi.json b/apps/web/openapi.json new file mode 100644 index 000000000..4c9c4c5b6 --- /dev/null +++ b/apps/web/openapi.json @@ -0,0 +1,17478 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Quackback API", + "version": "1.0.0", + "description": "Quackback Public REST API for managing feedback, posts, boards, and more.\n\n## Authentication\n\nAll API endpoints require authentication using an API key. Include your API key in the Authorization header:\n\n```\nAuthorization: Bearer qb_your_api_key_here\n```\n\nAPI keys can be created in the Quackback admin dashboard under Settings > API Keys.\n\n## Rate Limiting\n\nAPI requests are not currently rate limited, but this may change in the future.\n\n## Pagination\n\nList endpoints support cursor-based pagination:\n- Use the `limit` parameter to control page size (1-100, default 20)\n- Use the `cursor` parameter with the value from `meta.pagination.cursor` to fetch the next page\n- When `meta.pagination.hasMore` is false, there are no more items\n\n## TypeIDs\n\nAll resource IDs use TypeID format: `{type}_{base32_uuid}`\nExample: `post_01h455vb4pex5vsknk084sn02q`", + "contact": { + "name": "Quackback Support", + "url": "https://github.com/quackback/quackback" + }, + "license": { + "name": "AGPL-3.0", + "url": "https://www.gnu.org/licenses/agpl-3.0.html" + } + }, + "servers": [ + { + "url": "/api/v1", + "description": "API v1" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "Posts", + "description": "Manage feedback posts" + }, + { + "name": "Boards", + "description": "Manage feedback boards" + }, + { + "name": "Comments", + "description": "Manage post comments" + }, + { + "name": "Votes", + "description": "Manage post votes" + }, + { + "name": "Tags", + "description": "Manage tags" + }, + { + "name": "Statuses", + "description": "Manage post statuses" + }, + { + "name": "Members", + "description": "Manage workspace members" + }, + { + "name": "Roadmaps", + "description": "Manage roadmaps" + }, + { + "name": "Changelog", + "description": "Manage changelog entries" + }, + { + "name": "Tickets", + "description": "Customer-support tickets, threads, participants, shares, bulk ops" + }, + { + "name": "Support Config", + "description": "Inboxes, channels, memberships" + }, + { + "name": "Routing", + "description": "Routing rules + dry-run preview" + }, + { + "name": "SLA", + "description": "Business hours, SLA policies, escalation rules, internal cron tick" + }, + { + "name": "Organizations", + "description": "Customer organizations (B2B context)" + }, + { + "name": "Contacts", + "description": "Customer contacts + portal-user links" + }, + { + "name": "Audit", + "description": "Workspace audit log" + }, + { + "name": "API Keys", + "description": "Scoped API keys (legacy compat + per-key scopes)" + }, + { + "name": "Webhooks", + "description": "Webhook delivery audit log" + } + ], + "paths": { + "/posts": { + "get": { + "tags": ["Posts"], + "summary": "List posts", + "description": "Returns a paginated list of posts with optional filtering", + "parameters": [ + { + "name": "boardId", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter by board ID" + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter by status slug" + }, + { + "name": "tagIds", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter by tag IDs (comma-separated)" + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Search in title and content" + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": ["newest", "oldest", "votes"] + }, + "description": "Sort order" + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Pagination cursor from previous response" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + }, + "description": "Items per page (max 100)" + } + ], + "responses": { + "200": { + "description": "List of posts", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "title": { + "type": "string", + "example": "Add dark mode support" + }, + "content": { + "type": "string", + "example": "It would be great to have a dark mode option..." + }, + "voteCount": { + "type": "number", + "example": 42 + }, + "commentCount": { + "type": "number", + "example": 5 + }, + "boardId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "board_01h455vb4pex5vsknk084sn02q" + }, + "boardSlug": { + "type": "string", + "description": "Slug of the parent board", + "example": "feature-requests" + }, + "boardName": { + "type": "string", + "description": "Name of the parent board", + "example": "Feature Requests" + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ], + "example": "status_01h455vb4pex5vsknk084sn02q" + }, + "authorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "John Doe" + }, + "ownerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Assigned team member ID" + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#3b82f6" + } + }, + "required": ["id", "name", "color"], + "additionalProperties": false + }, + "description": "Tags assigned to this post" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "title", + "content", + "voteCount", + "commentCount", + "boardId", + "boardSlug", + "boardName", + "statusId", + "authorName", + "ownerId", + "tags", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Paginated posts list" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + }, + "post": { + "tags": ["Posts"], + "summary": "Create a post", + "description": "Create a new feedback post. The post is created on behalf of the authenticated API key holder.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Post title", + "example": "Add dark mode support" + }, + "content": { + "type": "string", + "maxLength": 10000, + "description": "Post content (optional)", + "example": "It would be great to have..." + }, + "boardId": { + "type": "string", + "description": "Board ID", + "example": "board_01h455vb4pex5vsknk084sn02q" + }, + "statusId": { + "description": "Initial status ID", + "type": "string", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "tagIds": { + "description": "Tag IDs to assign", + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + } + }, + "required": ["title", "content", "boardId"], + "description": "Create post request body" + } + } + } + }, + "responses": { + "201": { + "description": "Post created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "title": { + "type": "string" + }, + "content": { + "type": "string" + }, + "voteCount": { + "type": "number" + }, + "boardId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "authorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "title", + "content", + "voteCount", + "boardId", + "statusId", + "authorName", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Created post" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + } + }, + "/posts/{postId}": { + "get": { + "tags": ["Posts"], + "summary": "Get a post", + "description": "Get a single post by ID with full details", + "parameters": [ + { + "name": "postId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Post ID" + } + ], + "responses": { + "200": { + "description": "Post details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "title": { + "type": "string", + "example": "Add dark mode support" + }, + "content": { + "type": "string", + "example": "It would be great to have a dark mode option..." + }, + "contentJson": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ], + "description": "Rich text content as TipTap JSON" + }, + "voteCount": { + "type": "number", + "example": 42 + }, + "commentCount": { + "type": "number", + "example": 5 + }, + "boardId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "board_01h455vb4pex5vsknk084sn02q" + }, + "boardSlug": { + "type": "string", + "description": "Slug of the parent board", + "example": "feature-requests" + }, + "boardName": { + "type": "string", + "description": "Name of the parent board", + "example": "Feature Requests" + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ], + "example": "status_01h455vb4pex5vsknk084sn02q" + }, + "authorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "John Doe" + }, + "authorEmail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "user@example.com" + }, + "ownerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Assigned team member ID" + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#3b82f6" + } + }, + "required": ["id", "name", "color"], + "additionalProperties": false + }, + "description": "Tags assigned to this post" + }, + "roadmapIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of roadmaps this post belongs to" + }, + "pinnedComment": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "content": { + "type": "string" + }, + "authorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": ["id", "content", "authorName", "createdAt"], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "Pinned comment used as official response" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "deletedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "When the post was deleted, null if active", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "title", + "content", + "contentJson", + "voteCount", + "commentCount", + "boardId", + "boardSlug", + "boardName", + "statusId", + "authorName", + "authorEmail", + "ownerId", + "tags", + "roadmapIds", + "pinnedComment", + "createdAt", + "updatedAt", + "deletedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Post details" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Post not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Posts"], + "summary": "Update a post", + "description": "Update an existing post", + "parameters": [ + { + "name": "postId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Post ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "content": { + "type": "string", + "maxLength": 10000 + }, + "statusId": { + "description": "Status ID (set to null to clear)", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tagIds": { + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "ownerId": { + "description": "Assigned team member ID (set to null to unassign)", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "description": "Update post request body" + } + } + } + }, + "responses": { + "200": { + "description": "Post updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "title": { + "type": "string" + }, + "content": { + "type": "string" + }, + "contentJson": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "voteCount": { + "type": "number" + }, + "boardId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "authorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ownerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "title", + "content", + "contentJson", + "voteCount", + "boardId", + "statusId", + "authorName", + "ownerId", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Updated post" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Post not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Posts"], + "summary": "Delete a post", + "description": "Delete a post by ID", + "parameters": [ + { + "name": "postId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Post ID" + } + ], + "responses": { + "204": { + "description": "Post deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Post not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/posts/{postId}/vote": { + "post": { + "tags": ["Votes"], + "summary": "Toggle vote on a post", + "description": "Vote or unvote on a post (toggle)", + "parameters": [ + { + "name": "postId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Post ID" + } + ], + "responses": { + "200": { + "description": "Vote toggled", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "voted": { + "type": "boolean", + "description": "Whether the post is now voted" + }, + "voteCount": { + "type": "number", + "description": "Current vote count" + } + }, + "required": ["voted", "voteCount"], + "additionalProperties": false, + "description": "Vote result" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Vote result" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Post not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/posts/{postId}/vote/proxy": { + "post": { + "tags": ["Votes"], + "summary": "Add a proxy vote", + "description": "Add a vote on behalf of another user (insert-only, never toggles). Requires team role.", + "parameters": [ + { + "name": "postId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Post ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "voterPrincipalId": { + "type": "string", + "description": "Principal ID of the voter", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "required": ["voterPrincipalId"], + "description": "Proxy vote request body" + } + } + } + }, + "responses": { + "200": { + "description": "Proxy vote added", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "voted": { + "type": "boolean", + "description": "Whether the post is now voted" + }, + "voteCount": { + "type": "number", + "description": "Current vote count" + } + }, + "required": ["voted", "voteCount"], + "additionalProperties": false, + "description": "Vote result" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Vote result" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Post not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Votes"], + "summary": "Remove a vote", + "description": "Remove any vote (proxy, integration, or direct) for a user. Requires team role.", + "parameters": [ + { + "name": "postId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Post ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "voterPrincipalId": { + "type": "string", + "description": "Principal ID of the voter", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "required": ["voterPrincipalId"], + "description": "Proxy vote request body" + } + } + } + }, + "responses": { + "204": { + "description": "Vote removed" + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Post not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/boards": { + "get": { + "tags": ["Boards"], + "summary": "List boards", + "description": "Returns all boards in the workspace", + "responses": { + "200": { + "description": "List of boards", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "board_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Feature Requests" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "feature-requests" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Submit and vote on feature ideas" + }, + "isPublic": { + "type": "boolean", + "example": true + }, + "postCount": { + "type": "number", + "description": "Number of posts in this board", + "example": 42 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "isPublic", + "postCount", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "List of boards" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + }, + "post": { + "tags": ["Boards"], + "summary": "Create a board", + "description": "Create a new feedback board", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Board name", + "example": "Feature Requests" + }, + "slug": { + "description": "URL-friendly slug (auto-generated from name if omitted)", + "example": "feature-requests", + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[a-z0-9-]+$" + }, + "description": { + "description": "Board description", + "type": "string", + "maxLength": 500 + }, + "isPublic": { + "description": "Whether board is public", + "default": true, + "type": "boolean" + } + }, + "required": ["name"], + "description": "Create board request body" + } + } + } + }, + "responses": { + "201": { + "description": "Board created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "board_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Feature Requests" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "feature-requests" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Submit and vote on feature ideas" + }, + "isPublic": { + "type": "boolean", + "example": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "isPublic", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Created board" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + } + }, + "/boards/{boardId}": { + "get": { + "tags": ["Boards"], + "summary": "Get a board", + "description": "Get a single board by ID", + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Board ID" + } + ], + "responses": { + "200": { + "description": "Board details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "board_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Feature Requests" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "feature-requests" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Submit and vote on feature ideas" + }, + "isPublic": { + "type": "boolean", + "example": true + }, + "settings": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "Board-specific settings" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "isPublic", + "settings", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Board details" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Board not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Boards"], + "summary": "Update a board", + "description": "Update an existing board", + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Board ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + }, + "isPublic": { + "type": "boolean" + } + }, + "description": "Update board request body" + } + } + } + }, + "responses": { + "200": { + "description": "Board updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "board_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Feature Requests" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "feature-requests" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Submit and vote on feature ideas" + }, + "isPublic": { + "type": "boolean", + "example": true + }, + "settings": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "Board-specific settings" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "isPublic", + "settings", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Updated board" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Board not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Boards"], + "summary": "Delete a board", + "description": "Delete a board by ID", + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Board ID" + } + ], + "responses": { + "204": { + "description": "Board deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Board not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/posts/{postId}/comments": { + "get": { + "tags": ["Comments"], + "summary": "List comments on a post", + "description": "Returns all comments on a post as a threaded tree with nested replies", + "parameters": [ + { + "name": "postId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Post ID" + } + ], + "responses": { + "200": { + "description": "Threaded list of comments", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/__schema0" + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "List of comments" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Post not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "post": { + "tags": ["Comments"], + "summary": "Add a comment to a post", + "description": "Create a new comment on a post. The comment is attributed to the authenticated API key holder.", + "parameters": [ + { + "name": "postId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Post ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "minLength": 1, + "maxLength": 5000, + "description": "Comment content" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ], + "description": "Parent comment ID for replies" + }, + "isPrivate": { + "description": "Mark comment as private (team-only). Defaults to false.", + "type": "boolean" + } + }, + "required": ["content"], + "description": "Create comment request body" + } + } + } + }, + "responses": { + "201": { + "description": "Comment created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "comment_01h455vb4pex5vsknk084sn02q" + }, + "postId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "content": { + "type": "string" + }, + "authorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "isTeamMember": { + "type": "boolean" + }, + "isPrivate": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "postId", + "parentId", + "content", + "authorName", + "principalId", + "isTeamMember", + "isPrivate", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Created comment" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Post not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/comments/{commentId}": { + "get": { + "tags": ["Comments"], + "summary": "Get a comment", + "description": "Get a single comment by ID", + "parameters": [ + { + "name": "commentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Comment ID" + } + ], + "responses": { + "200": { + "description": "Comment details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "comment_01h455vb4pex5vsknk084sn02q" + }, + "postId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ], + "description": "Parent comment ID for replies" + }, + "content": { + "type": "string", + "example": "Great idea! This would be very useful." + }, + "authorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Jane Doe" + }, + "authorEmail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "user@example.com" + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ], + "description": "Principal ID of the comment author" + }, + "isTeamMember": { + "type": "boolean", + "description": "Whether the author is a team member", + "example": false + }, + "isPrivate": { + "type": "boolean", + "description": "Whether the comment is only visible to team members", + "example": false + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "deletedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "When the comment was deleted, null if active", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "postId", + "parentId", + "content", + "authorName", + "authorEmail", + "principalId", + "isTeamMember", + "isPrivate", + "createdAt", + "deletedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Comment details" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Comment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Comments"], + "summary": "Update a comment", + "description": "Update an existing comment", + "parameters": [ + { + "name": "commentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Comment ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "minLength": 1, + "maxLength": 5000, + "description": "Updated content" + } + }, + "required": ["content"], + "description": "Update comment request body" + } + } + } + }, + "responses": { + "200": { + "description": "Comment updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "comment_01h455vb4pex5vsknk084sn02q" + }, + "postId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "content": { + "type": "string" + }, + "authorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "isTeamMember": { + "type": "boolean" + }, + "isPrivate": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "postId", + "parentId", + "content", + "authorName", + "principalId", + "isTeamMember", + "isPrivate", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Updated comment" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Comment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Comments"], + "summary": "Delete a comment", + "description": "Delete a comment by ID", + "parameters": [ + { + "name": "commentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Comment ID" + } + ], + "responses": { + "204": { + "description": "Comment deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Comment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/tags": { + "get": { + "tags": ["Tags"], + "summary": "List tags", + "description": "Returns all tags in the workspace", + "responses": { + "200": { + "description": "List of tags", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "tag_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Bug" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#ef4444" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": ["id", "name", "color", "createdAt"], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "List of tags" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + }, + "post": { + "tags": ["Tags"], + "summary": "Create a tag", + "description": "Create a new tag", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "description": "Tag name", + "example": "Bug" + }, + "color": { + "description": "Tag color", + "default": "#6b7280", + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "example": "#3b82f6" + } + }, + "required": ["name"], + "description": "Create tag request body" + } + } + } + }, + "responses": { + "201": { + "description": "Tag created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "tag_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Bug" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#ef4444" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": ["id", "name", "color", "createdAt"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Created tag" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + } + }, + "/tags/{tagId}": { + "get": { + "tags": ["Tags"], + "summary": "Get a tag", + "description": "Get a single tag by ID", + "parameters": [ + { + "name": "tagId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Tag ID" + } + ], + "responses": { + "200": { + "description": "Tag details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "tag_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Bug" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#ef4444" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": ["id", "name", "color", "createdAt"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Tag details" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Tag not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Tags"], + "summary": "Update a tag", + "description": "Update an existing tag", + "parameters": [ + { + "name": "tagId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Tag ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#3b82f6" + } + }, + "description": "Update tag request body" + } + } + } + }, + "responses": { + "200": { + "description": "Tag updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "tag_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Bug" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#ef4444" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": ["id", "name", "color", "createdAt"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Updated tag" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Tag not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Tags"], + "summary": "Delete a tag", + "description": "Delete a tag by ID", + "parameters": [ + { + "name": "tagId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Tag ID" + } + ], + "responses": { + "204": { + "description": "Tag deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Tag not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/statuses": { + "get": { + "tags": ["Statuses"], + "summary": "List statuses", + "description": "Returns all statuses in the workspace, ordered by category and position", + "responses": { + "200": { + "description": "List of statuses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "status_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "In Progress" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "in_progress" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#f97316" + }, + "category": { + "type": "string", + "enum": ["active", "complete", "closed"], + "description": "Status category" + }, + "position": { + "type": "number", + "description": "Display order within category" + }, + "showOnRoadmap": { + "type": "boolean", + "description": "Whether to show on public roadmap" + }, + "isDefault": { + "type": "boolean", + "description": "Whether this is the default status for new posts" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "color", + "category", + "position", + "showOnRoadmap", + "isDefault", + "createdAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "List of statuses" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + }, + "post": { + "tags": ["Statuses"], + "summary": "Create a status", + "description": "Create a new post status", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "description": "Status name", + "example": "In Progress" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-z0-9_]+$", + "description": "URL-friendly slug", + "example": "in_progress" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Status color", + "example": "#f97316" + }, + "category": { + "type": "string", + "enum": ["active", "complete", "closed"], + "description": "Status category" + }, + "position": { + "description": "Display order within category", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "showOnRoadmap": { + "description": "Show on public roadmap", + "default": false, + "type": "boolean" + }, + "isDefault": { + "description": "Set as default for new posts", + "default": false, + "type": "boolean" + } + }, + "required": ["name", "slug", "color", "category"], + "description": "Create status request body" + } + } + } + }, + "responses": { + "201": { + "description": "Status created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "status_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "In Progress" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "in_progress" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#f97316" + }, + "category": { + "type": "string", + "enum": ["active", "complete", "closed"], + "description": "Status category" + }, + "position": { + "type": "number", + "description": "Display order within category" + }, + "showOnRoadmap": { + "type": "boolean", + "description": "Whether to show on public roadmap" + }, + "isDefault": { + "type": "boolean", + "description": "Whether this is the default status for new posts" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "color", + "category", + "position", + "showOnRoadmap", + "isDefault", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Created status" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + } + }, + "/statuses/{statusId}": { + "get": { + "tags": ["Statuses"], + "summary": "Get a status", + "description": "Get a single status by ID", + "parameters": [ + { + "name": "statusId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Status ID" + } + ], + "responses": { + "200": { + "description": "Status details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "status_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "In Progress" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "in_progress" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#f97316" + }, + "category": { + "type": "string", + "enum": ["active", "complete", "closed"], + "description": "Status category" + }, + "position": { + "type": "number", + "description": "Display order within category" + }, + "showOnRoadmap": { + "type": "boolean", + "description": "Whether to show on public roadmap" + }, + "isDefault": { + "type": "boolean", + "description": "Whether this is the default status for new posts" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "color", + "category", + "position", + "showOnRoadmap", + "isDefault", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Status details" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Status not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Statuses"], + "summary": "Update a status", + "description": "Update an existing status. Note: slug, category, and position cannot be changed via this endpoint.", + "parameters": [ + { + "name": "statusId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Status ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#3b82f6" + }, + "showOnRoadmap": { + "type": "boolean" + }, + "isDefault": { + "type": "boolean" + } + }, + "description": "Update status request body" + } + } + } + }, + "responses": { + "200": { + "description": "Status updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "status_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "In Progress" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "in_progress" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#f97316" + }, + "category": { + "type": "string", + "enum": ["active", "complete", "closed"], + "description": "Status category" + }, + "position": { + "type": "number", + "description": "Display order within category" + }, + "showOnRoadmap": { + "type": "boolean", + "description": "Whether to show on public roadmap" + }, + "isDefault": { + "type": "boolean", + "description": "Whether this is the default status for new posts" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "color", + "category", + "position", + "showOnRoadmap", + "isDefault", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Updated status" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Status not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Statuses"], + "summary": "Delete a status", + "description": "Delete a status by ID. Cannot delete the default status or a status with assigned posts.", + "parameters": [ + { + "name": "statusId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Status ID" + } + ], + "responses": { + "204": { + "description": "Status deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "403": { + "description": "Cannot delete (default status or has posts)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Forbidden error" + } + } + } + }, + "404": { + "description": "Status not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/members": { + "get": { + "tags": ["Members"], + "summary": "List team members", + "description": "Returns all team members (admin and member roles) in the workspace", + "responses": { + "200": { + "description": "List of team members", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "email": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "name", "email", "image"], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "List of team members" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + } + }, + "/members/{principalId}": { + "get": { + "tags": ["Members"], + "summary": "Get a team member", + "description": "Get a single team member by ID", + "parameters": [ + { + "name": "principalId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Principal ID" + } + ], + "responses": { + "200": { + "description": "Team member details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Member ID", + "example": "member_01h455vb4pex5vsknk084sn02q" + }, + "userId": { + "type": "string", + "description": "User ID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Member role" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "John Doe" + }, + "email": { + "type": "string", + "example": "john@example.com" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Profile image URL" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": ["id", "userId", "role", "name", "email", "image", "createdAt"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Team member details" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Team member not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Members"], + "summary": "Update a team member", + "description": "Update a team member's role. Cannot modify your own role.", + "parameters": [ + { + "name": "principalId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Principal ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "New role for the member" + } + }, + "required": ["role"], + "description": "Update member role request body" + } + } + } + }, + "responses": { + "200": { + "description": "Team member updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Member ID", + "example": "member_01h455vb4pex5vsknk084sn02q" + }, + "userId": { + "type": "string", + "description": "User ID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Member role" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "John Doe" + }, + "email": { + "type": "string", + "example": "john@example.com" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Profile image URL" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": ["id", "userId", "role", "name", "email", "image", "createdAt"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Updated team member" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "403": { + "description": "Cannot modify own role or last admin", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Forbidden error" + } + } + } + }, + "404": { + "description": "Team member not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Members"], + "summary": "Remove a team member", + "description": "Remove a team member from the workspace (converts them to a portal user). Cannot remove yourself or the last admin.", + "parameters": [ + { + "name": "principalId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Principal ID" + } + ], + "responses": { + "204": { + "description": "Team member removed" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "403": { + "description": "Cannot remove self or last admin", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Forbidden error" + } + } + } + }, + "404": { + "description": "Team member not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/users": { + "get": { + "tags": ["Users"], + "summary": "List portal users", + "description": "Returns a paginated list of portal users (public feedback submitters)", + "parameters": [ + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Search by name or email" + }, + { + "name": "verified", + "in": "query", + "schema": { + "type": "string", + "enum": ["true", "false"] + }, + "description": "Filter by email verification status" + }, + { + "name": "dateFrom", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "Filter by join date (from)" + }, + { + "name": "dateTo", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "Filter by join date (to)" + }, + { + "name": "segmentIds", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Comma-separated segment IDs to filter by (OR logic)" + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "newest", + "oldest", + "most_active", + "most_posts", + "most_comments", + "most_votes", + "name" + ] + }, + "description": "Sort order" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + }, + "description": "Page number" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + }, + "description": "Items per page" + } + ], + "responses": { + "200": { + "description": "List of portal users", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "principalId": { + "type": "string", + "description": "Principal ID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "userId": { + "type": "string", + "description": "User ID" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Jane Doe" + }, + "email": { + "type": "string", + "example": "jane@example.com" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Profile image URL" + }, + "emailVerified": { + "type": "boolean", + "description": "Whether email is verified" + }, + "attributes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "User attributes (must be configured in Settings > User Attributes)" + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the user joined", + "example": "2024-01-15T10:30:00.000Z" + }, + "postCount": { + "type": "number", + "description": "Number of posts created" + }, + "commentCount": { + "type": "number", + "description": "Number of comments made" + }, + "voteCount": { + "type": "number", + "description": "Number of votes cast" + } + }, + "required": [ + "principalId", + "userId", + "name", + "email", + "image", + "emailVerified", + "attributes", + "joinedAt", + "postCount", + "commentCount", + "voteCount" + ], + "additionalProperties": false + } + }, + "total": { + "type": "number" + }, + "hasMore": { + "type": "boolean" + }, + "page": { + "type": "number" + }, + "limit": { + "type": "number" + } + }, + "required": ["items", "total", "hasMore", "page", "limit"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Paginated portal users response" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + } + }, + "/users/identify": { + "post": { + "tags": ["Users"], + "summary": "Identify (create or update) a user", + "description": "Create a new portal user or update an existing one by email. User attributes must be configured in Settings > User Attributes before they can be set.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "User email (used for lookup/creation)" + }, + "name": { + "description": "Display name", + "type": "string" + }, + "image": { + "description": "Profile image URL", + "type": "string", + "format": "uri" + }, + "emailVerified": { + "description": "Email verification status", + "type": "boolean" + }, + "externalId": { + "description": "Your system's user ID for cross-referencing", + "type": "string" + }, + "attributes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "User attributes (must be configured in Settings > User Attributes)" + } + }, + "required": ["email"] + } + } + } + }, + "responses": { + "200": { + "description": "Existing user updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "principalId": { + "type": "string", + "description": "Principal ID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "userId": { + "type": "string", + "description": "User ID" + }, + "name": { + "type": "string", + "example": "Jane Doe" + }, + "email": { + "type": "string", + "example": "jane@example.com" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Profile image URL" + }, + "emailVerified": { + "type": "boolean", + "description": "Whether email is verified" + }, + "externalId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Customer-provided external user ID" + }, + "attributes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "User attributes (must be configured in Settings > User Attributes)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Account creation date", + "example": "2024-01-15T10:30:00.000Z" + }, + "created": { + "type": "boolean", + "description": "true if new user was created, false if existing was updated" + } + }, + "required": [ + "principalId", + "userId", + "name", + "email", + "image", + "emailVerified", + "externalId", + "attributes", + "createdAt", + "created" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Updated user" + } + } + } + }, + "201": { + "description": "New user created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "principalId": { + "type": "string", + "description": "Principal ID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "userId": { + "type": "string", + "description": "User ID" + }, + "name": { + "type": "string", + "example": "Jane Doe" + }, + "email": { + "type": "string", + "example": "jane@example.com" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Profile image URL" + }, + "emailVerified": { + "type": "boolean", + "description": "Whether email is verified" + }, + "externalId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Customer-provided external user ID" + }, + "attributes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "User attributes (must be configured in Settings > User Attributes)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Account creation date", + "example": "2024-01-15T10:30:00.000Z" + }, + "created": { + "type": "boolean", + "description": "true if new user was created, false if existing was updated" + } + }, + "required": [ + "principalId", + "userId", + "name", + "email", + "image", + "emailVerified", + "externalId", + "attributes", + "createdAt", + "created" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Created user" + } + } + } + }, + "400": { + "description": "Validation error (invalid attributes)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + } + }, + "/users/{principalId}": { + "get": { + "tags": ["Users"], + "summary": "Get a portal user", + "description": "Get detailed information about a portal user, including their activity", + "parameters": [ + { + "name": "principalId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Principal ID" + } + ], + "responses": { + "200": { + "description": "Portal user details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "principalId": { + "type": "string", + "description": "Principal ID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "userId": { + "type": "string", + "description": "User ID" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Jane Doe" + }, + "email": { + "type": "string", + "example": "jane@example.com" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Profile image URL" + }, + "emailVerified": { + "type": "boolean", + "description": "Whether email is verified" + }, + "attributes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "User attributes (must be configured in Settings > User Attributes)" + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the user joined", + "example": "2024-01-15T10:30:00.000Z" + }, + "postCount": { + "type": "number", + "description": "Number of posts created" + }, + "commentCount": { + "type": "number", + "description": "Number of comments made" + }, + "voteCount": { + "type": "number", + "description": "Number of votes cast" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Account creation date", + "example": "2024-01-15T10:30:00.000Z" + }, + "engagedPosts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "content": { + "type": "string" + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "statusName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "statusColor": { + "type": "string" + }, + "voteCount": { + "type": "number" + }, + "commentCount": { + "type": "number" + }, + "boardSlug": { + "type": "string" + }, + "boardName": { + "type": "string" + }, + "authorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "engagementTypes": { + "type": "array", + "items": { + "type": "string", + "enum": ["authored", "commented", "voted"] + } + }, + "engagedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "title", + "content", + "statusId", + "statusName", + "statusColor", + "voteCount", + "commentCount", + "boardSlug", + "boardName", + "authorName", + "createdAt", + "engagementTypes", + "engagedAt" + ], + "additionalProperties": false + }, + "description": "Posts the user has engaged with" + } + }, + "required": [ + "principalId", + "userId", + "name", + "email", + "image", + "emailVerified", + "attributes", + "joinedAt", + "postCount", + "commentCount", + "voteCount", + "createdAt", + "engagedPosts" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Portal user details" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Portal user not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Users"], + "summary": "Update a portal user", + "description": "Update a portal user's profile and attributes. User attributes must be configured in Settings > User Attributes before they can be set.", + "parameters": [ + { + "name": "principalId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Principal ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "description": "Display name", + "type": "string" + }, + "image": { + "description": "Profile image URL", + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "emailVerified": { + "description": "Email verification status", + "type": "boolean" + }, + "externalId": { + "description": "Your system's user ID (null to unset)", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "attributes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "User attributes (must be configured in Settings > User Attributes)" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated portal user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "principalId": { + "type": "string", + "description": "Principal ID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "userId": { + "type": "string", + "description": "User ID" + }, + "name": { + "type": "string", + "example": "Jane Doe" + }, + "email": { + "type": "string", + "example": "jane@example.com" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Profile image URL" + }, + "emailVerified": { + "type": "boolean", + "description": "Whether email is verified" + }, + "externalId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Customer-provided external user ID" + }, + "attributes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "User attributes (must be configured in Settings > User Attributes)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Account creation date", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "principalId", + "userId", + "name", + "email", + "image", + "emailVerified", + "externalId", + "attributes", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Updated user" + } + } + } + }, + "400": { + "description": "Validation error (invalid attributes)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Portal user not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Users"], + "summary": "Remove a portal user", + "description": "Remove a portal user from the workspace", + "parameters": [ + { + "name": "principalId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Principal ID" + } + ], + "responses": { + "204": { + "description": "Portal user removed" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Portal user not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/roadmaps": { + "get": { + "tags": ["Roadmaps"], + "summary": "List roadmaps", + "description": "Returns all roadmaps in the workspace", + "responses": { + "200": { + "description": "List of roadmaps", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "roadmap_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Product Roadmap" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "product-roadmap" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Our product development roadmap" + }, + "isPublic": { + "type": "boolean", + "description": "Whether the roadmap is publicly visible" + }, + "position": { + "type": "number", + "description": "Display order" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "isPublic", + "position", + "createdAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "List of roadmaps" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + }, + "post": { + "tags": ["Roadmaps"], + "summary": "Create a roadmap", + "description": "Create a new roadmap", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Roadmap name", + "example": "Product Roadmap" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[a-z0-9-]+$", + "description": "URL-friendly slug", + "example": "product-roadmap" + }, + "description": { + "description": "Roadmap description", + "type": "string", + "maxLength": 500 + }, + "isPublic": { + "description": "Make roadmap public", + "default": true, + "type": "boolean" + } + }, + "required": ["name", "slug"], + "description": "Create roadmap request body" + } + } + } + }, + "responses": { + "201": { + "description": "Roadmap created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "roadmap_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Product Roadmap" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "product-roadmap" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Our product development roadmap" + }, + "isPublic": { + "type": "boolean", + "description": "Whether the roadmap is publicly visible" + }, + "position": { + "type": "number", + "description": "Display order" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "isPublic", + "position", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Created roadmap" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + } + }, + "/roadmaps/{roadmapId}": { + "get": { + "tags": ["Roadmaps"], + "summary": "Get a roadmap", + "description": "Get a single roadmap by ID", + "parameters": [ + { + "name": "roadmapId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Roadmap ID" + } + ], + "responses": { + "200": { + "description": "Roadmap details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "roadmap_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Product Roadmap" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "product-roadmap" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Our product development roadmap" + }, + "isPublic": { + "type": "boolean", + "description": "Whether the roadmap is publicly visible" + }, + "position": { + "type": "number", + "description": "Display order" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "isPublic", + "position", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Roadmap details" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Roadmap not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Roadmaps"], + "summary": "Update a roadmap", + "description": "Update an existing roadmap", + "parameters": [ + { + "name": "roadmapId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Roadmap ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + }, + "isPublic": { + "type": "boolean" + } + }, + "description": "Update roadmap request body" + } + } + } + }, + "responses": { + "200": { + "description": "Roadmap updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "roadmap_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string", + "example": "Product Roadmap" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "product-roadmap" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Our product development roadmap" + }, + "isPublic": { + "type": "boolean", + "description": "Whether the roadmap is publicly visible" + }, + "position": { + "type": "number", + "description": "Display order" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "isPublic", + "position", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Updated roadmap" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Roadmap not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Roadmaps"], + "summary": "Delete a roadmap", + "description": "Delete a roadmap by ID", + "parameters": [ + { + "name": "roadmapId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Roadmap ID" + } + ], + "responses": { + "204": { + "description": "Roadmap deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Roadmap not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/roadmaps/{roadmapId}/posts": { + "get": { + "tags": ["Roadmaps"], + "summary": "List posts in a roadmap", + "description": "Returns posts assigned to a roadmap", + "parameters": [ + { + "name": "roadmapId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Roadmap ID" + }, + { + "name": "statusId", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter by status ID" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + }, + "description": "Items per page" + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + } + ], + "responses": { + "200": { + "description": "List of roadmap posts", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "title": { + "type": "string" + }, + "voteCount": { + "type": "number" + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "board": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": ["id", "name", "slug"], + "additionalProperties": false + }, + "position": { + "type": "number", + "description": "Position within the roadmap" + } + }, + "required": [ + "id", + "title", + "voteCount", + "statusId", + "board", + "position" + ], + "additionalProperties": false + } + }, + "total": { + "type": "number" + }, + "hasMore": { + "type": "boolean" + } + }, + "required": ["items", "total", "hasMore"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Paginated roadmap posts response" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Roadmap not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "post": { + "tags": ["Roadmaps"], + "summary": "Add a post to a roadmap", + "description": "Add an existing post to a roadmap", + "parameters": [ + { + "name": "roadmapId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Roadmap ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "postId": { + "type": "string", + "description": "Post ID to add", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "required": ["postId"], + "description": "Add post to roadmap request body" + } + } + } + }, + "responses": { + "201": { + "description": "Post added to roadmap", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "roadmapId": { + "type": "string" + }, + "postId": { + "type": "string" + } + }, + "required": ["message", "roadmapId", "postId"], + "additionalProperties": false, + "description": "Post added confirmation" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Post added confirmation" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Roadmap or post not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + }, + "409": { + "description": "Post already in roadmap", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "CONFLICT" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Conflict error" + } + } + } + } + } + } + }, + "/roadmaps/{roadmapId}/posts/{postId}": { + "delete": { + "tags": ["Roadmaps"], + "summary": "Remove a post from a roadmap", + "description": "Remove a post from a roadmap", + "parameters": [ + { + "name": "roadmapId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Roadmap ID" + }, + { + "name": "postId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Post ID" + } + ], + "responses": { + "204": { + "description": "Post removed from roadmap" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Roadmap or post not found in roadmap", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/changelog": { + "get": { + "tags": ["Changelog"], + "summary": "List changelog entries", + "description": "Returns changelog entries with optional filtering by published status", + "parameters": [ + { + "name": "published", + "in": "query", + "schema": { + "type": "string", + "enum": ["true", "false"] + }, + "description": "Filter by published status" + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Pagination cursor for next page" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + }, + "description": "Items per page" + } + ], + "responses": { + "200": { + "description": "List of changelog entries", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "changelog_01h455vb4pex5vsknk084sn02q" + }, + "title": { + "type": "string", + "example": "New Dark Mode Feature" + }, + "content": { + "type": "string", + "example": "We've added a dark mode option..." + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "When the entry was published (null if draft)", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "title", + "content", + "publishedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "pagination": { + "type": "object", + "properties": { + "cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Cursor for fetching next page (null if no more pages)" + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items to fetch" + } + }, + "required": ["cursor", "hasMore"], + "additionalProperties": false, + "description": "Cursor-based pagination metadata" + } + }, + "required": ["data", "pagination"], + "additionalProperties": false, + "description": "Paginated changelog entries" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + }, + "post": { + "tags": ["Changelog"], + "summary": "Create a changelog entry", + "description": "Create a new changelog entry", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Entry title", + "example": "New Dark Mode Feature" + }, + "content": { + "type": "string", + "minLength": 1, + "description": "Entry content (supports markdown)" + }, + "publishedAt": { + "description": "Publish date (omit to save as draft)", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": ["title", "content"], + "description": "Create changelog entry request body" + } + } + } + }, + "responses": { + "201": { + "description": "Changelog entry created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "changelog_01h455vb4pex5vsknk084sn02q" + }, + "title": { + "type": "string", + "example": "New Dark Mode Feature" + }, + "content": { + "type": "string", + "example": "We've added a dark mode option..." + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "When the entry was published (null if draft)", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "title", + "content", + "publishedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Created changelog entry" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + } + }, + "/changelog/{entryId}": { + "get": { + "tags": ["Changelog"], + "summary": "Get a changelog entry", + "description": "Get a single changelog entry by ID", + "parameters": [ + { + "name": "entryId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Changelog entry ID" + } + ], + "responses": { + "200": { + "description": "Changelog entry details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "changelog_01h455vb4pex5vsknk084sn02q" + }, + "title": { + "type": "string", + "example": "New Dark Mode Feature" + }, + "content": { + "type": "string", + "example": "We've added a dark mode option..." + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "When the entry was published (null if draft)", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "title", + "content", + "publishedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Changelog entry details" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Changelog entry not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Changelog"], + "summary": "Update a changelog entry", + "description": "Update an existing changelog entry", + "parameters": [ + { + "name": "entryId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Changelog entry ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "content": { + "type": "string", + "minLength": 1 + }, + "publishedAt": { + "description": "Set to null to unpublish", + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + } + }, + "description": "Update changelog entry request body" + } + } + } + }, + "responses": { + "200": { + "description": "Changelog entry updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "changelog_01h455vb4pex5vsknk084sn02q" + }, + "title": { + "type": "string", + "example": "New Dark Mode Feature" + }, + "content": { + "type": "string", + "example": "We've added a dark mode option..." + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "When the entry was published (null if draft)", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "title", + "content", + "publishedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Updated changelog entry" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Changelog entry not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Changelog"], + "summary": "Delete a changelog entry", + "description": "Delete a changelog entry by ID", + "parameters": [ + { + "name": "entryId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Changelog entry ID" + } + ], + "responses": { + "204": { + "description": "Changelog entry deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Changelog entry not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/tickets": { + "get": { + "tags": ["Tickets"], + "summary": "List tickets in scope", + "description": "Returns tickets visible to the caller, filtered by scope.", + "parameters": [ + { + "name": "scope", + "in": "query", + "schema": { + "def": { + "type": "default", + "innerType": { + "def": { + "type": "enum", + "entries": { + "all": "all", + "my_assigned": "my_assigned", + "my_team": "my_team", + "shared_with_me": "shared_with_me", + "unassigned": "unassigned", + "my_inbox": "my_inbox", + "inbox": "inbox" + } + }, + "type": "enum", + "enum": { + "all": "all", + "my_assigned": "my_assigned", + "my_team": "my_team", + "shared_with_me": "shared_with_me", + "unassigned": "unassigned", + "my_inbox": "my_inbox", + "inbox": "inbox" + }, + "options": [ + "all", + "my_assigned", + "my_team", + "shared_with_me", + "unassigned", + "my_inbox", + "inbox" + ] + }, + "defaultValue": "my_team" + }, + "type": "default" + } + }, + { + "name": "statusCategory", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "enum", + "entries": { + "open": "open", + "pending": "pending", + "on_hold": "on_hold", + "solved": "solved", + "closed": "closed" + } + }, + "type": "enum", + "enum": { + "open": "open", + "pending": "pending", + "on_hold": "on_hold", + "solved": "solved", + "closed": "closed" + }, + "options": ["open", "pending", "on_hold", "solved", "closed"] + } + }, + "type": "optional" + } + }, + { + "name": "inboxId", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "search", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "number", + "coerce": true, + "checks": [{}, {}] + }, + "type": "number", + "minValue": 1, + "maxValue": 200, + "isInt": false, + "isFinite": true, + "format": null + } + }, + "type": "optional" + } + } + ], + "responses": { + "200": { + "description": "Ticket queue", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_01h455vb4pex5vsknk084sn02q" + }, + "subject": { + "type": "string" + }, + "descriptionText": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "priority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "channel": { + "type": "string", + "enum": ["portal", "email", "api", "widget"] + }, + "visibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneePrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneeTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterPrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterContactId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "organizationId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "inboxId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "slaPolicyId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "firstResponseAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "resolvedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "reopenedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "closedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "lastActivityAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "subject", + "descriptionText", + "priority", + "channel", + "visibilityScope", + "statusId", + "primaryTeamId", + "assigneePrincipalId", + "assigneeTeamId", + "requesterPrincipalId", + "requesterContactId", + "organizationId", + "inboxId", + "slaPolicyId", + "firstResponseAt", + "resolvedAt", + "reopenedAt", + "closedAt", + "lastActivityAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "description": "Ticket header record" + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Tickets" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + }, + "post": { + "tags": ["Tickets"], + "summary": "Create a ticket", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "descriptionJson": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "descriptionText": { + "anyOf": [ + { + "type": "string", + "maxLength": 100000 + }, + { + "type": "null" + } + ] + }, + "priority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "channel": { + "type": "string", + "enum": ["portal", "email", "api", "widget"] + }, + "visibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneePrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneeTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterPrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterContactId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "organizationId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "inboxId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + } + }, + "required": ["subject"], + "description": "Create ticket request body" + } + } + } + }, + "responses": { + "201": { + "description": "Ticket created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_01h455vb4pex5vsknk084sn02q" + }, + "subject": { + "type": "string" + }, + "descriptionText": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "priority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "channel": { + "type": "string", + "enum": ["portal", "email", "api", "widget"] + }, + "visibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneePrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneeTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterPrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterContactId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "organizationId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "inboxId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "slaPolicyId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "firstResponseAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "resolvedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "reopenedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "closedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "lastActivityAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "subject", + "descriptionText", + "priority", + "channel", + "visibilityScope", + "statusId", + "primaryTeamId", + "assigneePrincipalId", + "assigneeTeamId", + "requesterPrincipalId", + "requesterContactId", + "organizationId", + "inboxId", + "slaPolicyId", + "firstResponseAt", + "resolvedAt", + "reopenedAt", + "closedAt", + "lastActivityAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "description": "Ticket header record" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Ticket" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + } + } + } + }, + "/tickets/{ticketId}": { + "get": { + "tags": ["Tickets"], + "summary": "Get a ticket", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Ticket", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_01h455vb4pex5vsknk084sn02q" + }, + "subject": { + "type": "string" + }, + "descriptionText": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "priority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "channel": { + "type": "string", + "enum": ["portal", "email", "api", "widget"] + }, + "visibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneePrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneeTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterPrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterContactId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "organizationId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "inboxId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "slaPolicyId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "firstResponseAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "resolvedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "reopenedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "closedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "lastActivityAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "subject", + "descriptionText", + "priority", + "channel", + "visibilityScope", + "statusId", + "primaryTeamId", + "assigneePrincipalId", + "assigneeTeamId", + "requesterPrincipalId", + "requesterContactId", + "organizationId", + "inboxId", + "slaPolicyId", + "firstResponseAt", + "resolvedAt", + "reopenedAt", + "closedAt", + "lastActivityAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "description": "Ticket header record" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Ticket" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Tickets"], + "summary": "Update ticket header (optimistic concurrency)", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "expectedUpdatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "priority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "visibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "organizationId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterContactId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + } + }, + "required": ["expectedUpdatedAt"], + "description": "Patch ticket request body (optimistic concurrency)" + } + } + } + }, + "responses": { + "200": { + "description": "Updated ticket", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_01h455vb4pex5vsknk084sn02q" + }, + "subject": { + "type": "string" + }, + "descriptionText": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "priority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "channel": { + "type": "string", + "enum": ["portal", "email", "api", "widget"] + }, + "visibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneePrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneeTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterPrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterContactId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "organizationId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "inboxId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "slaPolicyId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "firstResponseAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "resolvedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "reopenedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "closedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "lastActivityAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "subject", + "descriptionText", + "priority", + "channel", + "visibilityScope", + "statusId", + "primaryTeamId", + "assigneePrincipalId", + "assigneeTeamId", + "requesterPrincipalId", + "requesterContactId", + "organizationId", + "inboxId", + "slaPolicyId", + "firstResponseAt", + "resolvedAt", + "reopenedAt", + "closedAt", + "lastActivityAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "description": "Ticket header record" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Ticket" + } + } + } + }, + "409": { + "description": "Conflict (stale expectedUpdatedAt)" + } + } + }, + "delete": { + "tags": ["Tickets"], + "summary": "Soft-delete a ticket", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Deleted" + } + } + } + }, + "/tickets/{ticketId}/threads": { + "get": { + "tags": ["Tickets"], + "summary": "List threads on a ticket (audience-filtered)", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Threads", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_thread_01h455vb4pex5vsknk084sn02q" + }, + "ticketId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "audience": { + "type": "string", + "enum": ["public", "internal", "shared_team"] + }, + "sharedWithTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "bodyText": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "bodyJson": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "editedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "ticketId", + "principalId", + "audience", + "sharedWithTeamId", + "bodyText", + "bodyJson", + "createdAt", + "editedAt" + ], + "additionalProperties": false, + "description": "Ticket thread (message)" + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Threads" + } + } + } + } + } + }, + "post": { + "tags": ["Tickets"], + "summary": "Add a thread (public reply / internal note / shared note)", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "audience": { + "type": "string", + "enum": ["public", "internal", "shared_team"] + }, + "bodyJson": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "bodyText": { + "anyOf": [ + { + "type": "string", + "maxLength": 100000 + }, + { + "type": "null" + } + ] + }, + "sharedWithTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + } + }, + "required": ["audience"], + "description": "Add thread to ticket request body" + } + } + } + }, + "responses": { + "201": { + "description": "Thread created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_thread_01h455vb4pex5vsknk084sn02q" + }, + "ticketId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "audience": { + "type": "string", + "enum": ["public", "internal", "shared_team"] + }, + "sharedWithTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "bodyText": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "bodyJson": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "editedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "ticketId", + "principalId", + "audience", + "sharedWithTeamId", + "bodyText", + "bodyJson", + "createdAt", + "editedAt" + ], + "additionalProperties": false, + "description": "Ticket thread (message)" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Thread" + } + } + } + } + } + } + }, + "/tickets/{ticketId}/participants": { + "get": { + "tags": ["Tickets"], + "summary": "List participants", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Participants", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "ticketId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "contactId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "string", + "enum": ["watcher", "collaborator", "cc"] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "ticketId", + "principalId", + "contactId", + "role", + "createdAt" + ], + "additionalProperties": false, + "description": "Ticket participant" + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Participants" + } + } + } + } + } + }, + "post": { + "tags": ["Tickets"], + "summary": "Add a participant", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": ["watcher", "collaborator", "cc"] + }, + "principalId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "contactId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "required": ["role"] + } + } + } + }, + "responses": { + "201": { + "description": "Participant added" + } + } + } + }, + "/tickets/{ticketId}/shares": { + "get": { + "tags": ["Tickets"], + "summary": "List share grants", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Shares", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "ticketId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "teamId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "accessLevel": { + "type": "string", + "enum": ["read", "comment", "full"] + }, + "grantedByPrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "grantedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "revokedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "ticketId", + "teamId", + "accessLevel", + "grantedByPrincipalId", + "grantedAt", + "revokedAt" + ], + "additionalProperties": false, + "description": "Cross-team share grant" + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Shares" + } + } + } + } + } + }, + "post": { + "tags": ["Tickets"], + "summary": "Share ticket with another team", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "teamId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "accessLevel": { + "default": "read", + "type": "string", + "enum": ["read", "comment", "full"] + } + }, + "required": ["teamId"] + } + } + } + }, + "responses": { + "201": { + "description": "Share created" + } + } + } + }, + "/tickets/{ticketId}/take": { + "post": { + "tags": ["Tickets"], + "summary": "Take (self-assign) a ticket", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "expectedUpdatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": ["expectedUpdatedAt"] + } + } + } + }, + "responses": { + "200": { + "description": "Ticket taken" + } + } + } + }, + "/tickets/{ticketId}/return": { + "post": { + "tags": ["Tickets"], + "summary": "Return (un-self-assign) a ticket", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Ticket returned" + } + } + } + }, + "/tickets/{ticketId}/sla": { + "get": { + "tags": ["Tickets"], + "summary": "Get active SLA clocks for a ticket", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Clocks", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "ticketId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "kind": { + "type": "string", + "enum": ["first_response", "next_response", "resolution"] + }, + "state": { + "type": "string", + "enum": ["running", "paused", "met", "breached", "cancelled"] + }, + "startedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "dueAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "pausedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "breachedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "metAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "ticketId", + "kind", + "state", + "startedAt", + "dueAt", + "pausedAt", + "breachedAt", + "metAt" + ], + "additionalProperties": false, + "description": "Per-ticket SLA clock" + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Clocks" + } + } + } + } + } + } + }, + "/tickets/bulk/assign": { + "post": { + "tags": ["Tickets"], + "summary": "Bulk-assign tickets (best-effort)", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ticketIds": { + "minItems": 1, + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "assigneePrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + } + }, + "required": ["ticketIds", "assigneePrincipalId"] + } + } + } + }, + "responses": { + "200": { + "description": "Per-ticket result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "succeeded": { + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ticketId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "reason": { + "type": "string" + } + }, + "required": ["ticketId", "reason"], + "additionalProperties": false + } + } + }, + "required": ["succeeded", "failed"], + "additionalProperties": false, + "description": "Best-effort bulk operation result" + } + } + } + } + } + } + }, + "/tickets/bulk/transition": { + "post": { + "tags": ["Tickets"], + "summary": "Bulk-transition ticket statuses (best-effort)", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ticketIds": { + "minItems": 1, + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "statusId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "required": ["ticketIds", "statusId"] + } + } + } + }, + "responses": { + "200": { + "description": "Per-ticket result" + } + } + } + }, + "/tickets/bulk/change-inbox": { + "post": { + "tags": ["Tickets"], + "summary": "Bulk-move tickets to a different inbox (best-effort)", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ticketIds": { + "minItems": 1, + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "inboxId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + } + }, + "required": ["ticketIds", "inboxId"] + } + } + } + }, + "responses": { + "200": { + "description": "Per-ticket result" + } + } + } + }, + "/tickets/{ticketId}/restore": { + "post": { + "tags": ["Tickets"], + "summary": "Restore a soft-deleted ticket (admin)", + "description": "Pairs with `DELETE /tickets/{ticketId}`. Admin-only. Returns 409 if the ticket is not deleted.", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Ticket restored", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_01h455vb4pex5vsknk084sn02q" + }, + "subject": { + "type": "string" + }, + "descriptionText": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "priority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "channel": { + "type": "string", + "enum": ["portal", "email", "api", "widget"] + }, + "visibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "statusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneePrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "assigneeTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterPrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "requesterContactId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "organizationId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "inboxId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "slaPolicyId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "firstResponseAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "resolvedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "reopenedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "closedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "lastActivityAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "subject", + "descriptionText", + "priority", + "channel", + "visibilityScope", + "statusId", + "primaryTeamId", + "assigneePrincipalId", + "assigneeTeamId", + "requesterPrincipalId", + "requesterContactId", + "organizationId", + "inboxId", + "slaPolicyId", + "firstResponseAt", + "resolvedAt", + "reopenedAt", + "closedAt", + "lastActivityAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "description": "Ticket header record" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Ticket" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Ticket not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + }, + "409": { + "description": "Ticket is not deleted" + } + } + } + }, + "/tickets/{ticketId}/activity": { + "get": { + "tags": ["Tickets"], + "summary": "List ticket activity (timeline)", + "description": "Reverse-chronological feed of all activity events on the ticket. Use `before` (ISO timestamp) for pagination.", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "Return rows strictly older than this timestamp" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "description": "Default 50, max 200" + } + ], + "responses": { + "200": { + "description": "Activity timeline page", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "activity": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "ticketId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "description": "Event type, e.g. ticket.created, ticket.status_changed, thread.added" + }, + "metadata": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "actorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "actorAvatarUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "ticketId", + "principalId", + "type", + "metadata", + "createdAt", + "actorName", + "actorAvatarUrl" + ], + "additionalProperties": false, + "description": "Single ticket-activity event" + } + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO timestamp cursor for the next page; null when none" + } + }, + "required": ["activity", "nextCursor"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Ticket activity timeline response" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "404": { + "description": "Ticket not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/tickets/{ticketId}/threads/{threadId}": { + "patch": { + "tags": ["Tickets"], + "summary": "Edit a thread (author only)", + "description": "Only the original author may edit. Stamps `editedAt` and `editedByPrincipalId`.", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + { + "name": "threadId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bodyJson": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "bodyText": { + "anyOf": [ + { + "type": "string", + "maxLength": 100000 + }, + { + "type": "null" + } + ] + } + }, + "description": "Edit thread body — author only. Provide bodyJson or bodyText." + } + } + } + }, + "responses": { + "200": { + "description": "Thread updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_thread_01h455vb4pex5vsknk084sn02q" + }, + "ticketId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "audience": { + "type": "string", + "enum": ["public", "internal", "shared_team"] + }, + "sharedWithTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "bodyText": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "bodyJson": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "editedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "ticketId", + "principalId", + "audience", + "sharedWithTeamId", + "bodyText", + "bodyJson", + "createdAt", + "editedAt" + ], + "additionalProperties": false, + "description": "Ticket thread (message)" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Thread" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "403": { + "description": "Not the author" + }, + "404": { + "description": "Ticket or thread not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Tickets"], + "summary": "Soft-delete a thread", + "description": "Author or any caller with `ticket.edit_fields` may soft-delete. The row is marked `deletedAt`.", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + { + "name": "threadId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "403": { + "description": "Not the author and lacks ticket.edit_fields" + }, + "404": { + "description": "Ticket or thread not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/tickets/{ticketId}/threads/{threadId}/attachments": { + "get": { + "tags": ["Tickets"], + "summary": "List attachments on a thread", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + { + "name": "threadId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Attachments", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_att_01h455vb4pex5vsknk084sn02q" + }, + "threadId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "uploadedByPrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "filename": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "storageKey": { + "type": "string" + }, + "publicUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "threadId", + "uploadedByPrincipalId", + "filename", + "mimeType", + "sizeBytes", + "storageKey", + "publicUrl", + "createdAt" + ], + "additionalProperties": false, + "description": "Ticket attachment metadata" + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Attachments" + } + } + } + }, + "404": { + "description": "Ticket or thread not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "post": { + "tags": ["Tickets"], + "summary": "Upload an attachment to a thread (multipart)", + "description": "multipart/form-data with a `file` field. Image MIME types only (jpeg/png/gif/webp/avif), 5 MB cap.", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + { + "name": "threadId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": ["file"] + } + } + } + }, + "responses": { + "201": { + "description": "Attachment created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_att_01h455vb4pex5vsknk084sn02q" + }, + "threadId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "uploadedByPrincipalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "filename": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "storageKey": { + "type": "string" + }, + "publicUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "threadId", + "uploadedByPrincipalId", + "filename", + "mimeType", + "sizeBytes", + "storageKey", + "publicUrl", + "createdAt" + ], + "additionalProperties": false, + "description": "Ticket attachment metadata" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Attachment" + } + } + } + }, + "400": { + "description": "Invalid upload", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "404": { + "description": "Ticket or thread not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/tickets/{ticketId}/threads/{threadId}/attachments/{attachmentId}": { + "delete": { + "tags": ["Tickets"], + "summary": "Delete an attachment", + "description": "Removes the metadata row. Allowed for the original uploader or any caller with `ticket.edit_fields`.", + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + { + "name": "threadId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + { + "name": "attachmentId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "403": { + "description": "Not the uploader and lacks ticket.edit_fields" + }, + "404": { + "description": "Ticket / thread / attachment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + } + }, + "/ticket-statuses": { + "get": { + "tags": ["Ticket Statuses"], + "summary": "List ticket statuses", + "description": "Returns the workspace ticket-status catalogue, ordered by position.", + "parameters": [ + { + "name": "includeDeleted", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Include archived statuses" + } + ], + "responses": { + "200": { + "description": "List of ticket statuses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_status_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "feature-requests" + }, + "color": { + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#3b82f6" + }, + { + "type": "null" + } + ] + }, + "category": { + "type": "string", + "enum": ["open", "pending", "on_hold", "solved", "closed"] + }, + "position": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "isDefault": { + "type": "boolean" + }, + "isSystem": { + "type": "boolean", + "description": "System statuses cannot be archived" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "deletedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "color", + "category", + "position", + "isDefault", + "isSystem", + "createdAt", + "deletedAt" + ], + "additionalProperties": false, + "description": "Ticket workflow status (workflow state)" + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Statuses" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + }, + "post": { + "tags": ["Ticket Statuses"], + "summary": "Create a ticket status (admin)", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-z0-9_-]+$" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#3b82f6" + }, + "category": { + "type": "string", + "enum": ["open", "pending", "on_hold", "solved", "closed"] + }, + "position": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "isDefault": { + "type": "boolean" + } + }, + "required": ["name", "slug", "category"], + "description": "Create ticket-status request body" + } + } + } + }, + "responses": { + "201": { + "description": "Status created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_status_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "feature-requests" + }, + "color": { + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#3b82f6" + }, + { + "type": "null" + } + ] + }, + "category": { + "type": "string", + "enum": ["open", "pending", "on_hold", "solved", "closed"] + }, + "position": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "isDefault": { + "type": "boolean" + }, + "isSystem": { + "type": "boolean", + "description": "System statuses cannot be archived" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "deletedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "color", + "category", + "position", + "isDefault", + "isSystem", + "createdAt", + "deletedAt" + ], + "additionalProperties": false, + "description": "Ticket workflow status (workflow state)" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Status" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + }, + "409": { + "description": "Slug already in use" + } + } + } + }, + "/ticket-statuses/{statusId}": { + "get": { + "tags": ["Ticket Statuses"], + "summary": "Get a ticket status", + "parameters": [ + { + "name": "statusId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_status_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "feature-requests" + }, + "color": { + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#3b82f6" + }, + { + "type": "null" + } + ] + }, + "category": { + "type": "string", + "enum": ["open", "pending", "on_hold", "solved", "closed"] + }, + "position": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "isDefault": { + "type": "boolean" + }, + "isSystem": { + "type": "boolean", + "description": "System statuses cannot be archived" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "deletedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "color", + "category", + "position", + "isDefault", + "isSystem", + "createdAt", + "deletedAt" + ], + "additionalProperties": false, + "description": "Ticket workflow status (workflow state)" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Status" + } + } + } + }, + "404": { + "description": "Status not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "patch": { + "tags": ["Ticket Statuses"], + "summary": "Update a ticket status (admin)", + "parameters": [ + { + "name": "statusId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#3b82f6" + }, + "category": { + "type": "string", + "enum": ["open", "pending", "on_hold", "solved", "closed"] + }, + "position": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "isDefault": { + "type": "boolean" + } + }, + "description": "Update ticket-status request body" + } + } + } + }, + "responses": { + "200": { + "description": "Status updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "ticket_status_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "URL-friendly identifier", + "example": "feature-requests" + }, + "color": { + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Hex color code", + "example": "#3b82f6" + }, + { + "type": "null" + } + ] + }, + "category": { + "type": "string", + "enum": ["open", "pending", "on_hold", "solved", "closed"] + }, + "position": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "isDefault": { + "type": "boolean" + }, + "isSystem": { + "type": "boolean", + "description": "System statuses cannot be archived" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "deletedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "color", + "category", + "position", + "isDefault", + "isSystem", + "createdAt", + "deletedAt" + ], + "additionalProperties": false, + "description": "Ticket workflow status (workflow state)" + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Status" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "404": { + "description": "Status not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + } + } + }, + "delete": { + "tags": ["Ticket Statuses"], + "summary": "Archive a ticket status (admin)", + "description": "Soft-archives the status (sets `deletedAt`). Returns 409 if any active ticket still references the status, and rejects with 400 for system statuses.", + "parameters": [ + { + "name": "statusId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Archived" + }, + "400": { + "description": "System status — cannot archive", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "VALIDATION_ERROR" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Request validation failed" + } + } + } + }, + "404": { + "description": "Status not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "NOT_FOUND" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Resource not found" + } + } + } + }, + "409": { + "description": "Status is still referenced by active tickets" + } + } + } + }, + "/inboxes": { + "get": { + "tags": ["Support Config"], + "summary": "List inboxes", + "responses": { + "200": { + "description": "Inboxes", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "inbox_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "defaultVisibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "defaultPriority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "defaultStatusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "primaryTeamId", + "defaultVisibilityScope", + "defaultPriority", + "defaultStatusId", + "color", + "icon", + "archivedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Inboxes" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + }, + "post": { + "tags": ["Support Config"], + "summary": "Create an inbox", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "slug": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "defaultVisibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "defaultPriority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "color": { + "type": "string" + }, + "icon": { + "type": "string" + } + }, + "required": ["name", "slug"] + } + } + } + }, + "responses": { + "201": { + "description": "Inbox created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "inbox_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "defaultVisibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "defaultPriority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "defaultStatusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "primaryTeamId", + "defaultVisibilityScope", + "defaultPriority", + "defaultStatusId", + "color", + "icon", + "archivedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Inbox" + } + } + } + } + } + } + }, + "/inboxes/{inboxId}": { + "get": { + "tags": ["Support Config"], + "summary": "Get an inbox", + "parameters": [ + { + "name": "inboxId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Inbox", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "inbox_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "primaryTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "defaultVisibilityScope": { + "type": "string", + "enum": ["team", "org", "shared", "private"] + }, + "defaultPriority": { + "type": "string", + "enum": ["low", "normal", "high", "urgent"] + }, + "defaultStatusId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "primaryTeamId", + "defaultVisibilityScope", + "defaultPriority", + "defaultStatusId", + "color", + "icon", + "archivedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Inbox" + } + } + } + } + } + }, + "patch": { + "tags": ["Support Config"], + "summary": "Update an inbox", + "parameters": [ + { + "name": "inboxId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Updated" + } + } + }, + "delete": { + "tags": ["Support Config"], + "summary": "Archive an inbox", + "parameters": [ + { + "name": "inboxId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Archived" + } + } + } + }, + "/inboxes/{inboxId}/members": { + "get": { + "tags": ["Support Config"], + "summary": "List inbox members", + "parameters": [ + { + "name": "inboxId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Members" + } + } + }, + "post": { + "tags": ["Support Config"], + "summary": "Add inbox member", + "parameters": [ + { + "name": "inboxId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "201": { + "description": "Added" + } + } + } + }, + "/inboxes/{inboxId}/channels": { + "get": { + "tags": ["Support Config"], + "summary": "List channels", + "parameters": [ + { + "name": "inboxId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Channels" + } + } + }, + "post": { + "tags": ["Support Config"], + "summary": "Create a channel", + "parameters": [ + { + "name": "inboxId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "201": { + "description": "Created" + } + } + } + }, + "/routing-rules": { + "get": { + "tags": ["Routing"], + "summary": "List routing rules (priority-ordered)", + "responses": { + "200": { + "description": "Rules", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "route_rule_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "priority": { + "type": "number" + }, + "enabled": { + "type": "boolean" + }, + "conditions": {}, + "actions": {}, + "inboxIdScope": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "matchCount": { + "type": "number" + }, + "lastMatchedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "description", + "priority", + "enabled", + "conditions", + "actions", + "inboxIdScope", + "matchCount", + "lastMatchedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Rules" + } + } + } + } + } + }, + "post": { + "tags": ["Routing"], + "summary": "Create a routing rule", + "responses": { + "201": { + "description": "Created" + } + } + } + }, + "/routing-rules/{ruleId}": { + "get": { + "tags": ["Routing"], + "summary": "Get a routing rule", + "parameters": [ + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Rule" + } + } + }, + "patch": { + "tags": ["Routing"], + "summary": "Update a routing rule", + "parameters": [ + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Updated" + } + } + }, + "delete": { + "tags": ["Routing"], + "summary": "Delete a routing rule", + "parameters": [ + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Deleted" + } + } + } + }, + "/routing-rules/preview": { + "post": { + "tags": ["Routing"], + "summary": "Stateless routing dry-run", + "description": "Returns the matched rule + resolved decision for a hypothetical incoming ticket.", + "responses": { + "200": { + "description": "Decision" + } + } + } + }, + "/business-hours": { + "get": { + "tags": ["SLA"], + "summary": "List business-hours calendars", + "responses": { + "200": { + "description": "Calendars", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "bizhrs_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "timezone": { + "type": "string", + "example": "America/New_York" + }, + "schedule": {}, + "holidays": {}, + "archivedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "timezone", + "schedule", + "holidays", + "archivedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Calendars" + } + } + } + } + } + }, + "post": { + "tags": ["SLA"], + "summary": "Create a business-hours calendar", + "responses": { + "201": { + "description": "Created" + } + } + } + }, + "/business-hours/{id}": { + "get": { + "tags": ["SLA"], + "summary": "Get a calendar", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Calendar" + } + } + }, + "patch": { + "tags": ["SLA"], + "summary": "Update a calendar", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Updated" + } + } + }, + "delete": { + "tags": ["SLA"], + "summary": "Archive a calendar", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Archived" + } + } + } + }, + "/sla-policies": { + "get": { + "tags": ["SLA"], + "summary": "List SLA policies", + "responses": { + "200": { + "description": "Policies", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "sla_pol_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "scope": { + "type": "string", + "enum": ["workspace", "team", "inbox"] + }, + "scopeTeamId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "scopeInboxId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "priority": { + "type": "number" + }, + "enabled": { + "type": "boolean" + }, + "appliesToPriorities": { + "type": "array", + "items": { + "type": "string" + } + }, + "businessHoursId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "pauseOnPending": { + "type": "boolean" + }, + "pauseOnOnHold": { + "type": "boolean" + }, + "archivedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "description", + "scope", + "scopeTeamId", + "scopeInboxId", + "priority", + "enabled", + "appliesToPriorities", + "businessHoursId", + "pauseOnPending", + "pauseOnOnHold", + "archivedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Policies" + } + } + } + } + } + }, + "post": { + "tags": ["SLA"], + "summary": "Create an SLA policy", + "responses": { + "201": { + "description": "Created" + } + } + } + }, + "/sla-policies/{policyId}": { + "get": { + "tags": ["SLA"], + "summary": "Get a policy", + "parameters": [ + { + "name": "policyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Policy" + } + } + }, + "patch": { + "tags": ["SLA"], + "summary": "Update a policy", + "parameters": [ + { + "name": "policyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Updated" + } + } + }, + "delete": { + "tags": ["SLA"], + "summary": "Archive a policy", + "parameters": [ + { + "name": "policyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Archived" + } + } + } + }, + "/sla-policies/{policyId}/targets": { + "get": { + "tags": ["SLA"], + "summary": "List targets for a policy", + "parameters": [ + { + "name": "policyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Targets" + } + } + }, + "post": { + "tags": ["SLA"], + "summary": "Add a target", + "parameters": [ + { + "name": "policyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "201": { + "description": "Created" + } + } + }, + "patch": { + "tags": ["SLA"], + "summary": "Replace target set", + "parameters": [ + { + "name": "policyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Replaced" + } + } + } + }, + "/sla-policies/{policyId}/escalation-rules": { + "get": { + "tags": ["SLA"], + "summary": "List escalation rules for a policy", + "parameters": [ + { + "name": "policyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Rules" + } + } + }, + "post": { + "tags": ["SLA"], + "summary": "Create an escalation rule", + "parameters": [ + { + "name": "policyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "201": { + "description": "Created" + } + } + } + }, + "/sla-policies/{policyId}/escalation-rules/{ruleId}": { + "patch": { + "tags": ["SLA"], + "summary": "Update an escalation rule", + "parameters": [ + { + "name": "policyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Updated" + } + } + }, + "delete": { + "tags": ["SLA"], + "summary": "Delete an escalation rule", + "parameters": [ + { + "name": "policyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Deleted" + } + } + } + }, + "/internal/sla-tick": { + "post": { + "tags": ["SLA"], + "summary": "Run one SLA escalation tick (internal cron endpoint)", + "description": "Protected by `x-internal-secret` header. Designed for pg_cron or external scheduler. Idempotent; safe to call concurrently.", + "responses": { + "200": { + "description": "Counters: { escalated, breached }" + }, + "401": { + "description": "Bad shared secret" + } + } + } + }, + "/organizations": { + "get": { + "tags": ["Organizations"], + "summary": "List organizations", + "parameters": [ + { + "name": "search", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + ], + "responses": { + "200": { + "description": "Organizations", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "org_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "externalId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "website": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "domain", + "externalId", + "website", + "notes", + "archivedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Organizations" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "UNAUTHORIZED" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["error"], + "additionalProperties": false, + "description": "Authentication required or invalid API key" + } + } + } + } + } + }, + "post": { + "tags": ["Organizations"], + "summary": "Create an organization", + "responses": { + "201": { + "description": "Created" + } + } + } + }, + "/organizations/{organizationId}": { + "get": { + "tags": ["Organizations"], + "summary": "Get organization", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Organization" + } + } + }, + "patch": { + "tags": ["Organizations"], + "summary": "Update organization", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Updated" + } + } + }, + "delete": { + "tags": ["Organizations"], + "summary": "Archive organization", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Archived" + } + } + } + }, + "/organizations/{organizationId}/contacts": { + "get": { + "tags": ["Organizations"], + "summary": "List contacts for an organization", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Contacts" + } + } + } + }, + "/contacts": { + "get": { + "tags": ["Contacts"], + "summary": "List contacts", + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "email", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "organizationId", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + ], + "responses": { + "200": { + "description": "Contacts", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "contact_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "phone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "externalId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "organizationId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "avatarUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "email", + "phone", + "title", + "externalId", + "organizationId", + "avatarUrl", + "archivedAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Contacts" + } + } + } + } + } + }, + "post": { + "tags": ["Contacts"], + "summary": "Create a contact", + "responses": { + "201": { + "description": "Created" + } + } + } + }, + "/contacts/{contactId}": { + "get": { + "tags": ["Contacts"], + "summary": "Get contact", + "parameters": [ + { + "name": "contactId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Contact" + } + } + }, + "patch": { + "tags": ["Contacts"], + "summary": "Update contact", + "parameters": [ + { + "name": "contactId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Updated" + } + } + }, + "delete": { + "tags": ["Contacts"], + "summary": "Archive contact", + "parameters": [ + { + "name": "contactId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Archived" + } + } + } + }, + "/contacts/{contactId}/links": { + "post": { + "tags": ["Contacts"], + "summary": "Link contact to a portal user", + "parameters": [ + { + "name": "contactId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "201": { + "description": "Linked" + } + } + }, + "delete": { + "tags": ["Contacts"], + "summary": "Unlink contact from a portal user", + "parameters": [ + { + "name": "contactId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Unlinked" + } + } + } + }, + "/audit-events": { + "get": { + "tags": ["Audit"], + "summary": "List audit events", + "parameters": [ + { + "name": "principalId", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "action", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "actionPrefix", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "targetType", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "targetId", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "source", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "enum", + "entries": { + "web": "web", + "api": "api", + "integration": "integration", + "system": "system", + "mcp": "mcp" + } + }, + "type": "enum", + "enum": { + "web": "web", + "api": "api", + "integration": "integration", + "system": "system", + "mcp": "mcp" + }, + "options": ["web", "api", "integration", "system", "mcp"] + } + }, + "type": "optional" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string", + "checks": [ + { + "def": { + "type": "string", + "format": "datetime", + "check": "string_format", + "offset": false, + "local": false, + "precision": null, + "pattern": {} + }, + "type": "string", + "format": "datetime", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "datetime", + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string", + "checks": [ + { + "def": { + "type": "string", + "format": "datetime", + "check": "string_format", + "offset": false, + "local": false, + "precision": null, + "pattern": {} + }, + "type": "string", + "format": "datetime", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "datetime", + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "number", + "coerce": true, + "checks": [{}, {}] + }, + "type": "number", + "minValue": 1, + "maxValue": 200, + "isInt": false, + "isFinite": true, + "format": null + } + }, + "type": "optional" + } + } + ], + "responses": { + "200": { + "description": "Audit events", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "audit_01h455vb4pex5vsknk084sn02q" + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ] + }, + "action": { + "type": "string" + }, + "targetType": { + "type": "string" + }, + "targetId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "source": { + "type": "string", + "enum": ["web", "api", "integration", "system", "mcp"] + }, + "ipAddress": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "userAgent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "diff": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "principalId", + "action", + "targetType", + "targetId", + "source", + "ipAddress", + "userAgent", + "diff", + "createdAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Audit events" + } + } + } + } + } + } + }, + "/api-keys": { + "get": { + "tags": ["API Keys"], + "summary": "List API keys", + "responses": { + "200": { + "description": "API keys", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "apikey_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "keyPrefix": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowedTeamIds": { + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "allowedInboxIds": { + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "compatLegacyFullAccess": { + "type": "boolean" + }, + "compatAcknowledgedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "lastIp": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastUserAgent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "rotatedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "revokedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "lastUsedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "keyPrefix", + "scopes", + "allowedTeamIds", + "allowedInboxIds", + "compatLegacyFullAccess", + "compatAcknowledgedAt", + "lastIp", + "lastUserAgent", + "rotatedAt", + "expiresAt", + "revokedAt", + "createdAt", + "lastUsedAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "API keys" + } + } + } + } + } + }, + "post": { + "tags": ["API Keys"], + "summary": "Create a scoped API key", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowedTeamIds": { + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "allowedInboxIds": { + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + } + }, + "required": ["name"] + } + } + } + }, + "responses": { + "201": { + "description": "API key created (plaintext returned ONCE)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "apikey_01h455vb4pex5vsknk084sn02q" + }, + "name": { + "type": "string" + }, + "keyPrefix": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowedTeamIds": { + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "allowedInboxIds": { + "type": "array", + "items": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + } + }, + "compatLegacyFullAccess": { + "type": "boolean" + }, + "compatAcknowledgedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "lastIp": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastUserAgent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "rotatedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "revokedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "lastUsedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + }, + "plaintextKey": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "keyPrefix", + "scopes", + "allowedTeamIds", + "allowedInboxIds", + "compatLegacyFullAccess", + "compatAcknowledgedAt", + "lastIp", + "lastUserAgent", + "rotatedAt", + "expiresAt", + "revokedAt", + "createdAt", + "lastUsedAt", + "plaintextKey" + ], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "API key + plaintext" + } + } + } + } + } + } + }, + "/api-keys/{apiKeyId}": { + "get": { + "tags": ["API Keys"], + "summary": "Get API key", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "API key" + } + } + }, + "patch": { + "tags": ["API Keys"], + "summary": "Update API key (name + scopes + allow-lists)", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Updated" + } + } + }, + "delete": { + "tags": ["API Keys"], + "summary": "Revoke API key", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "204": { + "description": "Revoked" + } + } + } + }, + "/api-keys/{apiKeyId}/rotate": { + "post": { + "tags": ["API Keys"], + "summary": "Rotate API key (returns new plaintext)", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Rotated" + } + } + } + }, + "/api-keys/{apiKeyId}/acknowledge-legacy": { + "post": { + "tags": ["API Keys"], + "summary": "Acknowledge legacy unscoped status", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + ], + "responses": { + "200": { + "description": "Acknowledged" + } + } + } + }, + "/webhooks/{webhookId}/deliveries": { + "get": { + "tags": ["Webhooks"], + "summary": "List delivery attempts for a webhook", + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + { + "name": "status", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "enum", + "entries": { + "queued": "queued", + "success": "success", + "failed_retryable": "failed_retryable", + "failed_terminal": "failed_terminal", + "blocked_ssrf": "blocked_ssrf" + } + }, + "type": "enum", + "enum": { + "queued": "queued", + "success": "success", + "failed_retryable": "failed_retryable", + "failed_terminal": "failed_terminal", + "blocked_ssrf": "blocked_ssrf" + }, + "options": [ + "queued", + "success", + "failed_retryable", + "failed_terminal", + "blocked_ssrf" + ] + } + }, + "type": "optional" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "cursorAttemptedAt", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "cursorId", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "def": { + "type": "optional", + "innerType": { + "def": { + "type": "number", + "coerce": true, + "checks": [{}, {}] + }, + "type": "number", + "minValue": 1, + "maxValue": 200, + "isInt": false, + "isFinite": true, + "format": null + } + }, + "type": "optional" + } + } + ], + "responses": { + "200": { + "description": "Delivery attempts (newest first)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "wh_deliv_01h455vb4pex5vsknk084sn02q" + }, + "webhookId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "eventId": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "attemptNumber": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "success", + "failed_retryable", + "failed_terminal", + "blocked_ssrf" + ] + }, + "httpStatus": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "errorMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "requestUrl": { + "type": "string" + }, + "requestPayloadBytes": { + "type": "number" + }, + "responseBodySnippet": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latencyMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "signatureTimestamp": { + "type": "number" + }, + "attemptedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "nextRetryAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp or null", + "example": "2024-01-15T10:30:00.000Z" + } + }, + "required": [ + "id", + "webhookId", + "eventId", + "eventType", + "attemptNumber", + "status", + "httpStatus", + "errorMessage", + "requestUrl", + "requestPayloadBytes", + "responseBodySnippet", + "latencyMs", + "signatureTimestamp", + "attemptedAt", + "nextRetryAt" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "cursor": { + "description": "Cursor for next page, null if no more pages", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items" + }, + "total": { + "description": "Total count (when available)", + "type": "number" + } + }, + "required": ["hasMore"], + "additionalProperties": false + } + }, + "required": ["pagination"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false, + "description": "Deliveries" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "__schema0": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "comment_01h455vb4pex5vsknk084sn02q" + }, + "postId": { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ], + "description": "Parent comment ID for replies" + }, + "content": { + "type": "string", + "example": "Great idea! This would be very useful." + }, + "authorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "example": "Jane Doe" + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "description": "TypeID - a type-prefixed UUID", + "example": "post_01h455vb4pex5vsknk084sn02q" + }, + { + "type": "null" + } + ], + "description": "Principal ID of the comment author" + }, + "isTeamMember": { + "type": "boolean", + "description": "Whether the author is a team member", + "example": false + }, + "isPrivate": { + "type": "boolean", + "description": "Whether the comment is only visible to team members", + "example": false + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp", + "example": "2024-01-15T10:30:00.000Z" + }, + "reactions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "emoji": { + "type": "string", + "example": "👍" + }, + "count": { + "type": "number", + "example": 3 + }, + "hasReacted": { + "type": "boolean", + "description": "Whether the authenticated user has reacted with this emoji" + } + }, + "required": ["emoji", "count", "hasReacted"], + "additionalProperties": false + }, + "description": "Aggregated reaction counts" + }, + "replies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/__schema0" + }, + "description": "Nested reply comments" + } + }, + "required": [ + "id", + "postId", + "parentId", + "content", + "authorName", + "principalId", + "isTeamMember", + "isPrivate", + "createdAt", + "reactions", + "replies" + ], + "additionalProperties": false + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "API Key", + "description": "API key authentication. Format: Bearer qb_xxx" + } + } + } +} diff --git a/apps/web/scripts/backfill-ticket-contacts.ts b/apps/web/scripts/backfill-ticket-contacts.ts new file mode 100644 index 000000000..d457edb97 --- /dev/null +++ b/apps/web/scripts/backfill-ticket-contacts.ts @@ -0,0 +1,319 @@ +#!/usr/bin/env bun +/** + * Backfill historical data for the portal-tickets identity model. + * + * Closes the gap left by Phases A + B for rows that already existed before + * those landed: + * + * Phase 1 — link existing portal users to CRM contacts via + * `contact_user_links`. Mirrors the better-auth + * `databaseHooks.user.create.after` behavior, applied + * retroactively. Gated on `emailVerified = true` so we only + * link identities the user has demonstrably proven they own. + * + * Phase 2 — populate `tickets.requesterContactId` for tickets that were + * filed with a `requesterPrincipalId` but no contact (the + * pre-Phase-B portal-creation path). Resolves principal → user + * → email → contact and writes the contact id back. + * + * Both phases are idempotent — re-running is a no-op for rows already in + * the desired state. Use `--dry-run` for a preview that reports candidate + * counts without writing. + * + * Usage: + * bun apps/web/scripts/backfill-ticket-contacts.ts # Run both phases + * bun apps/web/scripts/backfill-ticket-contacts.ts --dry-run # Preview + * bun apps/web/scripts/backfill-ticket-contacts.ts --users-only + * bun apps/web/scripts/backfill-ticket-contacts.ts --tickets-only + * bun apps/web/scripts/backfill-ticket-contacts.ts --batch-size=200 + * bun apps/web/scripts/backfill-ticket-contacts.ts --limit=1000 + * bun apps/web/scripts/backfill-ticket-contacts.ts --help + * + * Environment: + * DATABASE_URL — Required. PostgreSQL connection string. + */ + +// Load .env if available — same pattern as sibling backfill scripts. +try { + const { config } = await import('dotenv') + config({ path: '.env', quiet: true }) +} catch { + // dotenv not available, rely on environment variables +} + +import { db, eq, and, isNull, isNotNull, gt, asc, tickets, principal, user } from '@/lib/server/db' +import type { ContactId, PrincipalId, TicketId, UserId } from '@quackback/ids' +import { linkContactForUser } from '@/lib/server/auth/link-contact' +import { + findOrCreateByEmail, + linkContactToUser, +} from '@/lib/server/domains/organizations/contact.service' + +// --------------------------------------------------------------------------- +// CLI parsing +// --------------------------------------------------------------------------- + +interface Flags { + dryRun: boolean + usersOnly: boolean + ticketsOnly: boolean + batchSize: number + limit: number | null +} + +function printUsage(): void { + console.log(`Backfill ticket-contact identity links. + +Usage: + bun apps/web/scripts/backfill-ticket-contacts.ts [flags] + +Flags: + --dry-run Preview without writing. + --users-only Skip the ticket backfill phase. + --tickets-only Skip the user-link phase. + --batch-size=N Rows per batch (default 100, max 500). + --limit=N Cap total processed rows per phase. + --help Show this message. + +Environment: + DATABASE_URL Required. PostgreSQL connection string. +`) +} + +function parseFlags(argv: string[]): Flags { + const dryRun = argv.includes('--dry-run') + const usersOnly = argv.includes('--users-only') + const ticketsOnly = argv.includes('--tickets-only') + if (usersOnly && ticketsOnly) { + console.error( + '[backfill-ticket-contacts] --users-only and --tickets-only are mutually exclusive' + ) + process.exit(2) + } + const batchSizeArg = argv.find((a) => a.startsWith('--batch-size=')) + let batchSize = batchSizeArg ? parseInt(batchSizeArg.split('=')[1] ?? '', 10) : 100 + if (!Number.isFinite(batchSize) || batchSize <= 0) batchSize = 100 + if (batchSize > 500) batchSize = 500 + const limitArg = argv.find((a) => a.startsWith('--limit=')) + const limit = limitArg ? parseInt(limitArg.split('=')[1] ?? '', 10) : NaN + return { + dryRun, + usersOnly, + ticketsOnly, + batchSize, + limit: Number.isFinite(limit) && limit > 0 ? limit : null, + } +} + +// --------------------------------------------------------------------------- +// Phase 1 — link existing verified users to contacts +// --------------------------------------------------------------------------- + +interface UserPhaseStats { + candidates: number + processed: number + errors: number +} + +async function phase1LinkUsers(flags: Flags): Promise { + const stats: UserPhaseStats = { candidates: 0, processed: 0, errors: 0 } + + let cursor: string | null = null + while (true) { + if (flags.limit != null && stats.candidates >= flags.limit) break + const remaining = flags.limit != null ? flags.limit - stats.candidates : flags.batchSize + const take = Math.min(flags.batchSize, remaining) + + const rows = await db + .select({ id: user.id, email: user.email, isAnonymous: user.isAnonymous }) + .from(user) + .where( + and( + isNotNull(user.email), + eq(user.emailVerified, true), + eq(user.isAnonymous, false), + cursor ? gt(user.id, cursor) : undefined + ) + ) + .orderBy(asc(user.id)) + .limit(take) + if (rows.length === 0) break + + for (const row of rows) { + stats.candidates += 1 + cursor = row.id + if (!row.email) continue // belt-and-suspenders; filtered above + if (flags.dryRun) continue + try { + await linkContactForUser({ + userId: row.id as UserId, + email: row.email, + emailVerified: true, + anonymous: row.isAnonymous, + }) + stats.processed += 1 + } catch (err) { + stats.errors += 1 + console.error('[backfill-ticket-contacts] phase1 user error', { + userId: row.id, + error: err instanceof Error ? err.message : err, + }) + } + } + + if (rows.length < take) break + } + + return stats +} + +// --------------------------------------------------------------------------- +// Phase 2 — backfill tickets.requesterContactId +// --------------------------------------------------------------------------- + +interface TicketPhaseStats { + candidates: number + processed: number + missingEmail: number + errors: number +} + +async function phase2BackfillTickets(flags: Flags): Promise { + const stats: TicketPhaseStats = { candidates: 0, processed: 0, missingEmail: 0, errors: 0 } + + let cursor: string | null = null + while (true) { + if (flags.limit != null && stats.candidates >= flags.limit) break + const remaining = flags.limit != null ? flags.limit - stats.candidates : flags.batchSize + const take = Math.min(flags.batchSize, remaining) + + const rows = await db + .select({ + id: tickets.id, + requesterPrincipalId: tickets.requesterPrincipalId, + }) + .from(tickets) + .where( + and( + isNotNull(tickets.requesterPrincipalId), + isNull(tickets.requesterContactId), + isNull(tickets.deletedAt), + cursor ? gt(tickets.id, cursor) : undefined + ) + ) + .orderBy(asc(tickets.id)) + .limit(take) + if (rows.length === 0) break + + for (const row of rows) { + stats.candidates += 1 + cursor = row.id + try { + const contactId = await resolveContactForTicket(row.requesterPrincipalId as PrincipalId) + if (!contactId) { + stats.missingEmail += 1 + continue + } + if (flags.dryRun) { + console.log( + `[backfill-ticket-contacts] [dry-run] would set ticket ${row.id}.requesterContactId = ${contactId}` + ) + continue + } + await db + .update(tickets) + .set({ requesterContactId: contactId }) + .where(eq(tickets.id, row.id as TicketId)) + stats.processed += 1 + } catch (err) { + stats.errors += 1 + console.error('[backfill-ticket-contacts] phase2 ticket error', { + ticketId: row.id, + error: err instanceof Error ? err.message : err, + }) + } + } + + if (rows.length < take) break + } + + return stats +} + +/** + * Resolve the contact id for a ticket's requester principal. + * + * Mirrors `resolveRequesterContactId` in `ticket.service.ts` but exposed at + * script level so we can decide policy (skip vs. error) per row. Returns + * `null` when the principal isn't a user, has no email, or any link + * step fails — those rows are reported as `missingEmail` and skipped. + */ +async function resolveContactForTicket(principalId: PrincipalId): Promise { + const principalRow = await db.query.principal.findFirst({ + where: eq(principal.id, principalId), + columns: { userId: true, type: true }, + }) + if (!principalRow || !principalRow.userId || principalRow.type !== 'user') return null + const userRow = await db.query.user.findFirst({ + where: eq(user.id, principalRow.userId as UserId), + columns: { email: true }, + }) + if (!userRow?.email) return null + const contact = await findOrCreateByEmail({ email: userRow.email }) + // Best-effort link — keeps the user/contact pair joined for future queries. + await linkContactToUser({ + contactId: contact.id, + userId: principalRow.userId as UserId, + linkedByPrincipalId: null, + }) + return contact.id +} + +// --------------------------------------------------------------------------- +// Entrypoint +// --------------------------------------------------------------------------- + +async function main(): Promise { + const argv = process.argv.slice(2) + if (argv.includes('--help') || argv.includes('-h')) { + printUsage() + return + } + if (!process.env.DATABASE_URL) { + console.error('[backfill-ticket-contacts] DATABASE_URL is required') + process.exit(1) + } + const flags = parseFlags(argv) + + console.log('[backfill-ticket-contacts] start', { + dryRun: flags.dryRun, + usersOnly: flags.usersOnly, + ticketsOnly: flags.ticketsOnly, + batchSize: flags.batchSize, + limit: flags.limit, + }) + + let userStats: UserPhaseStats | null = null + let ticketStats: TicketPhaseStats | null = null + + if (!flags.ticketsOnly) { + console.log('[backfill-ticket-contacts] phase 1 — link users to contacts') + userStats = await phase1LinkUsers(flags) + console.log('[backfill-ticket-contacts] phase 1 done', userStats) + } + if (!flags.usersOnly) { + console.log('[backfill-ticket-contacts] phase 2 — backfill ticket.requesterContactId') + ticketStats = await phase2BackfillTickets(flags) + console.log('[backfill-ticket-contacts] phase 2 done', ticketStats) + } + + console.log('[backfill-ticket-contacts] summary', { + users: userStats, + tickets: ticketStats, + }) + + const errors = (userStats?.errors ?? 0) + (ticketStats?.errors ?? 0) + process.exit(errors > 0 ? 1 : 0) +} + +await main() diff --git a/apps/web/src/components/admin/admin-sidebar.tsx b/apps/web/src/components/admin/admin-sidebar.tsx index 38764a36f..9d4a0d9c5 100644 --- a/apps/web/src/components/admin/admin-sidebar.tsx +++ b/apps/web/src/components/admin/admin-sidebar.tsx @@ -12,6 +12,8 @@ import { BookOpenIcon, ChartBarIcon, QuestionMarkCircleIcon, + TicketIcon, + BuildingOffice2Icon, } from '@heroicons/react/24/solid' import { Button } from '@/components/ui/button' import { Avatar } from '@/components/ui/avatar' @@ -30,6 +32,8 @@ import { NotificationBell } from '@/components/notifications' import { cn } from '@/lib/shared/utils' import { ScrollArea } from '@/components/ui/scroll-area' import type { LatestVersionResult } from '@/lib/server/functions/version' +import { useMyPermissions } from '@/lib/client/hooks/use-authz-queries' +import { PERMISSIONS, type PermissionKey } from '@/lib/server/domains/authz' interface AdminSidebarProps { initialUserData?: { @@ -40,8 +44,28 @@ interface AdminSidebarProps { latestVersion?: LatestVersionResult | null } -const navItems = [ +interface NavItemDef { + label: string + href: string + icon: typeof ChatBubbleLeftIcon + /** Optional permission gate; nav entry is hidden when actor lacks the permission. */ + requiresPermission?: PermissionKey +} + +const navItems: NavItemDef[] = [ { label: 'Feedback', href: '/admin/feedback', icon: ChatBubbleLeftIcon }, + { + label: 'Tickets', + href: '/admin/tickets', + icon: TicketIcon, + requiresPermission: PERMISSIONS.TICKET_VIEW_ALL, + }, + { + label: 'Contacts', + href: '/admin/contacts', + icon: BuildingOffice2Icon, + requiresPermission: PERMISSIONS.ORG_VIEW, + }, { label: 'Roadmap', href: '/admin/roadmap', icon: MapIcon }, { label: 'Changelog', href: '/admin/changelog', icon: DocumentTextIcon }, { label: 'Help Center', href: '/admin/help-center', icon: BookOpenIcon }, @@ -94,10 +118,19 @@ export function AdminSidebar({ initialUserData, latestVersion }: AdminSidebarPro const { session, settings } = useRouteContext({ from: '__root__' }) const pathname = useRouterState({ select: (s) => s.location.pathname }) const flags = settings?.featureFlags as { analytics?: boolean; helpCenter?: boolean } | undefined + const { data: myPerms } = useMyPermissions() const filteredNavItems = navItems.filter((item) => { if (item.href === '/admin/analytics') return flags?.analytics ?? false if (item.href === '/admin/help-center') return flags?.helpCenter ?? false + if (item.requiresPermission) { + if (!myPerms) return false + const ws = myPerms.workspacePermissions.includes(item.requiresPermission) + const team = myPerms.teamPermissions.some((t) => + t.permissions.includes(item.requiresPermission!) + ) + return ws || team + } return true }) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) diff --git a/apps/web/src/components/admin/contacts/contact-create-dialog.tsx b/apps/web/src/components/admin/contacts/contact-create-dialog.tsx new file mode 100644 index 000000000..fb9d6172f --- /dev/null +++ b/apps/web/src/components/admin/contacts/contact-create-dialog.tsx @@ -0,0 +1,177 @@ +/** + * Create-contact dialog. Optional `defaultOrganizationId` prop pre-selects the + * org picker (used from the org-detail Contacts tab). + */ +import { useState, useEffect } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useRouter } from '@tanstack/react-router' +import { toast } from 'sonner' +import type { ContactId, OrganizationId } from '@quackback/ids' +import { createContactFn } from '@/lib/server/functions/contacts' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { OrgPicker } from '@/components/admin/shared/org-picker' + +interface Props { + trigger: React.ReactNode + defaultOrganizationId?: OrganizationId +} + +export function ContactCreateDialog({ trigger, defaultOrganizationId }: Props) { + const [open, setOpen] = useState(false) + const router = useRouter() + const qc = useQueryClient() + + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') + const [title, setTitle] = useState('') + const [externalId, setExternalId] = useState('') + const [organizationId, setOrganizationId] = useState( + defaultOrganizationId ?? null + ) + + useEffect(() => { + if (open) setOrganizationId(defaultOrganizationId ?? null) + }, [open, defaultOrganizationId]) + + const reset = () => { + setName('') + setEmail('') + setPhone('') + setTitle('') + setExternalId('') + setOrganizationId(defaultOrganizationId ?? null) + } + + const mutation = useMutation({ + mutationFn: () => + createContactFn({ + data: { + name: name.trim() || null, + email: email.trim() || null, + phone: phone.trim() || null, + title: title.trim() || null, + externalId: externalId.trim() || null, + organizationId: organizationId ?? null, + }, + }), + onSuccess: (contact) => { + qc.invalidateQueries({ queryKey: ['contacts'] }) + toast.success('Contact created') + setOpen(false) + reset() + router.navigate({ + to: '/admin/contacts/people/$contactId', + params: { contactId: contact.id as ContactId }, + }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + return ( + + {trigger} + + + New contact + + People you support — typically a customer or end-user representative. + + + +
{ + e.preventDefault() + if (!name.trim() && !email.trim()) { + toast.error('Name or email is required') + return + } + mutation.mutate() + }} + className="space-y-3" + > +
+
+ + setName(e.target.value)} + maxLength={200} + /> +
+
+ + setEmail(e.target.value)} + maxLength={320} + /> +
+
+
+
+ + setPhone(e.target.value)} + maxLength={64} + /> +
+
+ + setTitle(e.target.value)} + maxLength={120} + /> +
+
+
+ + +
+
+ + setExternalId(e.target.value)} + maxLength={255} + className="font-mono text-xs" + /> +
+ + + + + +
+
+
+ ) +} diff --git a/apps/web/src/components/admin/contacts/contact-linked-users-tab.tsx b/apps/web/src/components/admin/contacts/contact-linked-users-tab.tsx new file mode 100644 index 000000000..8fa5317d8 --- /dev/null +++ b/apps/web/src/components/admin/contacts/contact-linked-users-tab.tsx @@ -0,0 +1,235 @@ +/** + * Linked users tab — manage portal-user links for a contact. + * + * Approach: + * - List existing links (`listLinksForContactFn`) → show userId + linkedAt; + * enrich displayName/email via `searchPrincipalsFn({roleFilter:['user']})` + * client-side Map keyed by userId. + * - Add: `` returns PrincipalId; we + * resolve PrincipalId → UserId via the same map and call `linkContactToUserFn`. + * - Remove: AlertDialog → `unlinkContactFromUserFn`. + */ +import { useState, useMemo } from 'react' +import { useSuspenseQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import type { ContactId, PrincipalId, UserId } from '@quackback/ids' +import { linkContactToUserFn, unlinkContactFromUserFn } from '@/lib/server/functions/contacts' +import { searchPrincipalsFn } from '@/lib/server/functions/principals' +import { contactQueries } from '@/lib/client/queries/contacts' +import { Button } from '@/components/ui/button' +import { Avatar } from '@/components/ui/avatar' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { TrashIcon } from '@heroicons/react/24/outline' +import { PrincipalPicker } from '@/components/admin/shared/principal-picker' +import { PermissionGate } from '@/components/admin/shared/permission-gate' +import { PERMISSIONS } from '@/lib/server/domains/authz' + +export function ContactLinkedUsersTab({ contactId }: { contactId: ContactId }) { + const qc = useQueryClient() + const { data: links } = useSuspenseQuery(contactQueries.links(contactId)) + + // Resolve userId → display info via a single principals query. + const principalsQuery = useQuery({ + queryKey: ['principals', 'allUsers'], + queryFn: () => searchPrincipalsFn({ data: { roleFilter: ['user'], limit: 50 } }), + staleTime: 60_000, + }) + const userMap = useMemo(() => { + const m = new Map< + string, + { principalId: PrincipalId; displayName: string | null; email: string | null } + >() + for (const p of principalsQuery.data ?? []) { + if (p.userId) + m.set(p.userId, { + principalId: p.id, + displayName: p.displayName, + email: p.email, + }) + } + return m + }, [principalsQuery.data]) + + const linkedUserIds = useMemo(() => links.map((l) => l.userId as UserId), [links]) + const excludePrincipalIds = useMemo(() => { + const ids: PrincipalId[] = [] + for (const u of linkedUserIds) { + const info = userMap.get(u) + if (info) ids.push(info.principalId) + } + return ids + }, [linkedUserIds, userMap]) + + const [addPrincipalId, setAddPrincipalId] = useState(null) + + const invalidate = () => + qc.invalidateQueries({ queryKey: contactQueries.links(contactId).queryKey }) + + const linkMutation = useMutation({ + mutationFn: (userId: UserId) => linkContactToUserFn({ data: { contactId, userId } }), + onSuccess: () => { + setAddPrincipalId(null) + invalidate() + toast.success('User linked') + }, + onError: (e: Error) => toast.error(e.message), + }) + + const unlinkMutation = useMutation({ + mutationFn: (userId: UserId) => unlinkContactFromUserFn({ data: { contactId, userId } }), + onSuccess: () => { + invalidate() + toast.success('User unlinked') + }, + onError: (e: Error) => toast.error(e.message), + }) + + const handleAdd = () => { + if (!addPrincipalId) return + // Find userId for the picked principalId. + let resolvedUserId: UserId | null = null + for (const [uid, info] of userMap.entries()) { + if (info.principalId === addPrincipalId) { + resolvedUserId = uid as UserId + break + } + } + if (!resolvedUserId) { + toast.error('Selected principal has no associated user') + return + } + linkMutation.mutate(resolvedUserId) + } + + return ( +
+
+

Linked users

+

+ Portal users associated with this contact. Linking ties their authenticated identity to + ticket history. +

+
+ + +
+
+
+ + +
+ +
+
+
+ +
+ + + + User + Linked at + + + + + {links.length === 0 ? ( + + + No linked users yet. + + + ) : ( + links.map((l) => { + const info = userMap.get(l.userId) + const label = info?.displayName ?? info?.email ?? l.userId + return ( + + +
+ + {label.slice(0, 2).toUpperCase()} + +
+ {label} + {info?.email && info?.displayName && ( + {info.email} + )} +
+
+
+ + {new Date(l.linkedAt).toLocaleString()} + + + + + + + + + + Unlink user? + + The contact will no longer be associated with this portal user. + + + + Cancel + unlinkMutation.mutate(l.userId as UserId)} + > + Unlink + + + + + + +
+ ) + }) + )} +
+
+
+
+ ) +} diff --git a/apps/web/src/components/admin/contacts/contact-list.tsx b/apps/web/src/components/admin/contacts/contact-list.tsx new file mode 100644 index 000000000..d884a2f5f --- /dev/null +++ b/apps/web/src/components/admin/contacts/contact-list.tsx @@ -0,0 +1,89 @@ +/** + * Cross-org contact table. Resolves organization names via list-cache lookup. + */ +import { useMemo } from 'react' +import { Link } from '@tanstack/react-router' +import { useSuspenseQuery, useQuery } from '@tanstack/react-query' +import type { ContactId } from '@quackback/ids' +import { contactQueries } from '@/lib/client/queries/contacts' +import { organizationQueries } from '@/lib/client/queries/organizations' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' + +interface Props { + search: string + showArchived: boolean +} + +export function ContactList({ search, showArchived }: Props) { + const { data: contacts } = useSuspenseQuery( + contactQueries.search({ query: search, includeArchived: showArchived }) + ) + + // Best-effort org name lookup. Stays optional so we don't block the list. + const orgsQuery = useQuery(organizationQueries.list({ includeArchived: true })) + const orgMap = useMemo(() => { + const m = new Map() + for (const o of orgsQuery.data ?? []) m.set(o.id, o.name) + return m + }, [orgsQuery.data]) + + return ( +
+ + + + Name + Email + Organization + Title + Status + + + + {contacts.length === 0 ? ( + + + No contacts. + + + ) : ( + contacts.map((c) => ( + + + + {c.name ?? c.email ?? c.id} + + + {c.email ?? '—'} + + {c.organizationId ? (orgMap.get(c.organizationId) ?? c.organizationId) : '—'} + + {c.title ?? '—'} + + {c.archivedAt ? ( + + Archived + + ) : ( + Active + )} + + + )) + )} + +
+
+ ) +} diff --git a/apps/web/src/components/admin/contacts/contact-overview-tab.tsx b/apps/web/src/components/admin/contacts/contact-overview-tab.tsx new file mode 100644 index 000000000..dd3c63d77 --- /dev/null +++ b/apps/web/src/components/admin/contacts/contact-overview-tab.tsx @@ -0,0 +1,202 @@ +/** + * Editable contact form. Bottom Archive section. No unarchive (backend does + * not expose unarchiveContactFn). + */ +import { useState, useEffect } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import type { Contact } from '@/lib/shared/db-types' +import type { OrganizationId } from '@quackback/ids' +import { updateContactFn, archiveContactFn } from '@/lib/server/functions/contacts' +import { contactQueries } from '@/lib/client/queries/contacts' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { OrgPicker } from '@/components/admin/shared/org-picker' +import { PermissionGate } from '@/components/admin/shared/permission-gate' +import { PERMISSIONS } from '@/lib/server/domains/authz' + +export function ContactOverviewTab({ contact }: { contact: Contact }) { + const qc = useQueryClient() + const [name, setName] = useState(contact.name ?? '') + const [email, setEmail] = useState(contact.email ?? '') + const [phone, setPhone] = useState(contact.phone ?? '') + const [title, setTitle] = useState(contact.title ?? '') + const [externalId, setExternalId] = useState(contact.externalId ?? '') + const [organizationId, setOrganizationId] = useState( + (contact.organizationId as OrganizationId | null) ?? null + ) + + useEffect(() => { + setName(contact.name ?? '') + setEmail(contact.email ?? '') + setPhone(contact.phone ?? '') + setTitle(contact.title ?? '') + setExternalId(contact.externalId ?? '') + setOrganizationId((contact.organizationId as OrganizationId | null) ?? null) + }, [contact]) + + const invalidate = () => { + qc.invalidateQueries({ queryKey: contactQueries.detail(contact.id).queryKey }) + qc.invalidateQueries({ queryKey: ['contacts'] }) + } + + const saveMutation = useMutation({ + mutationFn: () => + updateContactFn({ + data: { + contactId: contact.id, + name: name.trim() || null, + email: email.trim() || null, + phone: phone.trim() || null, + title: title.trim() || null, + externalId: externalId.trim() || null, + organizationId: organizationId ?? null, + }, + }), + onSuccess: () => { + invalidate() + toast.success('Contact updated') + }, + onError: (e: Error) => toast.error(e.message), + }) + + const archiveMutation = useMutation({ + mutationFn: () => archiveContactFn({ data: { contactId: contact.id } }), + onSuccess: () => { + invalidate() + toast.success('Contact archived') + }, + onError: (e: Error) => toast.error(e.message), + }) + + return ( +
+
{ + e.preventDefault() + if (!name.trim() && !email.trim()) { + toast.error('Name or email is required') + return + } + saveMutation.mutate() + }} + className="space-y-3" + > +
+
+ + setName(e.target.value)} + maxLength={200} + /> +
+
+ + setEmail(e.target.value)} + maxLength={320} + /> +
+
+
+
+ + setPhone(e.target.value)} + maxLength={64} + /> +
+
+ + setTitle(e.target.value)} + maxLength={120} + /> +
+
+
+ + +
+
+ + setExternalId(e.target.value)} + maxLength={255} + className="font-mono text-xs" + /> +
+ + +
+ +
+
+
+ + + {contact.archivedAt ? ( +
+
Archived
+

+ This contact is archived. Restoring is not currently supported. +

+
+ ) : ( +
+
Archive
+

+ Archived contacts are hidden from pickers; existing tickets keep their reference. +

+ + + + + + + Archive contact? + + This action cannot be undone from the UI. + + + + Cancel + archiveMutation.mutate()}> + Archive + + + + +
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/admin/contacts/contact-tickets-tab.tsx b/apps/web/src/components/admin/contacts/contact-tickets-tab.tsx new file mode 100644 index 000000000..498269e59 --- /dev/null +++ b/apps/web/src/components/admin/contacts/contact-tickets-tab.tsx @@ -0,0 +1,71 @@ +/** + * Tickets where this contact is the requester. Uses extended `listTicketsFn` + * with `requesterContactId` filter (Phase G0a backend gap). + */ +import { Link } from '@tanstack/react-router' +import { useSuspenseQuery } from '@tanstack/react-query' +import type { ContactId, TicketId } from '@quackback/ids' +import { listTicketsFn } from '@/lib/server/functions/tickets' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' + +export function ContactTicketsTab({ contactId }: { contactId: ContactId }) { + const { data } = useSuspenseQuery({ + queryKey: ['tickets', 'byContact', contactId], + queryFn: () => + listTicketsFn({ + data: { scope: 'all', requesterContactId: contactId, limit: 100 }, + }), + staleTime: 30_000, + }) + + return ( +
+ + + + Subject + Priority + Channel + Last activity + + + + {data.rows.length === 0 ? ( + + + No tickets for this contact. + + + ) : ( + data.rows.map((t) => ( + + + + {t.subject} + + + + + {t.priority} + + + {t.channel} + + {t.lastActivityAt ? new Date(t.lastActivityAt).toLocaleString() : '—'} + + + )) + )} + +
+
+ ) +} diff --git a/apps/web/src/components/admin/contacts/organization-contacts-tab.tsx b/apps/web/src/components/admin/contacts/organization-contacts-tab.tsx new file mode 100644 index 000000000..cbe34c998 --- /dev/null +++ b/apps/web/src/components/admin/contacts/organization-contacts-tab.tsx @@ -0,0 +1,93 @@ +/** + * Contacts belonging to an organization + Add-contact button. + */ +import { Link } from '@tanstack/react-router' +import { useSuspenseQuery } from '@tanstack/react-query' +import type { OrganizationId, ContactId } from '@quackback/ids' +import { contactQueries } from '@/lib/client/queries/contacts' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { PlusIcon } from '@heroicons/react/24/solid' +import { ContactCreateDialog } from '@/components/admin/contacts/contact-create-dialog' +import { PermissionGate } from '@/components/admin/shared/permission-gate' +import { PERMISSIONS } from '@/lib/server/domains/authz' + +export function OrganizationContactsTab({ organizationId }: { organizationId: OrganizationId }) { + const { data: contacts } = useSuspenseQuery( + contactQueries.byOrg(organizationId, { includeArchived: false }) + ) + + return ( +
+
+ + + + Add contact + + } + /> + +
+ +
+ + + + Name + Email + Phone + Title + Status + + + + {contacts.length === 0 ? ( + + + No contacts in this organization yet. + + + ) : ( + contacts.map((c) => ( + + + + {c.name ?? c.email ?? c.id} + + + {c.email ?? '—'} + {c.phone ?? '—'} + {c.title ?? '—'} + + {c.archivedAt ? ( + + Archived + + ) : ( + Active + )} + + + )) + )} + +
+
+
+ ) +} diff --git a/apps/web/src/components/admin/contacts/organization-create-dialog.tsx b/apps/web/src/components/admin/contacts/organization-create-dialog.tsx new file mode 100644 index 000000000..eb841739c --- /dev/null +++ b/apps/web/src/components/admin/contacts/organization-create-dialog.tsx @@ -0,0 +1,159 @@ +/** + * Create-organization dialog. Navigates to detail on success. + */ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useRouter } from '@tanstack/react-router' +import { toast } from 'sonner' +import type { OrganizationId } from '@quackback/ids' +import { createOrganizationFn } from '@/lib/server/functions/organizations' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' + +export function OrganizationCreateDialog({ trigger }: { trigger: React.ReactNode }) { + const [open, setOpen] = useState(false) + const router = useRouter() + const qc = useQueryClient() + + const [name, setName] = useState('') + const [domain, setDomain] = useState('') + const [website, setWebsite] = useState('') + const [externalId, setExternalId] = useState('') + const [notes, setNotes] = useState('') + + const reset = () => { + setName('') + setDomain('') + setWebsite('') + setExternalId('') + setNotes('') + } + + const mutation = useMutation({ + mutationFn: () => + createOrganizationFn({ + data: { + name: name.trim(), + domain: domain.trim() || null, + website: website.trim() || null, + externalId: externalId.trim() || null, + notes: notes.trim() || null, + }, + }), + onSuccess: (org) => { + qc.invalidateQueries({ queryKey: ['organizations'] }) + toast.success('Organization created') + setOpen(false) + reset() + router.navigate({ + to: '/admin/contacts/organizations/$organizationId', + params: { organizationId: org.id as OrganizationId }, + }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + return ( + + {trigger} + + + New organization + + Companies, departments, or any account-level grouping. + + + +
{ + e.preventDefault() + if (!name.trim()) { + toast.error('Name is required') + return + } + mutation.mutate() + }} + className="space-y-3" + > +
+ + setName(e.target.value)} + required + maxLength={200} + /> +
+
+
+ + setDomain(e.target.value)} + maxLength={255} + placeholder="acme.com" + /> +
+
+ + setWebsite(e.target.value)} + maxLength={500} + placeholder="https://…" + /> +
+
+
+ + setExternalId(e.target.value)} + maxLength={255} + className="font-mono text-xs" + /> +
+
+ +