From cdff7f32d2224a8b2e8c9f0e4f3a5458f89a39c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:35:51 +0200 Subject: [PATCH 001/334] docs(support): add staged support platform plan Plan for building a Zendesk/Freshdesk-class support platform into Veerify, presented in a Chatwoot-style conversation UI, so support and product feedback share one platform. 14 stages (00-13) with a design doc covering architecture, data model, and the changes required to existing code. Knowledge base deferred out of scope. Key decisions: - Email-only first channel; chat is stage 11, social is stage 12 - Inbox entity scoped to team, optionally linked to a product - Contacts stay separate from feedback, linked explicitly via contactLink. Reverses an earlier draft that put contactId on feedback with a backfill, on privacy, GDPR-erasure, and data-quality grounds - Fixed conversation statuses, unlike per-project feedbackStatus, because SLA timers and automation need well-known semantics - Redis-only realtime broker via the Redis wire protocol, not the Upstash SDK, so Upstash and self-hosted Valkey are the same driver. Postgres LISTEN/NOTIFY rejected: the global commit lock degrades all writes - Stage 00 adds the missing Dockerfile and production compose services; self-hosting is currently impossible Co-Authored-By: Claude Opus 5 --- .../2026-08-11-support-platform/README.md | 91 ++++++ .../2026-08-11-support-platform/design.md | 291 ++++++++++++++++++ .../stage-00-foundations.md | 141 +++++++++ .../stage-01-contacts.md | 121 ++++++++ .../stage-02-conversation-core.md | 149 +++++++++ .../stage-03-inbound-email.md | 140 +++++++++ .../stage-04-outbound-replies.md | 138 +++++++++ .../stage-05-agent-productivity.md | 127 ++++++++ .../stage-06-sla.md | 75 +++++ .../stage-07-automation.md | 82 +++++ .../stage-08-csat.md | 65 ++++ .../stage-09-reporting.md | 73 +++++ .../stage-10-customer-portal.md | 71 +++++ .../stage-11-live-chat.md | 85 +++++ .../stage-12-social-channels.md | 67 ++++ .../stage-13-importers.md | 73 +++++ 16 files changed, 1789 insertions(+) create mode 100644 docs/plans/2026-08-11-support-platform/README.md create mode 100644 docs/plans/2026-08-11-support-platform/design.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-00-foundations.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-01-contacts.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-02-conversation-core.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-03-inbound-email.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-04-outbound-replies.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-05-agent-productivity.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-06-sla.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-07-automation.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-08-csat.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-09-reporting.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-10-customer-portal.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-11-live-chat.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-12-social-channels.md create mode 100644 docs/plans/2026-08-11-support-platform/stage-13-importers.md diff --git a/docs/plans/2026-08-11-support-platform/README.md b/docs/plans/2026-08-11-support-platform/README.md new file mode 100644 index 00000000..e5ffe164 --- /dev/null +++ b/docs/plans/2026-08-11-support-platform/README.md @@ -0,0 +1,91 @@ +# Support Platform — Plan Index + +**Goal:** Build a Zendesk/Freshdesk-class support platform into Veerify, presented in a Chatwoot-style +conversation UI, so that support and product feedback live on one platform. + +**Created:** August 11, 2026 + +**Read `design.md` first.** It holds the architecture, data model, and the reasoning behind decisions +that individual stage docs assume without re-arguing. + +--- + +## Stage map + +| Stage | Title | Depends on | Status | +| ------------------------------------ | ------------------------- | ---------- | ------- | +| [00](stage-00-foundations.md) | Foundations | — | Ready | +| [01](stage-01-contacts.md) | Contact identity | 00 | Blocked | +| [02](stage-02-conversation-core.md) | Inbox + conversation core | 00, 01 | Blocked | +| [03](stage-03-inbound-email.md) | Inbound email | 02 | Blocked | +| [04](stage-04-outbound-replies.md) | Outbound replies | 03 | Blocked | +| [05](stage-05-agent-productivity.md) | Agent productivity | 02, 04 | Blocked | +| [06](stage-06-sla.md) | Business hours + SLA | 02, 04 | Blocked | +| [07](stage-07-automation.md) | Automation rules | 02, 04 | Blocked | +| [08](stage-08-csat.md) | CSAT | 04 | Blocked | +| [09](stage-09-reporting.md) | Reporting | 02, 06 | Blocked | +| [10](stage-10-customer-portal.md) | Customer portal | 02 | Blocked | +| [11](stage-11-live-chat.md) | Live chat | 00, 02 | Blocked | +| [12](stage-12-social-channels.md) | Social channels | 03, 11 | Blocked | +| [13](stage-13-importers.md) | Migration importers | 02 | Blocked | + +**Deferred, not planned:** Knowledge base / help center. Dropped from this program on August 11, 2026. +Stage 10 (customer portal) ships its ticket list and submit form without KB integration; wire the two +together if and when the KB is revived. + +## Dependency graph + +``` +00 Foundations + └─→ 01 Contacts + └─→ 02 Conversation core ──┬─→ 03 Inbound email ─→ 04 Outbound replies ─┬─→ 05 Agent productivity + │ ├─→ 06 SLA ─→ 09 Reporting + │ ├─→ 07 Automation + │ └─→ 08 CSAT + ├─→ 10 Customer portal + ├─→ 13 Importers + └─→ 11 Live chat ─→ 12 Social channels + ↑ + also needs 03 +``` + +**Stage 00 is a hard barrier** — every later stage touches the realtime adapter or the schema split. +Nothing else starts until it is merged and verified on `main`. + +**What can run in parallel:** + +- 00 → 01 → 02 is a strict chain. There is no parallelism available until Stage 02 lands. +- After 02: stages 10, 13, and 11 are all independent of the 03→04 email chain and of each other. +- After 04: stages 05, 06, 07, and 08 are mutually independent. This is the widest fan-out in the program + — up to four agents. +- Stage 09 needs 06. Stage 12 needs both 03 and 11. + +**First usable product is Stage 04.** At that point a team can run real email support end to end. +Stages 05–07 make it competitive with Freshdesk. 08–10 close the gap. 11–13 are expansion. + +## Dispatch protocol + +This program uses the existing harness in `.agents/skills/todo-harness-workflow/SKILL.md`. No GitHub +Issues, no new tooling. + +1. **One stage at a time enters `TODO.md`.** When a stage becomes unblocked, the orchestrator appends + that stage's `## TODO items` block to `TODO.md` as `- [ ]` lines. Do not front-load all stages — + it produces an unreadable backlog and invites agents to pick up blocked work. +2. Each item is dispatched to one subagent on `agent/-` cut from latest `origin/main`. +3. Subagents never edit `TODO.md` and never push to `main`. +4. The orchestrator merges sequentially, running `yarn harness:verify` after each merge. +5. Items are checked off only after merge + verification on `main`. + +**Stage exit criteria.** A stage is done when all its items are checked off, its acceptance criteria in +the stage doc are demonstrated, `docs/qa/manual-feature-checklist.md` has been updated, and +`yarn harness:verify` is green on `main`. + +## Conventions for every stage + +- Options API only. No ` diff --git a/pages/support/contacts/index.vue b/pages/support/contacts/index.vue new file mode 100644 index 00000000..61974a42 --- /dev/null +++ b/pages/support/contacts/index.vue @@ -0,0 +1,215 @@ + + + From 94a4cf596eee7db0b4ccf58638e32003fc42620c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:33:06 +0200 Subject: [PATCH 047/334] chore(todo): check off SUP-01-7, SUP-01-8, and stale SUP-X-2 Co-Authored-By: Claude Opus 5 --- TODO.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index d412e33f..bbca7c94 100644 --- a/TODO.md +++ b/TODO.md @@ -229,11 +229,14 @@ separate" in `design.md`. - `d97812f`, merged in `6e30379`. `buildContactTimeline()` in `server/utils/support-timeline.ts` dedupes: a feedback item that is explicitly linked is excluded from `probableFeedback`, so it can never appear in both sections. Link creation locks the contact row and validates the target feedback is in the same team before inserting. - [x] **SUP-01-6** Add `supportCompany` CRUD endpoints - `c3b3060`. Mirrors the contact CRUD conventions; `requireCompanyAccess` added to support-access.ts. `server/utils/list-cursor.ts` extracted so the cursor logic is shared with contacts rather than duplicated. -- [ ] **SUP-01-7** Build `/support/contacts` list page (search, pagination, skeletons, error retry) -- [ ] **SUP-01-8** Build `/support/contacts/[id]` detail page: attributes, identities, timeline with visually distinct Linked vs Possible matches, one-click link, merge dialog +- [x] **SUP-01-7** Build `/support/contacts` list page (search, pagination, skeletons, error retry) + - `04e9a8e`. Manual 300ms debounce (no external dep, matches the rest of the codebase), cursor-based Load more, reacts to team switches via the existing `veerify:active-team-changed` event. +- [x] **SUP-01-8** Build `/support/contacts/[id]` detail page: attributes, identities, timeline with visually distinct Linked vs Possible matches, one-click link, merge dialog + - `04e9a8e`. Possible matches renders in a dashed amber-tinted panel with an explicit "not confirmed" caption — deliberately unmistakable, not merely different, from Linked. Verified end-to-end against a live dev server and database: created a contact, inserted a feedback row with a matching email, confirmed it surfaced as a probable match, linked it, confirmed it moved to Linked and vanished from Possible matches, unlinked, confirmed it reverted, then merged two contacts and confirmed backfill semantics. No browser preview was available in this environment, so this was verified via authenticated curl against the real API plus SSR HTML fetches of both pages — not a visual check. - [ ] **SUP-01-9** Register support contact routes in `server/utils/openapi.ts` ## Support Platform — Cross-cutting - [ ] **SUP-X-1** Add a guarded Redis integration suite (delta D-15). Nothing currently exercises the Redis driver or the Lua rate-limit script against a real server — only the memory driver and fakes. Skip when no `REDIS_URL` is reachable, following the `test:e2e:if-available` pattern -- [ ] **SUP-X-2** Gate `scripts/seed.ts` behind an explicit env flag (delta D-13). `yarn build` runs `postbuild` → seed, which creates `test@preview.local` / `password123` in whatever database it points at +- [x] **SUP-X-2** Gate `scripts/seed.ts` behind an explicit env flag (delta D-13). `yarn build` runs `postbuild` → seed, which creates `test@preview.local` / `password123` in whatever database it points at + - Board entry was stale. `productionSeedBlockReason()` in `scripts/seed.ts` refuses to run when `NODE_ENV=production` or `VERCEL_ENV=production`, with `ALLOW_PRODUCTION_SEED=true` as a deliberate override. Verified: blocks under both env vars, proceeds with the override set. From e01ac73d6e4cc039f612827d8edd21c0d63a54bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:43:38 +0200 Subject: [PATCH 048/334] feat(support): register contact and company routes in the OpenAPI spec SUP-01-9. Resolves delta D-23 as a documentation-only workaround. The original task text assumed server/utils/openapi.ts held a route registry. It does not -- it is type helpers and commonSchemas only. The served spec is hand-written in server/api/openapi.json.get.ts with paths: {} hardcoded empty, and 24 endpoint files across the whole app (auth, github, orgs, and now support) carry standard swagger-jsdoc @openapi YAML-comment blocks that nothing has ever parsed. This predates support-platform entirely. Hand-transcribed the 9 support path templates (16 operations) into paths, matching the source JSDoc exactly, plus Contact and SupportCompany schema components. Also completed two under-specified JSDoc blocks in teams/[teamId]/settings.{get,put}.ts that were missing parameters and responses, so the source comments and the served spec agree. Verified by running the dev server and fetching /api/openapi.json directly: valid JSON, all 9 support paths present, both new schemas resolve. This is duplication that will drift the moment a JSDoc block changes without a matching manual edit -- recorded as D-23 with the real fix (a build-time scanner using js-yaml, merging all 24 files' worth of existing annotations) queued as SUP-X-3. Not done here because it is repo-wide scope, not a support platform stage item, and because a request-time filesystem scan would silently produce an empty spec on Vercel, where source .ts files are not necessarily shipped -- only a build-time scan is safe across both cloud and self-hosted deployment modes. Co-Authored-By: Claude Opus 5 --- TODO.md | 1 + .../2026-08-11-support-platform/deltas.md | 26 ++ server/api/openapi.json.get.ts | 318 +++++++++++++++++- .../support/teams/[teamId]/settings.get.ts | 8 + .../support/teams/[teamId]/settings.put.ts | 8 + 5 files changed, 360 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index bbca7c94..7bd2f292 100644 --- a/TODO.md +++ b/TODO.md @@ -240,3 +240,4 @@ separate" in `design.md`. - [ ] **SUP-X-1** Add a guarded Redis integration suite (delta D-15). Nothing currently exercises the Redis driver or the Lua rate-limit script against a real server — only the memory driver and fakes. Skip when no `REDIS_URL` is reachable, following the `test:e2e:if-available` pattern - [x] **SUP-X-2** Gate `scripts/seed.ts` behind an explicit env flag (delta D-13). `yarn build` runs `postbuild` → seed, which creates `test@preview.local` / `password123` in whatever database it points at - Board entry was stale. `productionSeedBlockReason()` in `scripts/seed.ts` refuses to run when `NODE_ENV=production` or `VERCEL_ENV=production`, with `ALLOW_PRODUCTION_SEED=true` as a deliberate override. Verified: blocks under both env vars, proceeds with the override set. +- [ ] **SUP-X-3** (repo-wide, not support-specific) Build a build-time scanner that parses the `@openapi` JSDoc blocks already present on 24 endpoint files — auth, github, orgs, and support — and merges them into `server/api/openapi.json.get.ts`'s served `paths`, replacing the hand-maintained duplicate added for support in SUP-01-9 (delta D-23). Must run at build time: a request-time filesystem scan of `server/api/**/*.ts` would work in dev and self-hosted but produce an empty spec on Vercel, where only compiled output ships. Add `js-yaml` as a direct dependency — currently present only transitively via eslint. diff --git a/docs/plans/2026-08-11-support-platform/deltas.md b/docs/plans/2026-08-11-support-platform/deltas.md index a14aebc9..5169e64e 100644 --- a/docs/plans/2026-08-11-support-platform/deltas.md +++ b/docs/plans/2026-08-11-support-platform/deltas.md @@ -274,3 +274,29 @@ uses a bounded claim/retry worker. CSAT and social channels reuse this mechanism The acceptance criterion requires once-only breach processing for first-response, next-response, and resolution independently, but `conversation.slaBreachedAt` represented only one instant. Stage 06 now uses unique `(conversationId, metric)` `slaBreach` rows. + +### D-23 — `server/utils/openapi.ts` has no route-registration mechanism + +**Found:** SUP-01-9. **Status:** worked around; real fix queued as SUP-X-3. + +The stage-01 plan said "register support contact routes in `server/utils/openapi.ts`", assuming it held +a route registry. It does not — it is only type helpers and `commonSchemas`. The actual served spec is +hand-written in `server/api/openapi.json.get.ts` with `paths: {}` hardcoded empty and the comment "Paths +will be added as routes are implemented". + +**This predates support-platform.** 24 endpoint files across the whole app — auth, github, orgs, and now +all of support — carry `@openapi` JSDoc blocks in the standard `swagger-jsdoc` YAML-comment format, and +none of them have ever been read by anything. `js-yaml` is present only transitively (via eslint), so +even a scanner couldn't be added without a real dependency. + +**What was done:** the nine support path entries were hand-transcribed into `openapi.json.get.ts`'s +`paths` object, matching the source JSDoc exactly, plus `Contact` and `SupportCompany` schema components. +This makes the served `/api/openapi.json` correct for support today, but it is duplication that will +drift the moment an endpoint's JSDoc changes without a matching manual edit. + +**Real fix, not done here:** a build-time scan of `server/api/**/*.ts` for `@openapi` blocks, parsed with +`js-yaml` (added as a direct dependency) and merged into the served spec. Must run at build time, not +request time — the TS source is not necessarily present in a Vercel serverless deployment, only the +compiled output, so a runtime filesystem scan would work in dev and self-hosted but silently produce an +empty spec on Vercel. This is repo-wide scope (fixes all 24 files, not just support's 16), so it was not +done inside a support-platform stage item. diff --git a/server/api/openapi.json.get.ts b/server/api/openapi.json.get.ts index 17b83505..ffea6a16 100644 --- a/server/api/openapi.json.get.ts +++ b/server/api/openapi.json.get.ts @@ -28,6 +28,7 @@ export default defineEventHandler((event) => { { name: 'Projects', description: 'Project management' }, { name: 'Feedback', description: 'Feedback and feature request management' }, { name: 'GitHub', description: 'GitHub integration endpoints' }, + { name: 'Support', description: 'Support platform: contacts, companies, and team settings' }, ], components: { securitySchemes: { @@ -129,9 +130,324 @@ export default defineEventHandler((event) => { updatedAt: { type: 'string', format: 'date-time' }, }, }, + Contact: { + type: 'object', + properties: { + id: { type: 'string', example: 'ctc_123' }, + teamId: { type: 'string' }, + name: { type: 'string', nullable: true }, + email: { type: 'string', format: 'email', nullable: true }, + phone: { type: 'string', nullable: true }, + avatarUrl: { type: 'string', format: 'uri', nullable: true }, + companyId: { type: 'string', nullable: true }, + userId: { type: 'string', nullable: true, description: 'Set when the contact has a Veerify account' }, + attributes: { type: 'object', nullable: true }, + blockedAt: { type: 'string', format: 'date-time', nullable: true }, + mergedIntoContactId: { + type: 'string', + nullable: true, + description: 'Set on a tombstone left behind after a merge', + }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + SupportCompany: { + type: 'object', + properties: { + id: { type: 'string', example: 'co_123' }, + teamId: { type: 'string' }, + name: { type: 'string' }, + domain: { type: 'string', nullable: true }, + attributes: { type: 'object', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + paths: { + '/api/support/contacts': { + get: { + tags: ['Support'], + summary: 'List contacts for a team', + operationId: 'listSupportContacts', + parameters: [ + { in: 'query', name: 'teamId', required: true, schema: { type: 'string' } }, + { in: 'query', name: 'search', schema: { type: 'string' } }, + { in: 'query', name: 'limit', schema: { type: 'integer', minimum: 1, maximum: 100, default: 25 } }, + { in: 'query', name: 'cursor', schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'Contacts page', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + contacts: { type: 'array', items: { $ref: '#/components/schemas/Contact' } }, + hasMore: { type: 'boolean' }, + nextCursor: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + }, + }, + '403': { description: 'Not a member of the team' }, + }, + }, + post: { + tags: ['Support'], + summary: 'Create a contact', + operationId: 'createSupportContact', + responses: { + '200': { description: 'Contact created' }, + '403': { description: 'Not a member of the team' }, + '409': { description: 'A contact with this email already exists in the team' }, + }, + }, + }, + '/api/support/contacts/{id}': { + get: { + tags: ['Support'], + summary: 'Get a contact with its identities and company', + operationId: 'getSupportContact', + parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Contact detail' }, + '403': { description: "Not a member of the contact's team" }, + '404': { description: 'Contact not found' }, + }, + }, + put: { + tags: ['Support'], + summary: 'Update a contact', + operationId: 'updateSupportContact', + parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Contact updated' }, + '403': { description: "Not a member of the contact's team" }, + '404': { description: 'Contact not found' }, + '409': { description: 'Another contact in the team already uses this email' }, + }, + }, + delete: { + tags: ['Support'], + summary: 'Delete a contact', + description: + "Hard delete. Cascades the contact's identities and links. Feedback is never touched — contacts and feedback are deliberately not coupled.", + operationId: 'deleteSupportContact', + parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Contact deleted' }, + '403': { description: "Not a member of the contact's team" }, + '404': { description: 'Contact not found' }, + }, + }, + }, + '/api/support/contacts/{id}/merge': { + post: { + tags: ['Support'], + summary: 'Merge another contact into this one', + description: + 'The path contact survives. The source contact is retained as a tombstone with mergedIntoContactId set, so stale references still resolve.', + operationId: 'mergeSupportContact', + parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Merged' }, + '400': { description: 'Contacts cannot be merged' }, + '403': { description: "Not a member of the contact's team" }, + '404': { description: 'Contact not found' }, + }, + }, + }, + '/api/support/contacts/{id}/timeline': { + get: { + tags: ['Support'], + summary: "Get a contact's linked and probable feedback timeline", + operationId: 'getSupportContactTimeline', + parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Linked entities and probable feedback suggestions, kept in separate sections', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + linked: { + type: 'array', + description: 'Explicit, agent-confirmed links', + items: { type: 'object' }, + }, + probableFeedback: { + type: 'array', + description: + 'Heuristic matches by email or account — suggestions only, never a confirmed identity', + items: { $ref: '#/components/schemas/Feedback' }, + }, + }, + }, + }, + }, + }, + }, + }, + '403': { description: "Not a member of the contact's team" }, + '404': { description: 'Contact not found' }, + }, + }, + }, + '/api/support/contacts/{id}/links': { + post: { + tags: ['Support'], + summary: 'Explicitly link a feedback item to a contact', + operationId: 'createSupportContactLink', + parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Link created' }, + '400': { description: "Target is not in the contact's team" }, + '403': { description: "Not a member of the contact's team" }, + '404': { description: 'Contact not found' }, + '409': { description: 'Link already exists' }, + }, + }, + }, + '/api/support/contacts/{id}/links/{linkId}': { + delete: { + tags: ['Support'], + summary: 'Remove an explicit contact link', + operationId: 'deleteSupportContactLink', + parameters: [ + { in: 'path', name: 'id', required: true, schema: { type: 'string' } }, + { in: 'path', name: 'linkId', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { description: 'Link removed' }, + '403': { description: "Not a member of the contact's team" }, + '404': { description: 'Contact or link not found' }, + }, + }, + }, + '/api/support/companies': { + get: { + tags: ['Support'], + summary: 'List companies for a team', + operationId: 'listSupportCompanies', + parameters: [ + { in: 'query', name: 'teamId', required: true, schema: { type: 'string' } }, + { in: 'query', name: 'search', schema: { type: 'string' } }, + { in: 'query', name: 'limit', schema: { type: 'integer', minimum: 1, maximum: 100, default: 25 } }, + { in: 'query', name: 'cursor', schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'Companies page', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + companies: { type: 'array', items: { $ref: '#/components/schemas/SupportCompany' } }, + hasMore: { type: 'boolean' }, + nextCursor: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + }, + }, + '403': { description: 'Not a member of the team' }, + }, + }, + post: { + tags: ['Support'], + summary: 'Create a company', + operationId: 'createSupportCompany', + responses: { + '200': { description: 'Company created' }, + '403': { description: 'Not a member of the team' }, + '409': { description: 'A company with this name or domain already exists in the team' }, + }, + }, + }, + '/api/support/companies/{id}': { + get: { + tags: ['Support'], + summary: 'Get a company', + operationId: 'getSupportCompany', + parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Company detail' }, + '403': { description: "Not a member of the company's team" }, + '404': { description: 'Company not found' }, + }, + }, + put: { + tags: ['Support'], + summary: 'Update a company', + operationId: 'updateSupportCompany', + parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Company updated' }, + '403': { description: "Not a member of the company's team" }, + '404': { description: 'Company not found' }, + '409': { description: 'Another company in the team already uses this name or domain' }, + }, + }, + delete: { + tags: ['Support'], + summary: 'Delete a company', + description: + 'Hard delete. Contacts referencing this company have their companyId cleared (onDelete set null) rather than being deleted themselves.', + operationId: 'deleteSupportCompany', + parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Company deleted' }, + '403': { description: "Not a member of the company's team" }, + '404': { description: 'Company not found' }, + }, + }, + }, + '/api/support/teams/{teamId}/settings': { + get: { + tags: ['Support'], + summary: 'Get support team settings', + operationId: 'getSupportTeamSettings', + parameters: [{ in: 'path', name: 'teamId', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Support team settings' }, + '403': { description: 'Not a member of the team' }, + }, + }, + put: { + tags: ['Support'], + summary: 'Change support team settings', + operationId: 'updateSupportTeamSettings', + parameters: [{ in: 'path', name: 'teamId', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Support team settings updated' }, + '403': { description: 'Not a member of the team' }, + }, + }, }, }, - paths: {}, // Paths will be added as routes are implemented } return spec diff --git a/server/api/support/teams/[teamId]/settings.get.ts b/server/api/support/teams/[teamId]/settings.get.ts index 2afa14e3..f2927377 100644 --- a/server/api/support/teams/[teamId]/settings.get.ts +++ b/server/api/support/teams/[teamId]/settings.get.ts @@ -5,6 +5,14 @@ * tags: [Support] * summary: Get support team settings * operationId: getSupportTeamSettings + * parameters: + * - in: path + * name: teamId + * required: true + * schema: { type: string } + * responses: + * 200: { description: Support team settings } + * 403: { description: Not a member of the team } */ import { eq } from 'drizzle-orm' import { createSuccessResponse } from '~/server/utils/response' diff --git a/server/api/support/teams/[teamId]/settings.put.ts b/server/api/support/teams/[teamId]/settings.put.ts index d14b427d..8f775e35 100644 --- a/server/api/support/teams/[teamId]/settings.put.ts +++ b/server/api/support/teams/[teamId]/settings.put.ts @@ -5,6 +5,14 @@ * tags: [Support] * summary: Change support team settings * operationId: updateSupportTeamSettings + * parameters: + * - in: path + * name: teamId + * required: true + * schema: { type: string } + * responses: + * 200: { description: Support team settings updated } + * 403: { description: Not a member of the team } */ import { z } from 'zod' import { createSuccessResponse } from '~/server/utils/response' From f229b323d03f8cb9286c236334e5f12c2e2c22e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:43:52 +0200 Subject: [PATCH 049/334] chore(todo): check off SUP-01-9, close out stage 01 Co-Authored-By: Claude Opus 5 --- TODO.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 7bd2f292..1a1e28ec 100644 --- a/TODO.md +++ b/TODO.md @@ -233,7 +233,10 @@ separate" in `design.md`. - `04e9a8e`. Manual 300ms debounce (no external dep, matches the rest of the codebase), cursor-based Load more, reacts to team switches via the existing `veerify:active-team-changed` event. - [x] **SUP-01-8** Build `/support/contacts/[id]` detail page: attributes, identities, timeline with visually distinct Linked vs Possible matches, one-click link, merge dialog - `04e9a8e`. Possible matches renders in a dashed amber-tinted panel with an explicit "not confirmed" caption — deliberately unmistakable, not merely different, from Linked. Verified end-to-end against a live dev server and database: created a contact, inserted a feedback row with a matching email, confirmed it surfaced as a probable match, linked it, confirmed it moved to Linked and vanished from Possible matches, unlinked, confirmed it reverted, then merged two contacts and confirmed backfill semantics. No browser preview was available in this environment, so this was verified via authenticated curl against the real API plus SSR HTML fetches of both pages — not a visual check. -- [ ] **SUP-01-9** Register support contact routes in `server/utils/openapi.ts` +- [x] **SUP-01-9** Register support contact routes in `server/utils/openapi.ts` + - `e01ac73`. `openapi.ts` turned out to have no route registry (delta D-23) — hand-transcribed the 9 support path templates into `openapi.json.get.ts` instead, matching the source JSDoc exactly. Verified by fetching `/api/openapi.json` from a running server: valid JSON, all 9 paths present. Real fix (build-time JSDoc scanner, repo-wide) queued as SUP-X-3. + +**Stage 01 complete.** All items SUP-01-1 through SUP-01-9 done. ## Support Platform — Cross-cutting From a751c6a4412e4b4396957f53d2d21de9adbc7804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:14:34 +0200 Subject: [PATCH 050/334] test(support): add guarded Redis integration suite SUP-X-1. Resolves delta D-15. Everything else exercised the memory driver or a fake client -- the real Redis driver and the Lua rate-limit script had never run against an actual server. Cross-instance delivery is the entire premise of Stage 00's Redis decision, and notifications (SUP-00-9) now depend on it too, so a driver bug would first surface in Stage 02 with the agent UI already built on top. tests/integration/redis.test.ts, run via its own vitest config (vitest.integration.config.ts) so it never enters the default `yarn test` -- it requires a real Redis/Valkey and would otherwise fail on any machine without one running. Coverage: - cross-instance publish/subscribe: two independent createRedisDriver() instances, each with its own dedicated subscriber socket, talking through a real server - channel isolation: a publish to one channel never reaches a subscriber on another - resubscribe after a dropped connection: CLIENT KILL TYPE pubsub forces a server-side disconnect of the subscriber socket without touching the admin connection that issued it, then confirms the driver's reconnect handler re-subscribes and delivery resumes -- exercises the real ioredis retry path, not a mock - rate limiter atomicity: 25 concurrent consume() calls against a limit of 5 admit exactly 5, proving the Lua script closes the race a plain ZREMRANGEBYSCORE+ZCARD+ZADD sequence would leave open - rate limiter window expiry and fail-open under an unreachable connection scripts/run-redis-integration-if-available.mjs mirrors the existing Playwright guard: skip cleanly with a clear reason when Redis isn't reachable, so harness:verify stays green with nothing running. Unlike the Playwright guard it is not restricted to cloud/CI -- it runs by default whenever `docker compose -f docker-compose-dev.yml up -d valkey` is up, so local contributors get real coverage for free. Wired into harness:verify as a new guarded step, and into AGENTS.md's Required Validation Gates alongside the Playwright guard. Caught during verification: the integration file was initially swept into the default `yarn test` run too, because vitest.config.ts's tests/**/*.test.ts glob also matches tests/integration/ -- fixed by adding it to the same exclude list as tests/e2e/. Also removed a dead isForced flag copy-pasted from the Playwright script that doesn't apply here, since this guard runs whenever Redis is reachable regardless of environment. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 10 + package.json | 2 + scripts/harness-verify.mjs | 6 + .../run-redis-integration-if-available.mjs | 79 +++++++ tests/integration/redis.test.ts | 216 ++++++++++++++++++ vitest.config.ts | 4 +- vitest.integration.config.ts | 25 ++ 7 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 scripts/run-redis-integration-if-available.mjs create mode 100644 tests/integration/redis.test.ts create mode 100644 vitest.integration.config.ts diff --git a/AGENTS.md b/AGENTS.md index eb3e9004..38e482d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ Run these after every change: - `yarn test` - `yarn lint` - `yarn test:e2e:if-available` +- `yarn test:integration:if-available` Or run the harness command: @@ -46,6 +47,15 @@ Or run the harness command: - `yarn test:e2e:if-available` must run Playwright only when environment is cloud/CI or `PLAYWRIGHT_FORCE=1`, and a database is configured and reachable. - If the guarded Playwright command skips, report the skip reason in updates/final output. +## Redis Integration Guard + +- `yarn test:integration:if-available` runs the real Redis driver and rate-limit suite only when Redis is + reachable at `REDIS_URL` (defaults to `redis://localhost:6379`). Start it with + `docker compose -f docker-compose-dev.yml up -d valkey`. +- Unlike the Playwright guard, this one is not restricted to cloud/CI — it runs locally by default + whenever Redis is up. +- If it skips, report the skip reason in updates/final output. + ## UI Change Rule - Any user-facing UI behavior change requires Playwright coverage updates for the affected workflow. diff --git a/package.json b/package.json index 8be5cb46..7ed36509 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "test": "vitest run", "test:e2e": "playwright test", "test:e2e:if-available": "node scripts/run-playwright-if-available.mjs", + "test:integration": "vitest run -c vitest.integration.config.ts", + "test:integration:if-available": "node scripts/run-redis-integration-if-available.mjs", "harness:context": "node scripts/harness-context.mjs", "harness:docs": "node scripts/harness-docs-check.mjs", "harness:verify": "node scripts/harness-verify.mjs", diff --git a/scripts/harness-verify.mjs b/scripts/harness-verify.mjs index ad5b8754..d34d98ba 100644 --- a/scripts/harness-verify.mjs +++ b/scripts/harness-verify.mjs @@ -9,6 +9,12 @@ const steps = [ { label: 'Unit tests', command: yarnCommand, args: ['test'], shell: isWindows }, { label: 'Lint', command: yarnCommand, args: ['lint'], shell: isWindows }, { label: 'E2E (guarded)', command: yarnCommand, args: ['test:e2e:if-available'], shell: isWindows }, + { + label: 'Redis integration (guarded)', + command: yarnCommand, + args: ['test:integration:if-available'], + shell: isWindows, + }, ] const timings = [] diff --git a/scripts/run-redis-integration-if-available.mjs b/scripts/run-redis-integration-if-available.mjs new file mode 100644 index 00000000..25fdd4bd --- /dev/null +++ b/scripts/run-redis-integration-if-available.mjs @@ -0,0 +1,79 @@ +import { spawnSync } from 'node:child_process' +import Redis from 'ioredis' + +/** + * Guarded runner for the Redis/rate-limit integration suite (delta D-15). + * + * Mirrors `run-playwright-if-available.mjs`: skip cleanly with a clear reason + * when the dependency isn't reachable, so `yarn harness:verify` stays green on + * a machine with no Redis running, while still exercising the real driver + * wherever one is available (locally via `docker compose -f + * docker-compose-dev.yml up -d valkey`, or in CI/cloud). + */ + +const isCloudEnvironment = Boolean( + process.env.GITHUB_ACTIONS || process.env.VERCEL || process.env.CIRCLECI || process.env.BUILDKITE || process.env.CI +) +const failOnPreflightSkip = + process.env.REDIS_INTEGRATION_SKIP_IS_FAILURE === '1' || + (isCloudEnvironment && process.env.REDIS_INTEGRATION_SKIP_IS_FAILURE !== '0') + +// Unlike the Playwright guard (which requires cloud/CI or an explicit force +// flag), this one runs by default whenever Redis is reachable. Local +// contributors get real coverage for free the moment `docker compose up -d +// valkey` is running, rather than needing to opt in with an env var. +const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' +const connectTimeoutMs = Number(process.env.REDIS_INTEGRATION_CONNECT_TIMEOUT_MS) || 2_000 + +async function verifyRedisAvailable() { + const client = new Redis(redisUrl, { + lazyConnect: true, + connectTimeout: connectTimeoutMs, + retryStrategy: () => null, // don't retry during the preflight check + enableOfflineQueue: false, + }) + + // Without a listener, ioredis's connection-failure error surfaces as an + // "Unhandled error event" crash-looking dump on stderr, even though it is + // the entirely expected outcome of this preflight check. The skip message + // below is the actual, readable report of what happened. + client.on('error', () => {}) + + try { + await client.connect() + const pong = await client.ping() + return pong === 'PONG' + } catch { + return false + } finally { + client.disconnect() + } +} + +const redisAvailable = await verifyRedisAvailable() + +if (!redisAvailable) { + const reason = `Redis is not reachable at ${redisUrl}` + + if (failOnPreflightSkip) { + console.error(`[redis-integration] Preflight failed: ${reason}.`) + process.exit(1) + } + + console.log(`[redis-integration] Skipping: ${reason}.`) + console.log('[redis-integration] Start it locally with: docker compose -f docker-compose-dev.yml up -d valkey') + process.exit(0) +} + +const command = process.platform === 'win32' ? 'yarn.cmd' : 'yarn' +const result = spawnSync(command, ['test:integration'], { + stdio: 'inherit', + env: { ...process.env, REDIS_URL: redisUrl }, + shell: process.platform === 'win32', +}) + +if (result.error) { + throw result.error +} + +process.exit(result.status ?? 1) diff --git a/tests/integration/redis.test.ts b/tests/integration/redis.test.ts new file mode 100644 index 00000000..60433626 --- /dev/null +++ b/tests/integration/redis.test.ts @@ -0,0 +1,216 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import Redis from 'ioredis' + +import { createRedisDriver } from '~/server/services/realtime/drivers/redis' +import { createEnvelope } from '~/server/services/realtime/types' +import { createRedisStore } from '~/server/services/rate-limit/stores/redis' + +/** + * Integration coverage for the Redis driver and the Lua rate-limit script + * against a real Redis/Valkey server (delta D-15). + * + * Everything else in the suite exercises the memory driver or a fake client. + * Cross-instance delivery is the entire premise of Stage 00's Redis decision, + * and notifications (SUP-00-9) now depend on it too, so this is the one place + * that proves the actual wire behaviour rather than the interface contract. + * + * Skipped entirely unless a real Redis is reachable — see + * `scripts/run-redis-integration-if-available.mjs`. + */ + +const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379' + +function uniqueChannel(label: string): string { + // Random suffix so parallel runs (or a re-run right after a failure) never + // collide on a channel or key another run is still using. + return `test:${label}:${Math.random().toString(36).slice(2)}` +} + +describe('redis realtime driver (integration)', () => { + const drivers: Array<{ close: () => Promise }> = [] + let admin: Redis + + beforeAll(() => { + // Separate connection used only to force server-side disconnects for the + // reconnect test (CLIENT KILL) — kept apart from any driver connection so + // killing it never dismantles the thing being tested. + admin = new Redis(REDIS_URL) + }) + + afterEach(async () => { + for (const driver of drivers.splice(0)) { + await driver.close() + } + }) + + afterAll(async () => { + await admin.quit() + }) + + it('delivers a publish from one driver instance to a subscriber on another', async () => { + // Each createRedisDriver() call gets its own dedicated subscriber socket + // (a Redis connection in subscriber mode can't issue other commands), so + // this exercises the real fan-out path even though both calls share one + // process-wide publisher client. That sharing only affects which local + // object issues PUBLISH; the message still travels through Redis and back + // down each subscriber's own socket, which is the behaviour under test. + const instanceA = createRedisDriver(REDIS_URL) + const instanceB = createRedisDriver(REDIS_URL) + drivers.push(instanceA, instanceB) + + const channel = uniqueChannel('cross-instance') + const received: unknown[] = [] + + await instanceB.subscribe(channel, (envelope) => received.push(envelope)) + // Give the SUBSCRIBE a moment to land before publishing — otherwise the + // publish can race the subscription and the message is lost, which is + // real Redis pub/sub semantics (no backlog for a subscriber that wasn't + // listening yet), not a bug in the driver. + await new Promise((resolve) => setTimeout(resolve, 100)) + + const envelope = createEnvelope({ type: 'conversation.created', teamId: 't1', conversationId: 'c1' }) + await instanceA.publish(channel, envelope) + + await waitForCondition(() => received.length > 0) + + expect(received[0]).toEqual(envelope) + }) + + it('does not deliver a publish to a subscriber on a different channel', async () => { + const driver = createRedisDriver(REDIS_URL) + drivers.push(driver) + + const channelA = uniqueChannel('isolation-a') + const channelB = uniqueChannel('isolation-b') + const received: unknown[] = [] + + await driver.subscribe(channelA, (envelope) => received.push(envelope)) + await new Promise((resolve) => setTimeout(resolve, 100)) + + await driver.publish(channelB, createEnvelope({ type: 'x', teamId: 't1' })) + + // Give a would-be leak time to arrive, then assert it didn't. + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(received).toHaveLength(0) + }) + + it('restores subscriptions after the subscriber connection is dropped', async () => { + const driver = createRedisDriver(REDIS_URL) + drivers.push(driver) + + const channel = uniqueChannel('reconnect') + const received: unknown[] = [] + + await driver.subscribe(channel, (envelope) => received.push(envelope)) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Force a server-side disconnect of every subscriber-mode connection. + // Valkey/Redis support CLIENT KILL TYPE pubsub for exactly this — it + // drops the driver's subscriber socket without touching `admin`, which + // issued the kill and is not itself in subscriber mode. + await admin.call('CLIENT', 'KILL', 'TYPE', 'pubsub').catch(() => { + // No matching connections is not a failure — it just means the driver + // hadn't finished subscribing yet, which the next publish will expose. + }) + + // ioredis's retryStrategy caps backoff at 10s (server/services/redis/client.ts); + // give it real time to reconnect and for the driver's `on('ready', ...)` + // handler to re-issue SUBSCRIBE, rather than asserting on a fixed delay. + await waitForCondition( + async () => { + received.length = 0 + await driver.publish(channel, createEnvelope({ type: 'x', teamId: 't1' })) + await new Promise((resolve) => setTimeout(resolve, 200)) + return received.length > 0 + }, + { timeoutMs: 12_000, intervalMs: 500 } + ) + + expect(received.length).toBeGreaterThan(0) + }) +}) + +describe('redis rate limit store (integration)', () => { + let client: Redis + + beforeAll(() => { + client = new Redis(REDIS_URL) + }) + + afterAll(async () => { + await client.quit() + }) + + it('admits exactly the configured limit under concurrent requests', async () => { + // This is what the Lua script exists to prevent: a plain + // ZREMRANGEBYSCORE + ZCARD + ZADD issued as separate commands would race, + // letting a burst through right at the boundary. Firing more concurrent + // requests than the limit and counting exact admissions is the only way + // to actually exercise that race rather than just trusting the script. + const store = createRedisStore(client) + const key = uniqueChannel('atomicity') + const limit = 5 + const attempts = 25 + + const results = await Promise.all(Array.from({ length: attempts }, () => store.consume(key, 60_000, limit))) + + expect(results.filter(Boolean)).toHaveLength(limit) + expect(results.filter((r) => !r)).toHaveLength(attempts - limit) + }) + + it('frees up slots once the window passes', async () => { + const store = createRedisStore(client) + const key = uniqueChannel('window-expiry') + const windowMs = 500 + + expect(await store.consume(key, windowMs, 1)).toBe(true) + expect(await store.consume(key, windowMs, 1)).toBe(false) + + await new Promise((resolve) => setTimeout(resolve, windowMs + 200)) + + expect(await store.consume(key, windowMs, 1)).toBe(true) + }) + + it('fails open when Redis is unreachable', async () => { + // A Redis outage must not take down the public API this limiter + // protects. Point the store at a port nothing listens on, with + // reconnection disabled, so the command rejects promptly instead of + // queuing forever under enableOfflineQueue — the failure this test + // forces is deliberately faster than production's real retry behaviour, + // it just needs to exercise the same catch block. + const unreachable = new Redis('redis://127.0.0.1:1', { + lazyConnect: true, + connectTimeout: 500, + retryStrategy: () => null, + enableOfflineQueue: false, + }) + + const store = createRedisStore(unreachable) + + await expect(store.consume(uniqueChannel('fail-open'), 60_000, 1)).resolves.toBe(true) + + unreachable.disconnect() + }) +}) + +/** + * Poll `check` until it returns truthy or `timeoutMs` elapses. Vitest's + * built-in `vi.waitFor` is designed for the fake-timer/synchronous case; this + * suite waits on real network round trips and real ioredis reconnect timers, + * so a plain polling loop against wall-clock time is the honest tool here. + */ +async function waitForCondition( + check: () => boolean | Promise, + options: { timeoutMs?: number; intervalMs?: number } = {} +): Promise { + const timeoutMs = options.timeoutMs ?? 5_000 + const intervalMs = options.intervalMs ?? 100 + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + if (await check()) return + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + + throw new Error(`waitForCondition: condition not met within ${timeoutMs}ms`) +} diff --git a/vitest.config.ts b/vitest.config.ts index c217b3a8..220ad466 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ test: { globals: true, include: ['tests/**/*.test.ts'], - exclude: ['tests/e2e/**'], + // e2e requires Playwright; integration requires a real Redis. Both run via + // their own `*:if-available` guarded script, never the default `yarn test`. + exclude: ['tests/e2e/**', 'tests/integration/**'], }, }) diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 00000000..3650aa29 --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +/** + * Separate config for the Redis integration suite (delta D-15), kept out of + * the default `yarn test` run since it requires a real Redis/Valkey server. + * Run via `yarn test:integration:if-available`, which checks reachability + * first and skips with a clear reason when none is running. + */ +export default defineConfig({ + resolve: { + alias: { + '~': fileURLToPath(new URL('./', import.meta.url)), + '@': fileURLToPath(new URL('./', import.meta.url)), + }, + }, + test: { + globals: true, + include: ['tests/integration/**/*.test.ts'], + // Integration tests exercise real network round trips and, for the + // reconnect case, a deliberate multi-second wait for ioredis's retry + // strategy — the default 5s test timeout is too tight. + testTimeout: 15_000, + }, +}) From 4ebb392ddabdc27ceaa93f6bb6ab5ef5fd6cd7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:15:02 +0200 Subject: [PATCH 051/334] chore(todo): check off SUP-X-1, resolve delta D-15 Co-Authored-By: Claude Opus 5 --- TODO.md | 3 ++- docs/plans/2026-08-11-support-platform/deltas.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 1a1e28ec..6ed2ba14 100644 --- a/TODO.md +++ b/TODO.md @@ -240,7 +240,8 @@ separate" in `design.md`. ## Support Platform — Cross-cutting -- [ ] **SUP-X-1** Add a guarded Redis integration suite (delta D-15). Nothing currently exercises the Redis driver or the Lua rate-limit script against a real server — only the memory driver and fakes. Skip when no `REDIS_URL` is reachable, following the `test:e2e:if-available` pattern +- [x] **SUP-X-1** Add a guarded Redis integration suite (delta D-15). Nothing currently exercises the Redis driver or the Lua rate-limit script against a real server — only the memory driver and fakes. Skip when no `REDIS_URL` is reachable, following the `test:e2e:if-available` pattern + - `a751c6a`. `tests/integration/redis.test.ts` against real Valkey: cross-instance publish/subscribe, channel isolation, reconnect-and-resubscribe (via `CLIENT KILL TYPE pubsub`), rate-limit atomicity under 25 concurrent requests, window expiry, fail-open. Runs by default whenever Redis is reachable — not restricted to cloud/CI like the Playwright guard. All 6 pass against a real server. - [x] **SUP-X-2** Gate `scripts/seed.ts` behind an explicit env flag (delta D-13). `yarn build` runs `postbuild` → seed, which creates `test@preview.local` / `password123` in whatever database it points at - Board entry was stale. `productionSeedBlockReason()` in `scripts/seed.ts` refuses to run when `NODE_ENV=production` or `VERCEL_ENV=production`, with `ALLOW_PRODUCTION_SEED=true` as a deliberate override. Verified: blocks under both env vars, proceeds with the override set. - [ ] **SUP-X-3** (repo-wide, not support-specific) Build a build-time scanner that parses the `@openapi` JSDoc blocks already present on 24 endpoint files — auth, github, orgs, and support — and merges them into `server/api/openapi.json.get.ts`'s served `paths`, replacing the hand-maintained duplicate added for support in SUP-01-9 (delta D-23). Must run at build time: a request-time filesystem scan of `server/api/**/*.ts` would work in dev and self-hosted but produce an empty spec on Vercel, where only compiled output ships. Add `js-yaml` as a direct dependency — currently present only transitively via eslint. diff --git a/docs/plans/2026-08-11-support-platform/deltas.md b/docs/plans/2026-08-11-support-platform/deltas.md index 5169e64e..9226f595 100644 --- a/docs/plans/2026-08-11-support-platform/deltas.md +++ b/docs/plans/2026-08-11-support-platform/deltas.md @@ -194,7 +194,7 @@ surface as plausible-looking type errors. ### D-15 — Nothing tests the Redis driver against real Redis -**Found:** Stage 00 boundary review. **Status:** open, recommended before Stage 02. +**Found:** Stage 00 boundary review. **Status:** RESOLVED in `a751c6a` (2026-08-14). Every realtime and rate-limit test covers the **memory** driver or a fake client. The Redis driver's reconnect-and-resubscribe path, and the rate limiter's Lua sliding window, have never run against an From b6b9514a485f1eea4af563172d2965c8c0051f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:19:43 +0200 Subject: [PATCH 052/334] fix(support): unwrap drizzle's error.cause in isUniqueViolation Caught by the live-server verification the user asked for, not by any existing test. drizzle-orm's node-postgres driver throws DrizzleQueryError, which wraps the real pg error -- the one that actually carries .code -- in .cause rather than exposing it on the top-level error. isUniqueViolation() only ever checked the top-level error, so it never matched, and every unique- constraint conflict across the support platform leaked as a raw 500 with a stack trace instead of the intended 409: - contact create (duplicate email) - contact update (duplicate email) - company create (duplicate name or domain) - company update (duplicate name or domain) - contact link create (already linked) Reproduced live: POSTing a duplicate company name against a running dev server returned 500 before this fix, 409 after. Verified all five call sites against the same running server post-fix. isUniqueViolation() now walks .cause (bounded at 5 levels, real chains are one deep) looking for code 23505, rather than assuming it sits on the top-level error. This is standard Error.cause unwrapping, not drizzle-specific, so it keeps working if another layer starts wrapping too. tests/support-errors.test.ts is new -- the function had no dedicated unit test before this, only indirect coverage through endpoint tests that likely constructed a raw {code: '23505'} object rather than the real wrapped shape, which is exactly how this went undetected through SUP-01-3 and SUP-01-6. Co-Authored-By: Claude Opus 5 --- server/utils/support-errors.ts | 31 ++++++++++++++++---- tests/support-errors.test.ts | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 tests/support-errors.test.ts diff --git a/server/utils/support-errors.ts b/server/utils/support-errors.ts index 23d314b8..1e61e442 100644 --- a/server/utils/support-errors.ts +++ b/server/utils/support-errors.ts @@ -9,6 +9,9 @@ /** Postgres `unique_violation`. */ const UNIQUE_VIOLATION = '23505' +/** How many `.cause` links to unwrap before giving up. Real chains are one deep; this is a safety bound, not a design target. */ +const MAX_CAUSE_DEPTH = 5 + /** * Did this error come from a unique index? * @@ -16,15 +19,31 @@ const UNIQUE_VIOLATION = '23505' * fact beats a pre-flight SELECT: two concurrent creates can both pass a * pre-check and then one still fails, so the constraint is the only real * arbiter — the pre-check would just be a slower way to be wrong. + * + * drizzle-orm's node-postgres driver throws `DrizzleQueryError`, which wraps + * the real `pg` error (the one that actually carries `.code`) in `.cause` + * rather than exposing it directly. Checking only the top-level error means + * this never matches and every conflict leaks as a raw 500 with a stack + * trace — which is exactly what happened here until a live request against a + * duplicate name caught it. Standard `Error.cause` unwrapping, not + * drizzle-specific, so it keeps working if another layer starts wrapping too. */ export function isUniqueViolation(error: unknown, constraint?: string): boolean { - if (!error || typeof error !== 'object') return false + let current = error + let depth = 0 + + while (current && typeof current === 'object' && depth < MAX_CAUSE_DEPTH) { + const code = (current as { code?: unknown }).code - const code = (error as { code?: unknown }).code - if (code !== UNIQUE_VIOLATION) return false + if (code === UNIQUE_VIOLATION) { + if (!constraint) return true + const name = (current as { constraint?: unknown }).constraint + return typeof name === 'string' && name === constraint + } - if (!constraint) return true + current = (current as { cause?: unknown }).cause + depth++ + } - const name = (error as { constraint?: unknown }).constraint - return typeof name === 'string' && name === constraint + return false } diff --git a/tests/support-errors.test.ts b/tests/support-errors.test.ts new file mode 100644 index 00000000..bbfdc13c --- /dev/null +++ b/tests/support-errors.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { isUniqueViolation } from '../server/utils/support-errors' + +describe('isUniqueViolation', () => { + it('matches a raw pg-style error with code 23505', () => { + expect(isUniqueViolation({ code: '23505' })).toBe(true) + }) + + it('matches when the real error is wrapped in .cause, like DrizzleQueryError', () => { + // This is the actual shape thrown by drizzle-orm's node-postgres driver: + // DrizzleQueryError wraps the underlying pg error (the one carrying + // .code) in .cause rather than exposing it on itself. A version of this + // check that only looked at the top-level error matched nothing here, + // and every unique-constraint conflict across the support platform + // leaked as a raw 500 until a live request against a duplicate company + // name caught it. + const pgError = { code: '23505', constraint: 'support_company_team_name_idx' } + const drizzleQueryError = { message: 'Failed query: insert into "support_company" ...', cause: pgError } + + expect(isUniqueViolation(drizzleQueryError)).toBe(true) + }) + + it('matches a specific constraint name through the wrapper', () => { + const wrapped = { cause: { code: '23505', constraint: 'contact_team_email_idx' } } + + expect(isUniqueViolation(wrapped, 'contact_team_email_idx')).toBe(true) + expect(isUniqueViolation(wrapped, 'some_other_idx')).toBe(false) + }) + + it('does not match a different error code, wrapped or not', () => { + expect(isUniqueViolation({ code: '23503' })).toBe(false) + expect(isUniqueViolation({ cause: { code: '23503' } })).toBe(false) + }) + + it('does not match a deeply nested cause beyond the safety bound', () => { + // Real chains are one level deep. This just confirms the bound actually + // stops the walk rather than looping forever on something pathological. + let error: Record = { code: '23505' } + for (let i = 0; i < 10; i++) { + error = { cause: error } + } + + expect(isUniqueViolation(error)).toBe(false) + }) + + it('does not match non-error inputs', () => { + expect(isUniqueViolation(null)).toBe(false) + expect(isUniqueViolation(undefined)).toBe(false) + expect(isUniqueViolation('not an error')).toBe(false) + expect(isUniqueViolation(new Error('plain error, no code'))).toBe(false) + }) +}) From a4b24d1963cf1a8dbf9f022e48c15a051d6e00d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:19:54 +0200 Subject: [PATCH 053/334] docs(support): record delta D-24 for the isUniqueViolation cause-unwrap bug Co-Authored-By: Claude Opus 5 --- .../2026-08-11-support-platform/deltas.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/plans/2026-08-11-support-platform/deltas.md b/docs/plans/2026-08-11-support-platform/deltas.md index 9226f595..d3469785 100644 --- a/docs/plans/2026-08-11-support-platform/deltas.md +++ b/docs/plans/2026-08-11-support-platform/deltas.md @@ -300,3 +300,22 @@ request time — the TS source is not necessarily present in a Vercel serverless compiled output, so a runtime filesystem scan would work in dev and self-hosted but silently produce an empty spec on Vercel. This is repo-wide scope (fixes all 24 files, not just support's 16), so it was not done inside a support-platform stage item. + +### D-24 — `isUniqueViolation` never matched a real drizzle error + +**Found:** live-server verification after SUP-X-1. **Status:** RESOLVED in `b6b9514` (2026-08-14). + +`drizzle-orm`'s node-postgres driver throws `DrizzleQueryError`, which wraps the real `pg` error — the +one carrying `.code` — in `.cause` rather than exposing it on the top-level error object. +`isUniqueViolation()` only ever checked the top-level error, so it never matched anything, and every +unique-constraint conflict across the support platform (contact create/update, company create/update, +contact link create) leaked as a raw 500 with a stack trace instead of the intended 409. + +**Caught by:** POSTing a duplicate company name against a running dev server, per the user's request to +verify the platform against a real server rather than just unit tests. No existing test caught this, +because the only prior coverage was indirect, through endpoint tests that presumably constructed a raw +`{code: '23505'}` object rather than the real wrapped shape. + +**Lesson for this program:** a helper with no dedicated unit test, exercised only indirectly through +other tests that may not reproduce the real error shape, is a live gap even when everything is green. +`isUniqueViolation()` now has its own test file asserting the exact wrapped shape. From 019cf717f7728af72f54d15253ce36218f5fe29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:19:27 +0200 Subject: [PATCH 054/334] feat(support): add inbox and conversation schema (SUP-02-1) Add Stage 02 tables to server/database/schema/support.ts: supportInbox, supportInboxAddress (delta D-27), supportInboxMember, conversation (including the delta D-27 nullable projectId), supportCounter, conversationMessage, conversationAttachment, conversationParticipant, supportTag, conversationTag, and supportEmailEvent, with all indexes and FK actions per design.md's Stage 02 data model. Generate and apply the corresponding migration. --- .../migrations/0022_lowly_machine_man.sql | 189 + .../migrations/meta/0022_snapshot.json | 5341 +++++++++++++++++ server/database/migrations/meta/_journal.json | 7 + server/database/schema/support.ts | 385 +- 4 files changed, 5919 insertions(+), 3 deletions(-) create mode 100644 server/database/migrations/0022_lowly_machine_man.sql create mode 100644 server/database/migrations/meta/0022_snapshot.json diff --git a/server/database/migrations/0022_lowly_machine_man.sql b/server/database/migrations/0022_lowly_machine_man.sql new file mode 100644 index 00000000..6efc74cd --- /dev/null +++ b/server/database/migrations/0022_lowly_machine_man.sql @@ -0,0 +1,189 @@ +CREATE TABLE "conversation" ( + "id" text PRIMARY KEY NOT NULL, + "inbox_id" text NOT NULL, + "team_id" text NOT NULL, + "contact_id" text NOT NULL, + "project_id" text, + "display_id" integer NOT NULL, + "subject" text, + "status" text DEFAULT 'open' NOT NULL, + "priority" text, + "assignee_user_id" text, + "linked_feedback_id" text, + "channel_thread_key" text, + "first_response_at" timestamp, + "resolved_at" timestamp, + "snoozed_until" timestamp, + "last_activity_at" timestamp, + "last_customer_reply_at" timestamp, + "last_agent_reply_at" timestamp, + "metadata" jsonb, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "conversation_attachment" ( + "id" text PRIMARY KEY NOT NULL, + "message_id" text NOT NULL, + "storage_key" text NOT NULL, + "file_name" text NOT NULL, + "content_type" text, + "size_bytes" integer, + "is_inline" boolean DEFAULT false NOT NULL, + "content_id" text, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "conversation_message" ( + "id" text PRIMARY KEY NOT NULL, + "conversation_id" text NOT NULL, + "kind" text NOT NULL, + "body" text, + "body_html" text, + "sender_kind" text NOT NULL, + "sender_contact_id" text, + "sender_user_id" text, + "is_private" boolean DEFAULT false NOT NULL, + "channel_message_id" text, + "in_reply_to" text, + "channel_headers" jsonb, + "delivery_status" text DEFAULT 'pending' NOT NULL, + "delivery_error" text, + "metadata" jsonb, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "conversation_participant" ( + "id" text PRIMARY KEY NOT NULL, + "conversation_id" text NOT NULL, + "contact_id" text, + "user_id" text, + "role" text NOT NULL, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "conversation_tag" ( + "id" text PRIMARY KEY NOT NULL, + "conversation_id" text NOT NULL, + "tag_id" text NOT NULL, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "support_counter" ( + "team_id" text PRIMARY KEY NOT NULL, + "next_conversation_display_id" integer DEFAULT 1 NOT NULL +); +--> statement-breakpoint +CREATE TABLE "support_email_event" ( + "id" text PRIMARY KEY NOT NULL, + "inbox_id" text NOT NULL, + "provider" text NOT NULL, + "provider_event_id" text NOT NULL, + "raw_storage_key" text, + "status" text DEFAULT 'processing' NOT NULL, + "attempt_count" integer DEFAULT 0 NOT NULL, + "lease_expires_at" timestamp, + "processed_at" timestamp, + "result_conversation_id" text, + "error" text, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "support_inbox" ( + "id" text PRIMARY KEY NOT NULL, + "team_id" text NOT NULL, + "project_id" text, + "name" text NOT NULL, + "slug" text NOT NULL, + "type" text DEFAULT 'email' NOT NULL, + "channel_config" jsonb, + "email_address" text, + "forward_address" text, + "from_name" text, + "signature" text, + "auto_reply_enabled" boolean DEFAULT false NOT NULL, + "auto_reply_template" text, + "default_assignee_user_id" text, + "is_enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "support_inbox_address" ( + "id" text PRIMARY KEY NOT NULL, + "inbox_id" text NOT NULL, + "address" text NOT NULL, + "project_id" text, + "is_primary" boolean DEFAULT false NOT NULL, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "support_inbox_member" ( + "id" text PRIMARY KEY NOT NULL, + "inbox_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text NOT NULL, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "support_tag" ( + "id" text PRIMARY KEY NOT NULL, + "team_id" text NOT NULL, + "name" text NOT NULL, + "color" text, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +ALTER TABLE "conversation" ADD CONSTRAINT "conversation_inbox_id_support_inbox_id_fk" FOREIGN KEY ("inbox_id") REFERENCES "public"."support_inbox"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation" ADD CONSTRAINT "conversation_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation" ADD CONSTRAINT "conversation_contact_id_contact_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."contact"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation" ADD CONSTRAINT "conversation_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation" ADD CONSTRAINT "conversation_assignee_user_id_user_id_fk" FOREIGN KEY ("assignee_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation" ADD CONSTRAINT "conversation_linked_feedback_id_feedback_id_fk" FOREIGN KEY ("linked_feedback_id") REFERENCES "public"."feedback"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_attachment" ADD CONSTRAINT "conversation_attachment_message_id_conversation_message_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."conversation_message"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_message" ADD CONSTRAINT "conversation_message_conversation_id_conversation_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_message" ADD CONSTRAINT "conversation_message_sender_contact_id_contact_id_fk" FOREIGN KEY ("sender_contact_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_message" ADD CONSTRAINT "conversation_message_sender_user_id_user_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_participant" ADD CONSTRAINT "conversation_participant_conversation_id_conversation_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_participant" ADD CONSTRAINT "conversation_participant_contact_id_contact_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_participant" ADD CONSTRAINT "conversation_participant_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_tag" ADD CONSTRAINT "conversation_tag_conversation_id_conversation_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_tag" ADD CONSTRAINT "conversation_tag_tag_id_support_tag_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."support_tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_counter" ADD CONSTRAINT "support_counter_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_email_event" ADD CONSTRAINT "support_email_event_inbox_id_support_inbox_id_fk" FOREIGN KEY ("inbox_id") REFERENCES "public"."support_inbox"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_email_event" ADD CONSTRAINT "support_email_event_result_conversation_id_conversation_id_fk" FOREIGN KEY ("result_conversation_id") REFERENCES "public"."conversation"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_inbox" ADD CONSTRAINT "support_inbox_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_inbox" ADD CONSTRAINT "support_inbox_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_inbox" ADD CONSTRAINT "support_inbox_default_assignee_user_id_user_id_fk" FOREIGN KEY ("default_assignee_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_inbox_address" ADD CONSTRAINT "support_inbox_address_inbox_id_support_inbox_id_fk" FOREIGN KEY ("inbox_id") REFERENCES "public"."support_inbox"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_inbox_address" ADD CONSTRAINT "support_inbox_address_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_inbox_member" ADD CONSTRAINT "support_inbox_member_inbox_id_support_inbox_id_fk" FOREIGN KEY ("inbox_id") REFERENCES "public"."support_inbox"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_inbox_member" ADD CONSTRAINT "support_inbox_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_tag" ADD CONSTRAINT "support_tag_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_team_display_id_idx" ON "conversation" USING btree ("team_id","display_id");--> statement-breakpoint +CREATE INDEX "conversation_team_status_activity_idx" ON "conversation" USING btree ("team_id","status","last_activity_at");--> statement-breakpoint +CREATE INDEX "conversation_inbox_status_idx" ON "conversation" USING btree ("inbox_id","status");--> statement-breakpoint +CREATE INDEX "conversation_assignee_status_idx" ON "conversation" USING btree ("assignee_user_id","status");--> statement-breakpoint +CREATE INDEX "conversation_contact_created_at_idx" ON "conversation" USING btree ("contact_id","created_at");--> statement-breakpoint +CREATE INDEX "conversation_channel_thread_key_idx" ON "conversation" USING btree ("channel_thread_key");--> statement-breakpoint +CREATE INDEX "conversation_project_status_idx" ON "conversation" USING btree ("project_id","status");--> statement-breakpoint +CREATE INDEX "conversation_attachment_message_idx" ON "conversation_attachment" USING btree ("message_id");--> statement-breakpoint +CREATE INDEX "conversation_message_conversation_created_at_idx" ON "conversation_message" USING btree ("conversation_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_message_channel_message_id_idx" ON "conversation_message" USING btree ("channel_message_id");--> statement-breakpoint +CREATE INDEX "conversation_message_delivery_status_idx" ON "conversation_message" USING btree ("delivery_status");--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_participant_conversation_contact_idx" ON "conversation_participant" USING btree ("conversation_id","contact_id");--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_participant_conversation_user_idx" ON "conversation_participant" USING btree ("conversation_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_tag_conversation_tag_idx" ON "conversation_tag" USING btree ("conversation_id","tag_id");--> statement-breakpoint +CREATE INDEX "conversation_tag_tag_idx" ON "conversation_tag" USING btree ("tag_id");--> statement-breakpoint +CREATE UNIQUE INDEX "support_email_event_provider_event_id_idx" ON "support_email_event" USING btree ("provider","provider_event_id");--> statement-breakpoint +CREATE INDEX "support_email_event_inbox_idx" ON "support_email_event" USING btree ("inbox_id");--> statement-breakpoint +CREATE UNIQUE INDEX "support_inbox_team_slug_idx" ON "support_inbox" USING btree ("team_id","slug");--> statement-breakpoint +CREATE UNIQUE INDEX "support_inbox_email_address_idx" ON "support_inbox" USING btree ("email_address");--> statement-breakpoint +CREATE INDEX "support_inbox_team_idx" ON "support_inbox" USING btree ("team_id");--> statement-breakpoint +CREATE INDEX "support_inbox_project_idx" ON "support_inbox" USING btree ("project_id");--> statement-breakpoint +CREATE UNIQUE INDEX "support_inbox_address_address_idx" ON "support_inbox_address" USING btree ("address");--> statement-breakpoint +CREATE INDEX "support_inbox_address_inbox_idx" ON "support_inbox_address" USING btree ("inbox_id");--> statement-breakpoint +CREATE INDEX "support_inbox_address_project_idx" ON "support_inbox_address" USING btree ("project_id");--> statement-breakpoint +CREATE UNIQUE INDEX "support_inbox_member_inbox_user_idx" ON "support_inbox_member" USING btree ("inbox_id","user_id");--> statement-breakpoint +CREATE INDEX "support_inbox_member_user_idx" ON "support_inbox_member" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "support_tag_team_name_idx" ON "support_tag" USING btree ("team_id","name"); \ No newline at end of file diff --git a/server/database/migrations/meta/0022_snapshot.json b/server/database/migrations/meta/0022_snapshot.json new file mode 100644 index 00000000..3ed8d589 --- /dev/null +++ b/server/database/migrations/meta/0022_snapshot.json @@ -0,0 +1,5341 @@ +{ + "id": "1182f656-eb9e-478b-a03c-616d8d478934", + "prevId": "83d485fe-50ec-41af-82b6-0e10f1bca105", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_team_id_team_id_fk": { + "name": "invitation_team_id_team_id_fk", + "tableFrom": "invitation", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "team_org_idx": { + "name": "team_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_slug_unique": { + "name": "team_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_organization_id_organization_id_fk": { + "name": "team_organization_id_organization_id_fk", + "tableFrom": "team", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "github_id": { + "name": "github_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_github_id_unique": { + "name": "user_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anonymous_session": { + "name": "anonymous_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anon_session_token_idx": { + "name": "anon_session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anon_session_expires_idx": { + "name": "anon_session_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "anonymous_session_token_unique": { + "name": "anonymous_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback": { + "name": "feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_session_id": { + "name": "author_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vote_count": { + "name": "vote_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment_count": { + "name": "comment_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "is_hidden": { + "name": "is_hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "feedback_project_idx": { + "name": "feedback_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_category_idx": { + "name": "feedback_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_status_idx": { + "name": "feedback_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_author_user_idx": { + "name": "feedback_author_user_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_author_session_idx": { + "name": "feedback_author_session_idx", + "columns": [ + { + "expression": "author_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_author_email_idx": { + "name": "feedback_author_email_idx", + "columns": [ + { + "expression": "author_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_vote_count_idx": { + "name": "feedback_vote_count_idx", + "columns": [ + { + "expression": "vote_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_pinned_idx": { + "name": "feedback_pinned_idx", + "columns": [ + { + "expression": "is_pinned", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_hidden_idx": { + "name": "feedback_hidden_idx", + "columns": [ + { + "expression": "is_hidden", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_project_status_idx": { + "name": "feedback_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_project_created_at_idx": { + "name": "feedback_project_created_at_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_project_id_project_id_fk": { + "name": "feedback_project_id_project_id_fk", + "tableFrom": "feedback", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_category_id_feedback_category_id_fk": { + "name": "feedback_category_id_feedback_category_id_fk", + "tableFrom": "feedback", + "tableTo": "feedback_category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "feedback_author_user_id_user_id_fk": { + "name": "feedback_author_user_id_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "feedback_author_session_id_anonymous_session_id_fk": { + "name": "feedback_author_session_id_anonymous_session_id_fk", + "tableFrom": "feedback", + "tableTo": "anonymous_session", + "columnsFrom": [ + "author_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_category": { + "name": "feedback_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "category_project_slug_idx": { + "name": "category_project_slug_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "category_project_idx": { + "name": "category_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "category_default_idx": { + "name": "category_default_idx", + "columns": [ + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_category_project_id_project_id_fk": { + "name": "feedback_category_project_id_project_id_fk", + "tableFrom": "feedback_category", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_comment": { + "name": "feedback_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "feedback_id": { + "name": "feedback_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_session_id": { + "name": "author_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_internal": { + "name": "is_internal", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "comment_feedback_idx": { + "name": "comment_feedback_idx", + "columns": [ + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_author_user_idx": { + "name": "comment_author_user_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_author_session_idx": { + "name": "comment_author_session_idx", + "columns": [ + { + "expression": "author_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_parent_idx": { + "name": "comment_parent_idx", + "columns": [ + { + "expression": "parent_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_internal_idx": { + "name": "comment_internal_idx", + "columns": [ + { + "expression": "is_internal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_feedback_created_at_idx": { + "name": "comment_feedback_created_at_idx", + "columns": [ + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_comment_feedback_id_feedback_id_fk": { + "name": "feedback_comment_feedback_id_feedback_id_fk", + "tableFrom": "feedback_comment", + "tableTo": "feedback", + "columnsFrom": [ + "feedback_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_comment_parent_comment_id_feedback_comment_id_fk": { + "name": "feedback_comment_parent_comment_id_feedback_comment_id_fk", + "tableFrom": "feedback_comment", + "tableTo": "feedback_comment", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_comment_author_user_id_user_id_fk": { + "name": "feedback_comment_author_user_id_user_id_fk", + "tableFrom": "feedback_comment", + "tableTo": "user", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "feedback_comment_author_session_id_anonymous_session_id_fk": { + "name": "feedback_comment_author_session_id_anonymous_session_id_fk", + "tableFrom": "feedback_comment", + "tableTo": "anonymous_session", + "columnsFrom": [ + "author_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_status": { + "name": "feedback_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "status_project_value_idx": { + "name": "status_project_value_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "status_project_idx": { + "name": "status_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_status_project_id_project_id_fk": { + "name": "feedback_status_project_id_project_id_fk", + "tableFrom": "feedback_status", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_subscription": { + "name": "feedback_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "feedback_id": { + "name": "feedback_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_channel": { + "name": "notify_channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sub_email_feedback_idx": { + "name": "sub_email_feedback_idx", + "columns": [ + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_token_idx": { + "name": "sub_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_feedback_idx": { + "name": "sub_feedback_idx", + "columns": [ + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_subscription_feedback_id_feedback_id_fk": { + "name": "feedback_subscription_feedback_id_feedback_id_fk", + "tableFrom": "feedback_subscription", + "tableTo": "feedback", + "columnsFrom": [ + "feedback_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_subscription_user_id_user_id_fk": { + "name": "feedback_subscription_user_id_user_id_fk", + "tableFrom": "feedback_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integration": { + "name": "github_integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_enabled": { + "name": "sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "auto_create_issues": { + "name": "auto_create_issues", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "auto_sync_status": { + "name": "auto_sync_status", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "github_integration_project_idx": { + "name": "github_integration_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_integration_repo_idx": { + "name": "github_integration_repo_idx", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_integration_project_id_project_id_fk": { + "name": "github_integration_project_id_project_id_fk", + "tableFrom": "github_integration", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_integration_project_id_unique": { + "name": "github_integration_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_issue_link": { + "name": "github_issue_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "feedback_id": { + "name": "feedback_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_integration_id": { + "name": "github_integration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_url": { + "name": "issue_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_state": { + "name": "issue_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "github_link_feedback_idx": { + "name": "github_link_feedback_idx", + "columns": [ + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_link_integration_idx": { + "name": "github_link_integration_idx", + "columns": [ + { + "expression": "github_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_link_issue_idx": { + "name": "github_link_issue_idx", + "columns": [ + { + "expression": "github_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_issue_link_feedback_id_feedback_id_fk": { + "name": "github_issue_link_feedback_id_feedback_id_fk", + "tableFrom": "github_issue_link", + "tableTo": "feedback", + "columnsFrom": [ + "feedback_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_issue_link_github_integration_id_github_integration_id_fk": { + "name": "github_issue_link_github_integration_id_github_integration_id_fk", + "tableFrom": "github_issue_link", + "tableTo": "github_integration", + "columnsFrom": [ + "github_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "submission_mode": { + "name": "submission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anonymous'" + }, + "custom_domain": { + "name": "custom_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "project_team_slug_idx": { + "name": "project_team_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_org_idx": { + "name": "project_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_idx": { + "name": "project_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_created_at_idx": { + "name": "project_team_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_public_idx": { + "name": "project_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_custom_domain_idx": { + "name": "project_custom_domain_idx", + "columns": [ + { + "expression": "custom_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_organization_id_organization_id_fk": { + "name": "project_organization_id_organization_id_fk", + "tableFrom": "project", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_team_id_team_id_fk": { + "name": "project_team_id_team_id_fk", + "tableFrom": "project", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roadmap_item": { + "name": "roadmap_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quarter": { + "name": "quarter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_date": { + "name": "completed_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "linked_feedback_ids": { + "name": "linked_feedback_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "roadmap_project_idx": { + "name": "roadmap_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "roadmap_status_idx": { + "name": "roadmap_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "roadmap_priority_idx": { + "name": "roadmap_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "roadmap_project_status_idx": { + "name": "roadmap_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roadmap_item_project_id_project_id_fk": { + "name": "roadmap_item_project_id_project_id_fk", + "tableFrom": "roadmap_item", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vote": { + "name": "vote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "feedback_id": { + "name": "feedback_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voter_user_id": { + "name": "voter_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voter_session_id": { + "name": "voter_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'upvote'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vote_user_feedback_idx": { + "name": "vote_user_feedback_idx", + "columns": [ + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "voter_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_session_feedback_idx": { + "name": "vote_session_feedback_idx", + "columns": [ + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "voter_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_feedback_idx": { + "name": "vote_feedback_idx", + "columns": [ + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_user_idx": { + "name": "vote_user_idx", + "columns": [ + { + "expression": "voter_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_session_idx": { + "name": "vote_session_idx", + "columns": [ + { + "expression": "voter_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vote_feedback_id_feedback_id_fk": { + "name": "vote_feedback_id_feedback_id_fk", + "tableFrom": "vote", + "tableTo": "feedback", + "columnsFrom": [ + "feedback_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vote_voter_user_id_user_id_fk": { + "name": "vote_voter_user_id_user_id_fk", + "tableFrom": "vote", + "tableTo": "user", + "columnsFrom": [ + "voter_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vote_voter_session_id_anonymous_session_id_fk": { + "name": "vote_voter_session_id_anonymous_session_id_fk", + "tableFrom": "vote", + "tableTo": "anonymous_session", + "columnsFrom": [ + "voter_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feedback_id": { + "name": "feedback_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "notification_user_idx": { + "name": "notification_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_user_read_idx": { + "name": "notification_user_read_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_user_created_at_idx": { + "name": "notification_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_feedback_idx": { + "name": "notification_feedback_idx", + "columns": [ + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_feedback_id_feedback_id_fk": { + "name": "notification_feedback_id_feedback_id_fk", + "tableFrom": "notification", + "tableTo": "feedback", + "columnsFrom": [ + "feedback_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_project_id_project_id_fk": { + "name": "notification_project_id_project_id_fk", + "tableFrom": "notification", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_actor_user_id_user_id_fk": { + "name": "notification_actor_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact": { + "name": "contact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "blocked_at": { + "name": "blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "merged_into_contact_id": { + "name": "merged_into_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "contact_team_email_idx": { + "name": "contact_team_email_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_team_created_at_idx": { + "name": "contact_team_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_company_idx": { + "name": "contact_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_user_idx": { + "name": "contact_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_team_id_team_id_fk": { + "name": "contact_team_id_team_id_fk", + "tableFrom": "contact", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contact_company_id_support_company_id_fk": { + "name": "contact_company_id_support_company_id_fk", + "tableFrom": "contact", + "tableTo": "support_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "contact_user_id_user_id_fk": { + "name": "contact_user_id_user_id_fk", + "tableFrom": "contact", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "contact_merged_into_contact_id_contact_id_fk": { + "name": "contact_merged_into_contact_id_contact_id_fk", + "tableFrom": "contact", + "tableTo": "contact", + "columnsFrom": [ + "merged_into_contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_identity": { + "name": "contact_identity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "contact_identity_team_kind_value_idx": { + "name": "contact_identity_team_kind_value_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_identity_contact_idx": { + "name": "contact_identity_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_identity_contact_id_contact_id_fk": { + "name": "contact_identity_contact_id_contact_id_fk", + "tableFrom": "contact_identity", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contact_identity_team_id_team_id_fk": { + "name": "contact_identity_team_id_team_id_fk", + "tableFrom": "contact_identity", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_link": { + "name": "contact_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "contact_link_contact_entity_idx": { + "name": "contact_link_contact_entity_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_link_entity_idx": { + "name": "contact_link_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_link_contact_id_contact_id_fk": { + "name": "contact_link_contact_id_contact_id_fk", + "tableFrom": "contact_link", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contact_link_created_by_user_id_user_id_fk": { + "name": "contact_link_created_by_user_id_user_id_fk", + "tableFrom": "contact_link", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation": { + "name": "conversation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_id": { + "name": "display_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_feedback_id": { + "name": "linked_feedback_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_thread_key": { + "name": "channel_thread_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_response_at": { + "name": "first_response_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snoozed_until": { + "name": "snoozed_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_customer_reply_at": { + "name": "last_customer_reply_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_agent_reply_at": { + "name": "last_agent_reply_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "conversation_team_display_id_idx": { + "name": "conversation_team_display_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_team_status_activity_idx": { + "name": "conversation_team_status_activity_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_inbox_status_idx": { + "name": "conversation_inbox_status_idx", + "columns": [ + { + "expression": "inbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_assignee_status_idx": { + "name": "conversation_assignee_status_idx", + "columns": [ + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_contact_created_at_idx": { + "name": "conversation_contact_created_at_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_channel_thread_key_idx": { + "name": "conversation_channel_thread_key_idx", + "columns": [ + { + "expression": "channel_thread_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_project_status_idx": { + "name": "conversation_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_inbox_id_support_inbox_id_fk": { + "name": "conversation_inbox_id_support_inbox_id_fk", + "tableFrom": "conversation", + "tableTo": "support_inbox", + "columnsFrom": [ + "inbox_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "conversation_team_id_team_id_fk": { + "name": "conversation_team_id_team_id_fk", + "tableFrom": "conversation", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_contact_id_contact_id_fk": { + "name": "conversation_contact_id_contact_id_fk", + "tableFrom": "conversation", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "conversation_project_id_project_id_fk": { + "name": "conversation_project_id_project_id_fk", + "tableFrom": "conversation", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversation_assignee_user_id_user_id_fk": { + "name": "conversation_assignee_user_id_user_id_fk", + "tableFrom": "conversation", + "tableTo": "user", + "columnsFrom": [ + "assignee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversation_linked_feedback_id_feedback_id_fk": { + "name": "conversation_linked_feedback_id_feedback_id_fk", + "tableFrom": "conversation", + "tableTo": "feedback", + "columnsFrom": [ + "linked_feedback_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation_attachment": { + "name": "conversation_attachment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_inline": { + "name": "is_inline", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "conversation_attachment_message_idx": { + "name": "conversation_attachment_message_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_attachment_message_id_conversation_message_id_fk": { + "name": "conversation_attachment_message_id_conversation_message_id_fk", + "tableFrom": "conversation_attachment", + "tableTo": "conversation_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation_message": { + "name": "conversation_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_kind": { + "name": "sender_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_contact_id": { + "name": "sender_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "channel_message_id": { + "name": "channel_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_headers": { + "name": "channel_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "delivery_error": { + "name": "delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "conversation_message_conversation_created_at_idx": { + "name": "conversation_message_conversation_created_at_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_message_channel_message_id_idx": { + "name": "conversation_message_channel_message_id_idx", + "columns": [ + { + "expression": "channel_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_message_delivery_status_idx": { + "name": "conversation_message_delivery_status_idx", + "columns": [ + { + "expression": "delivery_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_message_conversation_id_conversation_id_fk": { + "name": "conversation_message_conversation_id_conversation_id_fk", + "tableFrom": "conversation_message", + "tableTo": "conversation", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_sender_contact_id_contact_id_fk": { + "name": "conversation_message_sender_contact_id_contact_id_fk", + "tableFrom": "conversation_message", + "tableTo": "contact", + "columnsFrom": [ + "sender_contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversation_message_sender_user_id_user_id_fk": { + "name": "conversation_message_sender_user_id_user_id_fk", + "tableFrom": "conversation_message", + "tableTo": "user", + "columnsFrom": [ + "sender_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation_participant": { + "name": "conversation_participant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "conversation_participant_conversation_contact_idx": { + "name": "conversation_participant_conversation_contact_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_participant_conversation_user_idx": { + "name": "conversation_participant_conversation_user_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_participant_conversation_id_conversation_id_fk": { + "name": "conversation_participant_conversation_id_conversation_id_fk", + "tableFrom": "conversation_participant", + "tableTo": "conversation", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_participant_contact_id_contact_id_fk": { + "name": "conversation_participant_contact_id_contact_id_fk", + "tableFrom": "conversation_participant", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_participant_user_id_user_id_fk": { + "name": "conversation_participant_user_id_user_id_fk", + "tableFrom": "conversation_participant", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation_tag": { + "name": "conversation_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "conversation_tag_conversation_tag_idx": { + "name": "conversation_tag_conversation_tag_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_tag_tag_idx": { + "name": "conversation_tag_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_tag_conversation_id_conversation_id_fk": { + "name": "conversation_tag_conversation_id_conversation_id_fk", + "tableFrom": "conversation_tag", + "tableTo": "conversation", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_tag_tag_id_support_tag_id_fk": { + "name": "conversation_tag_tag_id_support_tag_id_fk", + "tableFrom": "conversation_tag", + "tableTo": "support_tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_company": { + "name": "support_company", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "support_company_team_domain_idx": { + "name": "support_company_team_domain_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_company_team_name_idx": { + "name": "support_company_team_name_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_company_team_id_team_id_fk": { + "name": "support_company_team_id_team_id_fk", + "tableFrom": "support_company", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_counter": { + "name": "support_counter", + "schema": "", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "next_conversation_display_id": { + "name": "next_conversation_display_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "support_counter_team_id_team_id_fk": { + "name": "support_counter_team_id_team_id_fk", + "tableFrom": "support_counter", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_email_event": { + "name": "support_email_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_storage_key": { + "name": "raw_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result_conversation_id": { + "name": "result_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "support_email_event_provider_event_id_idx": { + "name": "support_email_event_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_email_event_inbox_idx": { + "name": "support_email_event_inbox_idx", + "columns": [ + { + "expression": "inbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_email_event_inbox_id_support_inbox_id_fk": { + "name": "support_email_event_inbox_id_support_inbox_id_fk", + "tableFrom": "support_email_event", + "tableTo": "support_inbox", + "columnsFrom": [ + "inbox_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_email_event_result_conversation_id_conversation_id_fk": { + "name": "support_email_event_result_conversation_id_conversation_id_fk", + "tableFrom": "support_email_event", + "tableTo": "conversation", + "columnsFrom": [ + "result_conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_inbox": { + "name": "support_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "channel_config": { + "name": "channel_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_address": { + "name": "email_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_address": { + "name": "forward_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_reply_enabled": { + "name": "auto_reply_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_reply_template": { + "name": "auto_reply_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_assignee_user_id": { + "name": "default_assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "support_inbox_team_slug_idx": { + "name": "support_inbox_team_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_inbox_email_address_idx": { + "name": "support_inbox_email_address_idx", + "columns": [ + { + "expression": "email_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_inbox_team_idx": { + "name": "support_inbox_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_inbox_project_idx": { + "name": "support_inbox_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_inbox_team_id_team_id_fk": { + "name": "support_inbox_team_id_team_id_fk", + "tableFrom": "support_inbox", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_inbox_project_id_project_id_fk": { + "name": "support_inbox_project_id_project_id_fk", + "tableFrom": "support_inbox", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "support_inbox_default_assignee_user_id_user_id_fk": { + "name": "support_inbox_default_assignee_user_id_user_id_fk", + "tableFrom": "support_inbox", + "tableTo": "user", + "columnsFrom": [ + "default_assignee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_inbox_address": { + "name": "support_inbox_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "support_inbox_address_address_idx": { + "name": "support_inbox_address_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_inbox_address_inbox_idx": { + "name": "support_inbox_address_inbox_idx", + "columns": [ + { + "expression": "inbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_inbox_address_project_idx": { + "name": "support_inbox_address_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_inbox_address_inbox_id_support_inbox_id_fk": { + "name": "support_inbox_address_inbox_id_support_inbox_id_fk", + "tableFrom": "support_inbox_address", + "tableTo": "support_inbox", + "columnsFrom": [ + "inbox_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_inbox_address_project_id_project_id_fk": { + "name": "support_inbox_address_project_id_project_id_fk", + "tableFrom": "support_inbox_address", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_inbox_member": { + "name": "support_inbox_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "support_inbox_member_inbox_user_idx": { + "name": "support_inbox_member_inbox_user_idx", + "columns": [ + { + "expression": "inbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_inbox_member_user_idx": { + "name": "support_inbox_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_inbox_member_inbox_id_support_inbox_id_fk": { + "name": "support_inbox_member_inbox_id_support_inbox_id_fk", + "tableFrom": "support_inbox_member", + "tableTo": "support_inbox", + "columnsFrom": [ + "inbox_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_inbox_member_user_id_user_id_fk": { + "name": "support_inbox_member_user_id_user_id_fk", + "tableFrom": "support_inbox_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tag": { + "name": "support_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "support_tag_team_name_idx": { + "name": "support_tag_team_name_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tag_team_id_team_id_fk": { + "name": "support_tag_team_id_team_id_fk", + "tableFrom": "support_tag", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_team_settings": { + "name": "support_team_settings", + "schema": "", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "auto_link_feedback": { + "name": "auto_link_feedback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "support_team_settings_team_id_team_id_fk": { + "name": "support_team_settings_team_id_team_id_fk", + "tableFrom": "support_team_settings", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json index f2a34ffc..e7baf833 100644 --- a/server/database/migrations/meta/_journal.json +++ b/server/database/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1786654775757, "tag": "0021_daffy_legion", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1786781894184, + "tag": "0022_lowly_machine_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/database/schema/support.ts b/server/database/schema/support.ts index a13baacc..f9a6656d 100644 --- a/server/database/schema/support.ts +++ b/server/database/schema/support.ts @@ -4,17 +4,39 @@ // identifiers a contact is known by (`contactIdentity`), the companies they // belong to (`supportCompany`), and explicit links from a contact to other // Veerify entities (`contactLink`). Stage 02 adds the inbox and conversation -// tables. +// tables: `supportInbox`, `supportInboxAddress`, `supportInboxMember`, +// `conversation`, `supportCounter`, `conversationMessage`, +// `conversationAttachment`, `conversationParticipant`, `supportTag`, +// `conversationTag`, and `supportEmailEvent`. // // `contactLink` points outward at entities like `feedback` by // (entityType, entityId) — a loose reference, not a foreign key. Feedback // never references a contact; see "Why contacts and feedback stay separate" // in `docs/plans/2026-08-11-support-platform/design.md`. // -// See `docs/plans/2026-08-11-support-platform/design.md` → Data model → Stage 01. +// `conversation.projectId` and `supportInboxAddress.projectId` are real +// foreign keys into `feedback.ts`'s `project` table (support → feedback, +// the permitted direction — see delta D-27). `conversation.linkedFeedbackId` +// likewise references `feedback.ts`'s `feedback` table. `feedback.ts` itself +// is never edited to support this schema. +// +// See `docs/plans/2026-08-11-support-platform/design.md` → Data model → +// Stage 01 and Stage 02, and delta D-27 in `deltas.md` for the +// `supportInboxAddress` / `conversation.projectId` addition. -import { pgTable, text, timestamp, jsonb, uniqueIndex, index, boolean, type AnyPgColumn } from 'drizzle-orm/pg-core' +import { + pgTable, + text, + timestamp, + jsonb, + uniqueIndex, + index, + boolean, + integer, + type AnyPgColumn, +} from 'drizzle-orm/pg-core' import { user, team } from './auth' +import { project, feedback } from './feedback' // Support companies - the Zendesk "organization" concept, named to avoid // collision with the existing `organization` table. Declared before `contact` @@ -171,3 +193,360 @@ export const supportTeamSettings = pgTable('support_team_settings', { .$defaultFn(() => new Date()) .notNull(), }) + +// --------------------------------------------------------------------------- +// Stage 02 — inbox and conversations +// See `docs/plans/2026-08-11-support-platform/design.md` → Data model → +// Stage 02, and delta D-27 for `supportInboxAddress` / `conversation.projectId`. +// --------------------------------------------------------------------------- + +// Support inboxes - one shared inbox per team ("support@acme.com" as the +// primary sending identity). What the inbox actually *receives* on is +// governed by `supportInboxAddress` (delta D-27), which lets one inbox map +// several receiving addresses to different products. +export const supportInbox = pgTable( + 'support_inbox', + { + id: text('id').primaryKey(), + teamId: text('team_id') + .notNull() + .references(() => team.id, { onDelete: 'cascade' }), + // Optional single-product link for teams that don't need multi-address routing + projectId: text('project_id').references(() => project.id, { onDelete: 'set null' }), + name: text('name').notNull(), + slug: text('slug').notNull(), + // 'email' | 'chat' | 'whatsapp' | … (Stage 02 ships 'email' only) + type: text('type').default('email').notNull(), + // Provider, inbound address, credential references - never raw secrets + channelConfig: jsonb('channel_config').$type>(), + // Primary *sending* identity - distinct from the receiving addresses in supportInboxAddress + emailAddress: text('email_address'), + forwardAddress: text('forward_address'), + fromName: text('from_name'), + signature: text('signature'), + autoReplyEnabled: boolean('auto_reply_enabled') + .default(false) + .notNull(), + autoReplyTemplate: text('auto_reply_template'), + defaultAssigneeUserId: text('default_assignee_user_id').references(() => user.id, { onDelete: 'set null' }), + isEnabled: boolean('is_enabled') + .default(true) + .notNull(), + createdAt: timestamp('created_at') + .$defaultFn(() => new Date()) + .notNull(), + updatedAt: timestamp('updated_at') + .$defaultFn(() => new Date()) + .notNull(), + }, + (table) => ({ + uniqueTeamSlug: uniqueIndex('support_inbox_team_slug_idx').on(table.teamId, table.slug), + uniqueEmailAddress: uniqueIndex('support_inbox_email_address_idx').on(table.emailAddress), + teamIdx: index('support_inbox_team_idx').on(table.teamId), + projectIdx: index('support_inbox_project_idx').on(table.projectId), + }) +) + +// Receiving addresses for an inbox, and the product each maps to (delta D-27). +// Email carries no product signal on its own; a customer mailing +// `billing@acme.com` attributes to Billing only because that address is +// mapped here. `projectId` null means unattributed. +export const supportInboxAddress = pgTable( + 'support_inbox_address', + { + id: text('id').primaryKey(), + inboxId: text('inbox_id') + .notNull() + .references(() => supportInbox.id, { onDelete: 'cascade' }), + address: text('address').notNull(), + projectId: text('project_id').references(() => project.id, { onDelete: 'set null' }), + isPrimary: boolean('is_primary') + .default(false) + .notNull(), + createdAt: timestamp('created_at') + .$defaultFn(() => new Date()) + .notNull(), + }, + (table) => ({ + uniqueAddress: uniqueIndex('support_inbox_address_address_idx').on(table.address), + inboxIdx: index('support_inbox_address_inbox_idx').on(table.inboxId), + projectIdx: index('support_inbox_address_project_idx').on(table.projectId), + }) +) + +// Support permissions live here, not on `teamMember.role` (delta D-28 - +// `teamMember.role` semantics are unchanged). +export const supportInboxMember = pgTable( + 'support_inbox_member', + { + id: text('id').primaryKey(), + inboxId: text('inbox_id') + .notNull() + .references(() => supportInbox.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + // 'agent' | 'supervisor' | 'admin' + role: text('role').notNull(), + createdAt: timestamp('created_at') + .$defaultFn(() => new Date()) + .notNull(), + }, + (table) => ({ + uniqueInboxUser: uniqueIndex('support_inbox_member_inbox_user_idx').on(table.inboxId, table.userId), + userIdx: index('support_inbox_member_user_idx').on(table.userId), + }) +) + +// Per-team `displayId` allocation for conversations. A row per team, +// incremented with `SELECT … FOR UPDATE` inside the same transaction as the +// conversation insert - not a sequence, because the number must be per-team +// and gap-free enough to read as a ticket number. +export const supportCounter = pgTable('support_counter', { + teamId: text('team_id') + .primaryKey() + .references(() => team.id, { onDelete: 'cascade' }), + nextConversationDisplayId: integer('next_conversation_display_id') + .default(1) + .notNull(), +}) + +// Conversations - the core support ticket entity. +export const conversation = pgTable( + 'conversation', + { + id: text('id').primaryKey(), + inboxId: text('inbox_id') + .notNull() + .references(() => supportInbox.id, { onDelete: 'restrict' }), + // Denormalized for team-scoped queries and isolation checks + teamId: text('team_id') + .notNull() + .references(() => team.id, { onDelete: 'cascade' }), + contactId: text('contact_id') + .notNull() + .references(() => contact.id, { onDelete: 'restrict' }), + // Resolved product - from the receiving address's mapping, or an agent override (delta D-27) + projectId: text('project_id').references(() => project.id, { onDelete: 'set null' }), + displayId: integer('display_id').notNull(), + subject: text('subject'), + // 'open' | 'pending' | 'resolved' | 'snoozed' | 'closed' + status: text('status') + .default('open') + .notNull(), + // 'low' | 'normal' | 'high' | 'urgent', nullable + priority: text('priority'), + assigneeUserId: text('assignee_user_id').references(() => user.id, { onDelete: 'set null' }), + linkedFeedbackId: text('linked_feedback_id').references(() => feedback.id, { onDelete: 'set null' }), + // Root RFC Message-ID, used to thread replies onto this conversation + channelThreadKey: text('channel_thread_key'), + firstResponseAt: timestamp('first_response_at'), + resolvedAt: timestamp('resolved_at'), + snoozedUntil: timestamp('snoozed_until'), + lastActivityAt: timestamp('last_activity_at'), + lastCustomerReplyAt: timestamp('last_customer_reply_at'), + lastAgentReplyAt: timestamp('last_agent_reply_at'), + metadata: jsonb('metadata').$type>(), + createdAt: timestamp('created_at') + .$defaultFn(() => new Date()) + .notNull(), + updatedAt: timestamp('updated_at') + .$defaultFn(() => new Date()) + .notNull(), + }, + (table) => ({ + uniqueTeamDisplayId: uniqueIndex('conversation_team_display_id_idx').on(table.teamId, table.displayId), + teamStatusActivityIdx: index('conversation_team_status_activity_idx').on( + table.teamId, + table.status, + table.lastActivityAt + ), + inboxStatusIdx: index('conversation_inbox_status_idx').on(table.inboxId, table.status), + assigneeStatusIdx: index('conversation_assignee_status_idx').on(table.assigneeUserId, table.status), + contactCreatedAtIdx: index('conversation_contact_created_at_idx').on(table.contactId, table.createdAt), + channelThreadKeyIdx: index('conversation_channel_thread_key_idx').on(table.channelThreadKey), + projectStatusIdx: index('conversation_project_status_idx').on(table.projectId, table.status), + }) +) + +// Messages within a conversation. `kind = 'activity'` stores system events +// ("assigned to Bob", "status → resolved") as messages rather than in a side +// table - this is what lets the Chatwoot-style thread render actions inline +// with replies from a single ordered query. +export const conversationMessage = pgTable( + 'conversation_message', + { + id: text('id').primaryKey(), + conversationId: text('conversation_id') + .notNull() + .references(() => conversation.id, { onDelete: 'cascade' }), + // 'incoming' | 'outgoing' | 'note' | 'activity' + kind: text('kind').notNull(), + body: text('body'), + // Sanitized on ingest - never render raw provider HTML + bodyHtml: text('body_html'), + // 'contact' | 'agent' | 'system' + senderKind: text('sender_kind').notNull(), + senderContactId: text('sender_contact_id').references(() => contact.id, { onDelete: 'set null' }), + senderUserId: text('sender_user_id').references(() => user.id, { onDelete: 'set null' }), + isPrivate: boolean('is_private') + .default(false) + .notNull(), + channelMessageId: text('channel_message_id'), + inReplyTo: text('in_reply_to'), + channelHeaders: jsonb('channel_headers').$type>(), + // 'pending' | 'sent' | 'delivered' | 'failed' | 'bounced' + deliveryStatus: text('delivery_status').default('pending').notNull(), + deliveryError: text('delivery_error'), + metadata: jsonb('metadata').$type>(), + createdAt: timestamp('created_at') + .$defaultFn(() => new Date()) + .notNull(), + }, + (table) => ({ + conversationCreatedAtIdx: index('conversation_message_conversation_created_at_idx').on( + table.conversationId, + table.createdAt + ), + uniqueChannelMessageId: uniqueIndex('conversation_message_channel_message_id_idx').on(table.channelMessageId), + deliveryStatusIdx: index('conversation_message_delivery_status_idx').on(table.deliveryStatus), + }) +) + +// Attachments on a message. Reuses `server/utils/storage` and the existing +// presign flow. +export const conversationAttachment = pgTable( + 'conversation_attachment', + { + id: text('id').primaryKey(), + messageId: text('message_id') + .notNull() + .references(() => conversationMessage.id, { onDelete: 'cascade' }), + storageKey: text('storage_key').notNull(), + fileName: text('file_name').notNull(), + contentType: text('content_type'), + sizeBytes: integer('size_bytes'), + isInline: boolean('is_inline') + .default(false) + .notNull(), + contentId: text('content_id'), + createdAt: timestamp('created_at') + .$defaultFn(() => new Date()) + .notNull(), + }, + (table) => ({ + messageIdx: index('conversation_attachment_message_idx').on(table.messageId), + }) +) + +// CCs and watchers on a conversation. Either `contactId` or `userId` is set, +// never both - a CC'd customer or an internal follower. +export const conversationParticipant = pgTable( + 'conversation_participant', + { + id: text('id').primaryKey(), + conversationId: text('conversation_id') + .notNull() + .references(() => conversation.id, { onDelete: 'cascade' }), + contactId: text('contact_id').references(() => contact.id, { onDelete: 'cascade' }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + // 'cc' | 'follower' + role: text('role').notNull(), + createdAt: timestamp('created_at') + .$defaultFn(() => new Date()) + .notNull(), + }, + (table) => ({ + uniqueConversationContact: uniqueIndex('conversation_participant_conversation_contact_idx').on( + table.conversationId, + table.contactId + ), + uniqueConversationUser: uniqueIndex('conversation_participant_conversation_user_idx').on( + table.conversationId, + table.userId + ), + }) +) + +// Team-scoped tags. `design.md` describes this pair only as "team-scoped +// tags and their join table" without a column list; columns below follow +// the existing `supportCompany` (team-scoped, named entity) and +// `contactLink` (join-style, unique compound index) conventions in this file. +export const supportTag = pgTable( + 'support_tag', + { + id: text('id').primaryKey(), + teamId: text('team_id') + .notNull() + .references(() => team.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + color: text('color'), + createdAt: timestamp('created_at') + .$defaultFn(() => new Date()) + .notNull(), + }, + (table) => ({ + uniqueTeamName: uniqueIndex('support_tag_team_name_idx').on(table.teamId, table.name), + }) +) + +// Join table between conversations and tags. +export const conversationTag = pgTable( + 'conversation_tag', + { + id: text('id').primaryKey(), + conversationId: text('conversation_id') + .notNull() + .references(() => conversation.id, { onDelete: 'cascade' }), + tagId: text('tag_id') + .notNull() + .references(() => supportTag.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at') + .$defaultFn(() => new Date()) + .notNull(), + }, + (table) => ({ + uniqueConversationTag: uniqueIndex('conversation_tag_conversation_tag_idx').on( + table.conversationId, + table.tagId + ), + tagIdx: index('conversation_tag_tag_idx').on(table.tagId), + }) +) + +// Inbound idempotency and audit. A unique key on its own is not enough: a +// failed claim must be replayable without duplicating a processed event - +// `status` + `leaseExpiresAt` support a claim/lease pattern on top of it. +export const supportEmailEvent = pgTable( + 'support_email_event', + { + id: text('id').primaryKey(), + inboxId: text('inbox_id') + .notNull() + .references(() => supportInbox.id, { onDelete: 'cascade' }), + provider: text('provider').notNull(), + providerEventId: text('provider_event_id').notNull(), + rawStorageKey: text('raw_storage_key'), + // 'processing' | 'processed' | 'failed' + status: text('status').default('processing').notNull(), + attemptCount: integer('attempt_count') + .default(0) + .notNull(), + leaseExpiresAt: timestamp('lease_expires_at'), + processedAt: timestamp('processed_at'), + resultConversationId: text('result_conversation_id').references(() => conversation.id, { + onDelete: 'set null', + }), + error: text('error'), + createdAt: timestamp('created_at') + .$defaultFn(() => new Date()) + .notNull(), + }, + (table) => ({ + uniqueProviderEventId: uniqueIndex('support_email_event_provider_event_id_idx').on( + table.provider, + table.providerEventId + ), + inboxIdx: index('support_email_event_inbox_idx').on(table.inboxId), + }) +) From 9ec028c2ca144d51eaad67039a143ae936610082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:23:02 +0200 Subject: [PATCH 055/334] fix(support): use validateBody instead of raw Zod .parse() (delta D-25) All seven support POST/PUT endpoints validated request bodies with bodySchema.parse(await readBody(event)), which throws an uncaught ZodError on bad input -- an unhandled 500 with a stack trace where a 400 was intended. Switch to the existing validateBody() helper in server/utils/validation.ts, already used by every non-support endpoint. Caught by sending a malformed merge request during a live-server support-case walkthrough. Verified: malformed body now returns a structured 400. Co-Authored-By: Claude Sonnet 5 --- server/api/support/companies/[id].put.ts | 3 ++- server/api/support/companies/index.post.ts | 3 ++- server/api/support/contacts/[id].put.ts | 3 ++- server/api/support/contacts/[id]/links.post.ts | 3 ++- server/api/support/contacts/[id]/merge.post.ts | 3 ++- server/api/support/contacts/index.post.ts | 3 ++- server/api/support/teams/[teamId]/settings.put.ts | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/server/api/support/companies/[id].put.ts b/server/api/support/companies/[id].put.ts index 4f206ebe..b506bc0f 100644 --- a/server/api/support/companies/[id].put.ts +++ b/server/api/support/companies/[id].put.ts @@ -23,6 +23,7 @@ import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/ import { requireAuth } from '~/server/utils/auth-middleware' import { requireCompanyAccess } from '~/server/utils/support-access' import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' import { supportCompany } from '~/server/database/schema/support' @@ -35,7 +36,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const session = await requireAuth(event) const companyId = getRouterParam(event, 'id') as string - const body = bodySchema.parse(await readBody(event)) + const body = await validateBody(event, bodySchema) await requireCompanyAccess(companyId, session.user.id) diff --git a/server/api/support/companies/index.post.ts b/server/api/support/companies/index.post.ts index 0bbf6d65..600c938f 100644 --- a/server/api/support/companies/index.post.ts +++ b/server/api/support/companies/index.post.ts @@ -17,6 +17,7 @@ import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/ import { requireAuth } from '~/server/utils/auth-middleware' import { requireTeamMembership } from '~/server/utils/support-access' import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' import { supportCompany } from '~/server/database/schema/support' @@ -29,7 +30,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const session = await requireAuth(event) - const body = bodySchema.parse(await readBody(event)) + const body = await validateBody(event, bodySchema) await requireTeamMembership(body.teamId, session.user.id) diff --git a/server/api/support/contacts/[id].put.ts b/server/api/support/contacts/[id].put.ts index 19a4632a..7bbede5d 100644 --- a/server/api/support/contacts/[id].put.ts +++ b/server/api/support/contacts/[id].put.ts @@ -25,6 +25,7 @@ import { requireAuth } from '~/server/utils/auth-middleware' import { canUpdateContact } from '~/server/utils/contact-merge' import { requireContactAccess } from '~/server/utils/support-access' import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' import { contact, contactIdentity, supportCompany } from '~/server/database/schema/support' @@ -40,7 +41,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const session = await requireAuth(event) const contactId = getRouterParam(event, 'id') as string - const body = bodySchema.parse(await readBody(event)) + const body = await validateBody(event, bodySchema) await requireContactAccess(contactId, session.user.id) diff --git a/server/api/support/contacts/[id]/links.post.ts b/server/api/support/contacts/[id]/links.post.ts index df990bc7..fc04fca4 100644 --- a/server/api/support/contacts/[id]/links.post.ts +++ b/server/api/support/contacts/[id]/links.post.ts @@ -20,6 +20,7 @@ import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/ import { requireAuth } from '~/server/utils/auth-middleware' import { requireContactAccess } from '~/server/utils/support-access' import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' import { contact, contactLink } from '~/server/database/schema/support' import { feedback, project } from '~/server/database/schema/feedback' @@ -32,7 +33,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const session = await requireAuth(event) const contactId = getRouterParam(event, 'id') as string - const body = bodySchema.parse(await readBody(event)) + const body = await validateBody(event, bodySchema) const accessibleContact = await requireContactAccess(contactId, session.user.id) try { diff --git a/server/api/support/contacts/[id]/merge.post.ts b/server/api/support/contacts/[id]/merge.post.ts index e0a527b6..d0fa9fad 100644 --- a/server/api/support/contacts/[id]/merge.post.ts +++ b/server/api/support/contacts/[id]/merge.post.ts @@ -26,6 +26,7 @@ import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/ import { requireAuth } from '~/server/utils/auth-middleware' import { requireContactAccess } from '~/server/utils/support-access' import { backfillContactFields, canMerge, mergeAttributes } from '~/server/utils/contact-merge' +import { validateBody } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' import { contact, contactIdentity, contactLink } from '~/server/database/schema/support' @@ -36,7 +37,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const session = await requireAuth(event) const survivorId = getRouterParam(event, 'id') as string - const body = bodySchema.parse(await readBody(event)) + const body = await validateBody(event, bodySchema) // Access is checked on BOTH contacts. Holding access to the survivor says // nothing about the source, and the source id comes straight from the request. diff --git a/server/api/support/contacts/index.post.ts b/server/api/support/contacts/index.post.ts index a5c06259..9a2a00be 100644 --- a/server/api/support/contacts/index.post.ts +++ b/server/api/support/contacts/index.post.ts @@ -18,6 +18,7 @@ import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/ import { requireAuth } from '~/server/utils/auth-middleware' import { requireTeamMembership } from '~/server/utils/support-access' import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' import { contact, contactIdentity, supportCompany } from '~/server/database/schema/support' @@ -32,7 +33,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const session = await requireAuth(event) - const body = bodySchema.parse(await readBody(event)) + const body = await validateBody(event, bodySchema) await requireTeamMembership(body.teamId, session.user.id) diff --git a/server/api/support/teams/[teamId]/settings.put.ts b/server/api/support/teams/[teamId]/settings.put.ts index 8f775e35..0fabca5f 100644 --- a/server/api/support/teams/[teamId]/settings.put.ts +++ b/server/api/support/teams/[teamId]/settings.put.ts @@ -18,6 +18,7 @@ import { z } from 'zod' import { createSuccessResponse } from '~/server/utils/response' import { requireAuth } from '~/server/utils/auth-middleware' import { requireTeamMembership } from '~/server/utils/support-access' +import { validateBody } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' import { supportTeamSettings } from '~/server/database/schema/support' @@ -27,7 +28,7 @@ export default defineEventHandler(async (event) => { const session = await requireAuth(event) const teamId = getRouterParam(event, 'teamId') as string await requireTeamMembership(teamId, session.user.id) - const body = bodySchema.parse(await readBody(event)) + const body = await validateBody(event, bodySchema) const now = new Date() const [settings] = await db From 0498031f67e89812f6861aa6c7fa61d14a6bc672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:23:07 +0200 Subject: [PATCH 056/334] docs(support): correct scheduler comments after dropping IMAP (delta D-29) Inbound email is webhook-only now -- the IMAP polling driver was dropped from Stage 03 scope. Three shipped Stage 00 comments still justified the scheduler by "Stage 03's IMAP poll"; repoint them at the scheduler's real consumers (Stage 06 SLA sweeper, Stage 08 CSAT dispatch, Stage 09 nightly rollups). Co-Authored-By: Claude Sonnet 5 --- nuxt.config.ts | 3 ++- server/services/scheduler/registry.ts | 4 ++-- server/services/scheduler/tasks/example-ping.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index 2c21a42b..ac88373a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -26,7 +26,8 @@ export default defineNuxtConfig({ }, scheduledTasks: { // Trivial example proving the self-hosted backend wires up (Stage 00). - // Real consumers land in Stage 03 (IMAP poll) and Stage 06 (SLA sweeper). + // Real consumers land in Stage 06 (SLA sweeper), Stage 08 (CSAT dispatch), + // and Stage 09 (nightly rollups). Mail intake is webhook-only. '*/15 * * * *': ['example:ping'], }, }, diff --git a/server/services/scheduler/registry.ts b/server/services/scheduler/registry.ts index 4e362200..21898191 100644 --- a/server/services/scheduler/registry.ts +++ b/server/services/scheduler/registry.ts @@ -6,8 +6,8 @@ const registry = new Map() /** * Register a scheduled task. * - * This is the single call site future stages use (Stage 03's IMAP poll, - * Stage 06's SLA sweeper). Registration only stores the definition in a + * This is the single call site future stages use (Stage 06's SLA sweeper, + * Stage 08's CSAT dispatch). Registration only stores the definition in a * process-wide map — actually running it on a schedule is the job of the * backend-specific adapter (`server/tasks/**` for self-hosted, * `server/api/cron/*.get.ts` for Vercel), both of which look the diff --git a/server/services/scheduler/tasks/example-ping.ts b/server/services/scheduler/tasks/example-ping.ts index 6bfe14e3..041badae 100644 --- a/server/services/scheduler/tasks/example-ping.ts +++ b/server/services/scheduler/tasks/example-ping.ts @@ -1,7 +1,7 @@ /** * Trivial no-op task proving the scheduler mechanism wires up on both - * backends. Not a real task — Stage 03 (IMAP poll) and Stage 06 (SLA - * sweeper) are the first real consumers of `defineScheduledTask`. + * backends. Not a real task — Stage 06 (SLA sweeper) and Stage 08 (CSAT + * dispatch) are the first real consumers of `defineScheduledTask`. * * Imported by both backend adapters (`server/tasks/example/ping.ts` for * self-hosted, `server/api/cron/example-ping.get.ts` for Vercel) so From 07c3f8f809538e6695075557e586588134d5f65f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:23:17 +0200 Subject: [PATCH 057/334] docs(support): drop IMAP, add multi-address routing, settle Stage 09b framing Three planning threads folded into design.md, deltas.md, and the affected stage docs (deltas D-25, D-26, D-27, D-28, D-29, D-30): - Inbound email is webhook-only; IMAP polling and its scheduler/ credential-storage dependencies are removed from Stage 03 scope. - supportInboxAddress and conversation.projectId let one team inbox route several receiving addresses to different products, since email carries no product signal on its own. - Stage 09b (Home) went through three framings before landing: an org-wide stats view (rejected -- org membership doesn't imply membership of every team in it), then a picker-selectable cross-team scope (rejected -- ambiguous click-through into team-owned items), to its current shape: a fixed sidebar destination with its own narrow cross-team read endpoints, decoupled from the team picker entirely. The per-product Support toggle is deferred to Stage 10, where the customer portal gives it something to control. Co-Authored-By: Claude Sonnet 5 --- .../2026-08-11-support-platform/README.md | 10 + .../2026-08-11-support-platform/deltas.md | 155 ++++++++++++++ .../2026-08-11-support-platform/design.md | 57 +++-- .../stage-02-conversation-core.md | 77 +++++-- .../stage-03-inbound-email.md | 42 ++-- .../stage-09b-home.md | 194 ++++++++++++++++++ .../stage-10-customer-portal.md | 12 ++ 7 files changed, 501 insertions(+), 46 deletions(-) create mode 100644 docs/plans/2026-08-11-support-platform/stage-09b-home.md diff --git a/docs/plans/2026-08-11-support-platform/README.md b/docs/plans/2026-08-11-support-platform/README.md index 72ae0078..8a5c78a9 100644 --- a/docs/plans/2026-08-11-support-platform/README.md +++ b/docs/plans/2026-08-11-support-platform/README.md @@ -52,6 +52,7 @@ ls server/api/support/contacts/ | [07](stage-07-automation.md) | Automation rules | 02, 04 | Blocked | | [08](stage-08-csat.md) | CSAT | 04 | Blocked | | [09](stage-09-reporting.md) | Reporting | 02, 06 | Blocked | +| [09b](stage-09b-home.md) | Home (cross-team) | 02 | Future | | [10](stage-10-customer-portal.md) | Customer portal | 02 | Blocked | | [11](stage-11-live-chat.md) | Live chat | 00, 02 | Blocked | | [12](stage-12-social-channels.md) | Social channels | 03, 11 | Blocked | @@ -61,6 +62,11 @@ ls server/api/support/contacts/ Stage 10 (customer portal) ships its ticket list and submit form without KB integration; wire the two together if and when the KB is revived. +**Recorded but not scheduled:** Stage 09b (Home), added August 14, 2026. It is a future task with an +outline-level doc only. Per the dispatch protocol it has **not** been added to `TODO.md` — stages enter +the board when they become unblocked, not before. It also renames `/dashboard` to `/home`, so it touches +the post-login landing route; schedule it when that churn is acceptable. + ## Dependency graph ``` @@ -70,6 +76,7 @@ together if and when the KB is revived. │ ├─→ 06 SLA ─→ 09 Reporting │ ├─→ 07 Automation │ └─→ 08 CSAT + ├─→ 09b Home (cross-team) ├─→ 10 Customer portal ├─→ 13 Importers └─→ 11 Live chat ─→ 12 Social channels @@ -87,6 +94,9 @@ Nothing else starts until it is merged and verified on `main`. - After 04: stages 05, 06, 07, and 08 are mutually independent. This is the widest fan-out in the program — up to four agents. - Stage 09 needs 06. Stage 12 needs both 03 and 11. +- Stage 09b (Home) needs only 02, despite its number — it is a personal cross-team view, not an + org-stats page, so it needs no rollups. It sits at 09b at the requester's direction and can be pulled + forward into the post-02 parallel group whenever wanted. **First usable product is Stage 04.** At that point a team can run real email support end to end. Stages 05–07 make it competitive with Freshdesk. 08–10 close the gap. 11–13 are expansion. diff --git a/docs/plans/2026-08-11-support-platform/deltas.md b/docs/plans/2026-08-11-support-platform/deltas.md index d3469785..97c89483 100644 --- a/docs/plans/2026-08-11-support-platform/deltas.md +++ b/docs/plans/2026-08-11-support-platform/deltas.md @@ -319,3 +319,158 @@ because the only prior coverage was indirect, through endpoint tests that presum **Lesson for this program:** a helper with no dedicated unit test, exercised only indirectly through other tests that may not reproduce the real error shape, is a live gap even when everything is green. `isUniqueViolation()` now has its own test file asserting the exact wrapped shape. + +### D-25 — Zod `.parse()` in support endpoints returned 500 instead of 400 + +**Found:** live-server support-case walkthrough after Stage 01. **Status:** RESOLVED (2026-08-14). + +All seven support POST/PUT endpoints validated request bodies with a bare `bodySchema.parse(await +readBody(event))`. Zod throws on failure, nothing caught it, and the result was an uncaught 500 with a +stack trace where a 400 was intended. The repo already had `validateBody()` in +`server/utils/validation.ts`, which wraps `safeParse` and throws a structured 400 — every non-support +endpoint uses it. Support simply never adopted it. + +Caught by sending a merge request with the wrong field name during a manual walkthrough. Fixed across +`contacts/index.post`, `contacts/[id].put`, `contacts/[id]/merge.post`, `contacts/[id]/links.post`, +`companies/index.post`, `companies/[id].put`, and `teams/[teamId]/settings.put`. + +**Convention for Stage 02 onward:** support endpoints use `validateBody(event, schema)`. Never call +`.parse()` directly on a request body. + +--- + +## Stage 02 (planning) + +### D-26 — Support configuration had no defined home in the UI + +**Found:** UI design discussion before Stage 02. **Status:** resolved in plan (2026-08-14). + +The plan said "`/support` in `AppSidebar.vue`" and "inbox settings UI" without saying where enablement or +configuration lived, and three facts in the existing code made the obvious readings wrong: + +1. **`AppSidebar.vue` already has a group literally named "Support"** — containing only *Settings*. It is + a mis-named misc/system group. Adding a support module collides with it, so it is renamed **System**. +2. **The sidebar does not react to feature toggles at all.** `Roadmap` and `Changelog` are hardcoded + `disabled: true` placeholders. "Which modules does this workspace use" was already an unsolved + problem; support did not introduce it. +3. **Feature toggles are per-project only** (`project.settings.feedbackEnabled` and friends), but an + inbox is team-scoped. A per-product toggle alone structurally cannot configure support. + +**Decisions:** + +- Two distinct surfaces, previously conflated: the **agent workspace** (`/support`, team-scoped) and the + **customer entry point** (per-product, public board). They get separate controls. +- **Enablement is per team**, in a new **Tools** tab in `/settings`, listing Feedback / Roadmap / + Changelog / Support. It drives sidebar visibility, which retroactively fixes point 2 above. +- **Inbox configuration is in-context** at `/support/settings`, not in global settings — Stages 05–07 add + macros, SLA, and automation to it, which would make a global tab unreasonably deep. +- **Switching Support off** hides nav and stops inbound processing, but preserves conversations and + contacts, matching the existing per-product disable dialog's "data will not be deleted" contract. +- **Contacts join the sidebar** under the new Support group, closing the Stage 01 loose end where they + were reachable only by URL. + +### D-27 — One shared inbox needs multi-address routing to attribute products + +**Found:** UI design discussion before Stage 02. **Status:** resolved in plan (2026-08-14). + +All products feed one team inbox, and agents filter by product. But **email carries no product signal** — +a customer mailing `support@acme.com` gives nothing to attribute from, so with one address every email +ticket would arrive unattributed and per-product reporting in Stage 09 would be meaningless. + +`supportInbox.emailAddress` is a single unique column and cannot express "three addresses, two mapped to +products". Two schema additions follow, neither in the original design: + +- **`supportInboxAddress`** — `id`, `inboxId` (FK cascade), `address` (unique), `projectId` (FK project, + set null — null means unattributed), `isPrimary`, `createdAt`. Teams genuinely want several addresses + per inbox: one per product, plus general team addresses. +- **`conversation.projectId`** — nullable FK to project. The design's conversation table has + `linkedFeedbackId` but no product link. + +Resolution order: the receiving address's mapping, then an agent override on the conversation. Portal +(Stage 10) and chat (Stage 11) submissions attribute from page context instead. + +`supportInbox.emailAddress` is retained as the primary sending identity; `supportInboxAddress` governs +what the inbox *receives*. + +### D-28 — Module toggles want a team-admin role that does not yet exist + +**Found:** UI design discussion before Stage 02. **Status:** DEFERRED — future reference, not scheduled. + +Enabling or disabling a whole module has a much larger blast radius than editing a signature: switching +Support off stops inbound mail for the entire team. That argues for restricting module toggles to admins +while leaving day-to-day inbox configuration open to team members. + +**It is not being built now.** `teamMember.role` has `admin` and `member` with **currently equivalent +permissions**, and `design.md` explicitly states "`teamMember.role` semantics are not changed". Acting on +this would be the first real differentiation of that column, and it is not worth pulling that change into +an already-oversized Stage 02. + +**For now:** module toggles require team membership only, consistent with every other settings surface in +the app today. + +**If revisited:** restrict module enable/disable to `teamMember.role === 'admin'`, keep `/support/settings` +open to members, and note that the design's freeze on `teamMember.role` was about keeping *support* +permissions off it (those live on `supportInboxMember.role`) — workspace administration is arguably a +different question. Whoever picks this up should decide deliberately rather than treating the freeze as +either binding or irrelevant. + +### D-29 — IMAP driver dropped from Stage 03 + +**Found:** scope decision (2026-08-14). **Status:** resolved in plan. + +Inbound email is **webhook-only**. The IMAP polling driver is removed from Stage 03, taking with it the +scheduled-poll registration, encrypted IMAP credential storage in `channelConfig`, and the parallel +poll-vs-webhook code path. + +The Stage 00 scheduler is unaffected and still required — Stage 06's SLA breach sweeper, Stage 08's CSAT +dispatch, and Stage 09's nightly rollups all use it. Only mail intake stops depending on it. + +Self-hosted deployments now require a webhook-capable mail provider. If IMAP is ever reinstated for +self-hosters with no provider, it re-enters as an additional driver behind the same `InboundMessage` +normalization — the adapter boundary in Stage 03 is what keeps that possible. + +### D-30 — The team picker's "Workspace" entry is not an organization scope + +**Found:** org-workspace request (2026-08-14). **Status:** recorded; addressed by Stage 09b, not +scheduled. Went through three framings before settling — see below. + +`TeamSwitcher.vue` renders a "Workspace" row above the team list, styled like a team entry, and it reads +as a cross-team scope. It is not one: + +- `switchToDefaultTeam()` resolves the team **named `Default`** and activates it. The app remains in a + single-team scope. +- `additionalTeams` filters `name !== 'Default'`, hiding that team from the list and silently reusing it + as an org proxy. +- `displaySubtitle` renders "All projects" in that state, which is **false** the moment a second team + owns a project. + +**Why it matters here:** anyone assessing a cross-team view will look at this component and conclude the +scope already exists and only needs new pages hung off it. It does not — the visible affordance is a team +in disguise, and the resolution below removes it rather than building behind it. + +**Three framings, in order:** + +1. **"Organization workspace"** — org-wide stats with a per-team breakdown. Rejected: organization + membership does not imply membership of every team in it, so an org-wide page showing only a user's + subset is a partial view of something claiming to be complete, and "show everything in the org" is the + obvious wrong shortcut. +2. **"Home" as a picker entry** — a third option alongside the teams that reroutes every team-scoped + surface (`/support`, `/feedback`, `/products`) into a cross-team aggregate while selected. Fixed the + authorization framing but introduced a harder one: clicking through from an aggregate view to one + team-owned item raises the question of whether the picker's selection silently changes underneath the + user. Every surface would need its own answer, "new conversation" has no implicit team while Home is + selected, and a live cross-team inbox needs new realtime fan-out approaching `MAX_CHANNELS_PER_PEER`. +3. **"Home" as a fixed sidebar destination, decoupled from the picker (current).** The picker goes back to + being purely team selection — the "Workspace" row is **deleted**, not replaced with a scope resolver. + Home becomes its own small set of read-only cross-team pages (`/home`, `/home/inbox`, + `/home/feedback`) with their own narrow endpoints, linking out to the real, team-scoped pages for + everything. `/support` and `/feedback` are untouched. The click-through question stops existing because + Home never claims to represent the user's current context. + +**Consequences of the final framing:** the stage's dependency is Stage 02 only (personal reads need no +`supportMetricDaily` rollups). It renames `/dashboard` to `/home`, made unconditionally visible rather +than gated on organization state. And a `team:` realtime-publish requirement that framing 2 had added +to Stage 02 is **withdrawn** — framing 3's read-only Home pages reuse the existing `user:` notification +channel instead, so Stage 02 needs no change for this at all. + +The full detail is in `stage-09b-home.md`. diff --git a/docs/plans/2026-08-11-support-platform/design.md b/docs/plans/2026-08-11-support-platform/design.md index 00b77888..a5afee15 100644 --- a/docs/plans/2026-08-11-support-platform/design.md +++ b/docs/plans/2026-08-11-support-platform/design.md @@ -18,8 +18,11 @@ public domains. | Contact ↔ feedback relation | **Separate.** Linked explicitly via `contactLink`, never structurally coupled | | Conversation statuses | **Fixed set**, not per-inbox customizable | | Realtime broker | **Redis only**, via the Redis wire protocol | -| Mail intake | **Adapter**: provider webhook first, IMAP second | +| Mail intake | **Adapter**: provider webhook | | Knowledge base | **Deferred**, dropped from this program | +| Mail intake protocol | **Webhook only.** IMAP polling dropped (delta D-29) | +| Inbox per team | **One shared inbox, many receiving addresses**; product resolved per address | +| Module enablement | **Per team**, in a Tools tab in `/settings` (delta D-26) | | Parity target | Full Zendesk/Freshdesk parity, staged | ### Why email-only first @@ -34,9 +37,23 @@ An inbox is one channel endpoint (`support@acme.com`). Teams need more than one and some want one desk spanning several products. Modelling the inbox as its own entity with a `type` column means Stage 11 (chat) and Stage 12 (social) are new inbox types rather than a schema rewrite. -Products still get a Support tab; it filters to inboxes linked to that product via -`supportInbox.projectId`, and `supportEnabled` joins the existing feature toggles in -`ProductSettingsFeatures.vue`. +### How products map onto one inbox + +A team runs **one shared inbox** with **many receiving addresses**. Each address optionally maps to a +product, so `billing@acme.com` attributes to Billing while `support@acme.com` stays unattributed. Agents +work one queue and filter by product; they can override the product on any conversation. + +This exists because **email carries no product signal**. With a single address, every email ticket would +arrive unattributed and per-product reporting in Stage 09 would be meaningless. Portal (Stage 10) and +chat (Stage 11) submissions attribute from page context instead and need no address mapping. + +`supportInbox.emailAddress` is the primary *sending* identity; `supportInboxAddress` governs what the +inbox *receives*. See delta D-27. + +Products get a Support tab and a `supportEnabled` feature toggle in `ProductSettingsFeatures.vue` +**from Stage 10**, not Stage 02 — the customer-facing entry point they control is the customer portal, +so shipping them earlier would mean a switch that does nothing. Module enablement for the agent-facing +side is per team, in a Tools tab in `/settings`, and is independent of any product toggle (delta D-26). ### Why contacts and feedback stay separate @@ -144,7 +161,11 @@ envelope's own `type` overwrite the frame type and silently breaks client dispat `/api/support/inbound/[provider]`. Signature verified, then recorded in `supportEmailEvent` for idempotency before any processing — providers retry, and a duplicate delivery must not create a second ticket. -- **`imap` driver** (Stage 03): scheduled poll for self-hosted deployments with no provider. + +**Webhook only.** The IMAP polling driver was dropped (delta D-29), taking with it the scheduled poll, +encrypted IMAP credentials, and the parallel intake path. Self-hosted deployments require a +webhook-capable mail provider. The Stage 00 scheduler is still required by Stages 06, 08, and 09 — only +mail intake stops depending on it. ### Deployment @@ -154,7 +175,8 @@ duration cap and no instance pinning. But the repo cannot currently self-host at Stage 00 closes this. Scheduling differs by mode: Vercel Cron on cloud, Nitro scheduled tasks on VM, behind one interface. -Needed by Stage 03 (IMAP poll) and Stage 06 (SLA sweeper). +Needed by Stage 06 (SLA sweeper), Stage 08 (CSAT dispatch), and Stage 09 (nightly rollups). Mail intake +does not use it — inbound is webhook-only (delta D-29). --- @@ -199,21 +221,30 @@ Indexes: unique `(contactId, entityType, entityId)`; `(entityType, entityId)`. `defaultAssigneeUserId` (FK user, set null), `isEnabled`, `createdAt`, `updatedAt`. Indexes: unique `(teamId, slug)`; unique `(emailAddress)`; `(teamId)`; `(projectId)`. +**`supportInboxAddress`** — the addresses one inbox receives on, and the product each maps to. Teams run +one inbox per team but want several addresses: one per product, plus general team addresses. +`id`, `inboxId` (FK, cascade), `address` (unique), `projectId` (FK project, set null — null means +unattributed), `isPrimary`, `createdAt`. +Indexes: unique `(address)`; `(inboxId)`; `(projectId)`. + **`supportInboxMember`** — support permissions live here. **`teamMember.role` semantics are not -changed.** +changed.** Module enablement in the Tools tab requires team membership only; restricting it to team +admins was considered and deferred (delta D-28). `id`, `inboxId` (FK, cascade), `userId` (FK user, cascade), `role` (`agent` | `supervisor` | `admin`), `createdAt`. Indexes: unique `(inboxId, userId)`; `(userId)`. **`conversation`** `id`, `inboxId` (FK, restrict), `teamId` (FK, cascade — denormalized for team-scoped queries and -isolation checks), `contactId` (FK contact, restrict), `displayId` (integer, human-readable ticket -number), `subject`, `status` (`open` | `pending` | `resolved` | `snoozed` | `closed`), `priority` +isolation checks), `contactId` (FK contact, restrict), `projectId` (FK project, set null — the resolved +product, from the receiving address's mapping or an agent override), `displayId` (integer, +human-readable ticket number), `subject`, `status` (`open` | `pending` | `resolved` | `snoozed` | +`closed`), `priority` (`low` | `normal` | `high` | `urgent`, nullable), `assigneeUserId` (FK user, set null), `linkedFeedbackId` (FK feedback, set null), `channelThreadKey` (root RFC Message-ID), `firstResponseAt`, `resolvedAt`, `snoozedUntil`, `lastActivityAt`, `lastCustomerReplyAt`, `lastAgentReplyAt`, `metadata` (jsonb), `createdAt`, `updatedAt`. Indexes: unique `(teamId, displayId)`; `(teamId, status, lastActivityAt)`; `(inboxId, status)`; -`(assigneeUserId, status)`; `(contactId, createdAt)`; `(channelThreadKey)`. +`(assigneeUserId, status)`; `(contactId, createdAt)`; `(channelThreadKey)`; `(projectId, status)`. **`supportCounter`** — per-team `displayId` allocation. A row per team incremented with `SELECT … FOR UPDATE` inside the same transaction as the conversation insert. @@ -270,9 +301,11 @@ Introduced by the stage that needs them: `businessHours`, `slaPolicy`, `slaTarge | 2 | New `server/services/realtime/` with `redis` (ioredis) and `memory` drivers; rewrite `ws-connections.ts` to channel subscriptions with subscribe-time authorization | 00 | In-memory fan-out is broken across instances on both Vercel and any multi-instance self-host | | 3 | `Dockerfile` + production `docker-compose.yml` with `app`, `valkey`, `minio`, and Caddy | 00 | There is no Dockerfile today and prod compose runs only Postgres — self-hosting is currently impossible. Caddy on-demand TLS is what makes `project.customDomain` work off-Vercel | | 4 | Store adapter for `server/utils/rate-limit.ts` (`memory` \| `redis`), reusing the same Redis | 00 | Same in-memory flaw, already flagged in `TODO.md`. Inbound webhooks need throttling that holds across instances | -| 5 | Scheduler abstraction (Vercel Cron \| Nitro scheduled task) | 00 | Needed by the IMAP poll (03) and the SLA breach sweeper (06) | +| 5 | Scheduler abstraction (Vercel Cron \| Nitro scheduled task) | 00 | Needed by the SLA breach sweeper (06), CSAT dispatch (08), and nightly rollups (09). Not by mail intake — inbound is webhook-only | | 6 | New `server/utils/support-access.ts`: `requireInboxAccess`, `requireConversationAccess`, `requireContactAccess`, `resolveInboxByAddress` | 02 | Mirrors `project-access.ts`. Support permissions go on `supportInboxMember.role`; **`teamMember` semantics unchanged** | -| 7 | `/support` added to `AppSidebar.vue` and to `protectedRoutes` in `middleware/auth.global.ts`; `supportEnabled` added to product feature toggles | 02 | Route-guard rule 10 in `.agents/CLAUDE.md` | +| 7 | `AppSidebar.vue`: existing mis-named `Support` group renamed to `System`; new `Support` group with Inbox and Contacts; `/support` added to `protectedRoutes` | 02 | Route-guard rule 10 in `.agents/CLAUDE.md`. The old group holds only Settings; the name is needed for the real module (delta D-26) | +| 7b | New per-team **Tools** tab in `/settings` with Feedback/Roadmap/Changelog/Support module toggles driving sidebar visibility | 02 | Feature toggles are per-project today, but an inbox is team-scoped. Also replaces the hardcoded `disabled: true` Roadmap/Changelog placeholders in `AppSidebar.vue` | +| 7c | `supportEnabled` added to `ProductSettingsFeatures.vue` and a product Support tab | 10 | Deferred from 02: the customer-facing entry point these control is the Stage 10 portal, so shipping them earlier means a switch that does nothing | | 8 | New notification types in `server/utils/notifications.ts` (`conversation_assigned`, `conversation_mention`, `sla_breach`) + preference toggles in `SettingsNotifications` | 02/06 | Reuses shipped notification infrastructure rather than building a parallel one | | 9 | Extend `lib/email.ts` with an options bag (custom From/Reply-To, `In-Reply-To`/`References`/`Message-ID`, attachments); add `lib/support-email.ts` | 04 | Today it only sends fixed transactional templates. Without real RFC-5322 threading every reply opens a new ticket | | 10 | Register support routes in `server/utils/openapi.ts` | 02+ | Public API docs are published at `/api-docs` | diff --git a/docs/plans/2026-08-11-support-platform/stage-02-conversation-core.md b/docs/plans/2026-08-11-support-platform/stage-02-conversation-core.md index b55cdbc8..a40c744f 100644 --- a/docs/plans/2026-08-11-support-platform/stage-02-conversation-core.md +++ b/docs/plans/2026-08-11-support-platform/stage-02-conversation-core.md @@ -20,12 +20,22 @@ filter. Replies in this stage are stored, not sent — Stage 04 sends them. ### 1. Schema Add to `server/database/schema/support.ts`, per `design.md` → Data model → Stage 02: `supportInbox`, -`supportInboxMember`, `conversation`, `supportCounter`, `conversationMessage`, +`supportInboxAddress`, `supportInboxMember`, `conversation`, `supportCounter`, `conversationMessage`, `conversationAttachment`, `conversationParticipant`, `supportTag`, `conversationTag`, `supportEmailEvent`. `supportEmailEvent` is created here even though Stage 03 is its first writer — it keeps the inbound -pipeline from needing a migration mid-stage. +pipeline from needing a migration mid-stage. The same applies to `supportInboxAddress`: Stage 03 is its +first reader, but it is created here so mail intake needs no migration. + +**Multi-address inboxes (delta D-27).** One team inbox serves every product, so the receiving address is +the only product signal an email carries. `supportInboxAddress` holds `id`, `inboxId` (FK cascade), +`address` (unique), `projectId` (FK project, set null — null means unattributed), `isPrimary`, +`createdAt`. Teams want several addresses per inbox: one per product, plus general team addresses. +`supportInbox.emailAddress` remains the primary *sending* identity; `supportInboxAddress` governs what +the inbox *receives*. + +`conversation` gains a nullable `projectId` (FK project, set null) for the resolved product. **`displayId` allocation.** A `supportCounter` row per team, incremented with `SELECT … FOR UPDATE` inside the same transaction as the conversation insert. Not a sequence, because the number must be @@ -50,11 +60,12 @@ Extend `server/utils/support-access.ts`: | ---------------------------------------------- | --------------- | ----------------------------------------------------------------- | | `/api/support/inboxes` | GET/POST | Team-scoped list and create | | `/api/support/inboxes/[id]` | GET/PUT/DELETE | Detail, settings, delete | +| `/api/support/inboxes/[id]/addresses` | GET/POST/DELETE | Receiving addresses and their optional product mapping | | `/api/support/inboxes/[id]/members` | GET/POST/DELETE | Agent membership | -| `/api/support/conversations` | GET | Filter by inbox, status, assignee, tag, contact; cursor-paginated | +| `/api/support/conversations` | GET | Filter by inbox, status, assignee, tag, contact, product; cursor-paginated | | `/api/support/conversations` | POST | Manual creation — the Stage 02 entry point | | `/api/support/conversations/[id]` | GET | Detail with contact and participants | -| `/api/support/conversations/[id]` | PATCH | Status, priority, assignee, subject | +| `/api/support/conversations/[id]` | PATCH | Status, priority, assignee, subject, product | | `/api/support/conversations/[id]/messages` | GET/POST | Thread; `POST` accepts `kind` of `outgoing` or `note` | | `/api/support/conversations/[id]/participants` | POST/DELETE | CC and followers | | `/api/support/conversations/[id]/tags` | POST/DELETE | Tag assignment | @@ -91,13 +102,42 @@ Options API throughout. Skeletons while loading; error states with retry. ### 5. Navigation and settings -- `/support` in `components/sidebar/AppSidebar.vue`. +Settled in deltas D-26 and D-28. Two surfaces were previously conflated and are now separate: the +**agent workspace** (`/support`, team-scoped) and the **customer entry point** (per-product, public +board). Stage 02 builds only the first. + +**Sidebar** — `components/sidebar/AppSidebar.vue`: + +- **Rename the existing `Support` group to `System`.** It contains only *Settings* and is a mis-named + misc group; the name is needed for the real module. This is a rename, not a move — Settings stays put. +- Add a **`Support` group** with *Inbox* (`/support`) and *Contacts* (`/support/contacts`), inside the + `hasActiveOrganization === true` block alongside `Feedback` and `Management`. This also closes the + Stage 01 loose end where contacts were reachable only by URL. - `/support` prefix added to `protectedRoutes` in `middleware/auth.global.ts`. -- `supportEnabled` added to the feature toggles in `components/products/ProductSettingsFeatures.vue`, - alongside `feedbackEnabled` / `roadmapEnabled` / `changelogEnabled`. -- A Support tab in `pages/products/[slug].vue` filtering to inboxes with `projectId` set to that product. -- Inbox settings UI: name, linked product, signature, agent membership. Channel configuration is - Stage 03. + +**Team module enablement** — a new **Tools** tab in `pages/settings/index.vue`: + +- Lists Feedback / Roadmap / Changelog / Support as **per-team** module toggles, stored in + `supportTeamSettings` for support (`supportEnabled`, default false) and alongside it for the others. +- Drives sidebar group visibility. `Roadmap` and `Changelog` are currently hardcoded `disabled: true` + placeholders in `AppSidebar.vue`; this replaces that with real state, so the tab is not + support-specific scaffolding. +- **Disabling Support hides the nav group and stops inbound processing, but preserves conversations and + contacts.** Re-enabling restores them intact. Follow the wording contract already set by the + `ProductSettingsFeatures.vue` disable dialog: "Your data will not be deleted." +- **Permissions: team membership only**, matching every other settings surface today. Restricting module + toggles to admins was considered and deliberately deferred — see delta D-28. Do not introduce a + `teamMember.role` check here. + +**Inbox configuration** — in-context at `/support/settings`, **not** a global settings tab. Stages 05–07 +add macros, SLA, and automation to this surface, which would make a `/settings` tab unreasonably deep. +Stage 02 covers: inbox name, signature, agent membership, and the receiving-address list with each +address's optional product mapping. Channel and provider configuration is Stage 03. + +**Not in this stage:** the per-product `supportEnabled` toggle in `ProductSettingsFeatures.vue` and the +product Support tab. The customer-facing entry point they would control is the Stage 10 customer portal, +so shipping the toggle here would mean a switch that visibly does nothing for seven stages. Deferred to +Stage 10. ### 6. Notifications @@ -118,23 +158,30 @@ toggles in `SettingsNotifications`. Reuse the existing infrastructure; do not bu conversation endpoint. Cross-tenant isolation is tested. 6. Deleting an inbox does not orphan conversations — the FK is `restrict`; the API returns a clear 409. 7. `/support` redirects to `/login` when signed out. -8. `yarn harness:verify` green on `support-platform`. +8. Disabling Support in the Tools tab hides the sidebar group; re-enabling restores it with all + conversations and contacts intact. +9. An inbox with three receiving addresses, two mapped to products, resolves each to the correct + `conversation.projectId`; an agent override persists and is not reverted by later activity. +10. `yarn harness:verify` green on `support-platform`. ## TODO items Items 1 and 2 block everything else. Items 5, 6, and 7 can run in parallel once the API lands. -- [ ] Add inbox and conversation tables to `server/database/schema/support.ts` with all indexes; generate migration +- [ ] Add inbox and conversation tables to `server/database/schema/support.ts` with all indexes, including `supportInboxAddress` and the nullable `conversation.projectId`; generate migration - [ ] Extend `server/utils/support-access.ts` with `requireInboxAccess`, `requireConversationAccess`, `resolveInboxByAddress`; unit tests including the team-admin bypass - [ ] **Replace the deny branch in `server/utils/realtime-channels.ts`.** `inbox:` and `conversation:` currently deny unconditionally because these tables did not exist in Stage 00, and `tests/realtime-channels.test.ts` asserts that denial. Swap it for `requireInboxAccess` / `requireConversationAccess` and update those tests — otherwise the agent UI silently receives no realtime events. See delta D-04 - [ ] Implement `displayId` allocation via `supportCounter` with `SELECT … FOR UPDATE` in the insert transaction; concurrency test with 100 parallel inserts - [ ] Add inbox CRUD + membership endpoints -- [ ] Add conversation list/create/get/patch endpoints with filters and cursor pagination; emit `activity` messages on every status, priority, and assignee change +- [ ] Add receiving-address endpoints (`/api/support/inboxes/[id]/addresses`) with per-address product mapping and same-team `projectId` validation +- [ ] Add conversation list/create/get/patch endpoints with filters (including product) and cursor pagination; emit `activity` messages on every status, priority, assignee, and product change - [ ] Add message, participant, and tag endpoints; publish thin realtime envelopes on `conversation:` and `inbox:` for every write - [ ] Build the `/support` three-pane UI: inbox switcher, filtered conversation list, thread pane rendering all four message kinds, contact drawer - [ ] Build the composer with an unmistakable reply/note toggle; messages are stored only, not sent, in this stage -- [ ] Add `/support` to `AppSidebar.vue` and `protectedRoutes`; add `supportEnabled` toggle and the product Support tab -- [ ] Add inbox settings UI (name, linked product, signature, agents) +- [ ] Rename the existing `Support` sidebar group to `System`; add a real `Support` group with Inbox and Contacts; add `/support` to `protectedRoutes` +- [ ] Add the per-team Tools tab to `/settings` with Feedback/Roadmap/Changelog/Support module toggles driving sidebar visibility, replacing the hardcoded `disabled: true` placeholders; team membership only, no role check (delta D-28) +- [ ] Implement disable semantics: hide nav and stop inbound processing while preserving conversations and contacts +- [ ] Build `/support/settings` with inbox name, signature, agent membership, and the receiving-address list with product mapping - [ ] Add `conversation_assigned` and `conversation_mention` notification types and preference toggles - [ ] Register support inbox and conversation routes in `server/utils/openapi.ts` - [ ] Add E2E coverage: create conversation, reply, add note, change status, verify activity message and live update diff --git a/docs/plans/2026-08-11-support-platform/stage-03-inbound-email.md b/docs/plans/2026-08-11-support-platform/stage-03-inbound-email.md index 6c8d33fb..67b41d25 100644 --- a/docs/plans/2026-08-11-support-platform/stage-03-inbound-email.md +++ b/docs/plans/2026-08-11-support-platform/stage-03-inbound-email.md @@ -8,11 +8,16 @@ conversation rather than opening a new ticket. ## Scope -**In:** the channel adapter, provider webhook driver, IMAP driver, MIME parsing, threading, contact -resolution, attachment ingest, inbox channel configuration UI. +**In:** the channel adapter, provider webhook drivers, MIME parsing, threading, contact resolution, +product attribution from the receiving address, attachment ingest, inbox channel configuration UI. **Out:** sending anything. Stage 04 owns all outbound mail, including auto-replies. +**Out — IMAP.** Inbound is **webhook-only** (delta D-29). There is no polling driver, no scheduled mail +fetch, and no IMAP credential storage. Self-hosted deployments require a webhook-capable mail provider. +If IMAP is ever reinstated it enters as another driver behind the same `InboundMessage` normalization, +which is what the adapter boundary below exists to allow. + ## Work ### 1. Channel adapter @@ -24,7 +29,6 @@ resolution, attachment ingest, inbox channel configuration UI. `cc[]`, `subject`, `text`, `html`, `attachments[]`, `receivedAt`, `rawHeaders`. - `webhook/postmark.ts`, `webhook/mailgun.ts` — signature verification plus provider payload → `InboundMessage`. -- `imap.ts` — poll a mailbox, parse MIME, produce the same shape. - `index.ts` — driver selection from `SUPPORT_CHANNEL_PROVIDER`. Every driver produces `InboundMessage`. **Nothing downstream may know which provider it came from.** @@ -44,10 +48,13 @@ Order of operations matters and is a correctness requirement: 3. Archive the raw payload to storage (`rawStorageKey`) before parsing, so a parse failure is debuggable and replayable. 4. Parse to `InboundMessage`. -5. Resolve the inbox via `resolveInboxByAddress` against `to[]` and `cc[]`. No match → record the event - with an error and return 200; do not 404, or the provider will retry forever. +5. Resolve the inbox via `resolveInboxByAddress` against `to[]` and `cc[]`, matching against + `supportInboxAddress` rows. No match → record the event with an error and return 200; do not 404, or + the provider will retry forever. 6. Resolve or create the contact. -7. Resolve or create the conversation. +7. Resolve or create the conversation. On creation, set `conversation.projectId` from the matched + `supportInboxAddress.projectId` (null when the address is unmapped) — see delta D-27. Never overwrite + the product on an existing conversation; an agent may have corrected it. 8. Insert the `incoming` message and attachments, update `lastActivityAt` and `lastCustomerReplyAt`, publish realtime envelopes. 9. Stamp `processedAt` and `resultConversationId` on the event. On failure, record a sanitized error, @@ -89,17 +96,11 @@ Look up `contactIdentity` on `(teamId, 'email', fromAddress)`. Create the contac using the `From` display name. `cc[]` addresses become `conversationParticipant` rows with `role: 'cc'`, creating contacts for them as needed. -### 6. IMAP driver - -Register a scheduled task through the Stage 00 scheduler. Poll, fetch unseen messages, feed the same -pipeline, mark seen only after successful processing. Store credentials encrypted in -`supportInbox.channelConfig`. - -### 7. Inbox configuration UI +### 6. Inbox configuration UI -Channel tab on inbox settings: provider selection, inbound address, the forwarding address to point MX -or a forwarding rule at, webhook signing secret, IMAP credentials, and a connection test with a clear -pass/fail result. +Channel tab on `/support/settings`: provider selection, the `supportInboxAddress` list with each +address's optional product mapping, the forwarding address to point MX or a forwarding rule at, the +webhook signing secret, and a connection test with a clear pass/fail result. ## Acceptance criteria @@ -115,7 +116,10 @@ pass/fail result. 7. An out-of-office auto-reply does not reopen a resolved conversation. 8. An email to an unknown address is recorded with an error and returns 200, not 404. 9. Quote stripping produces a clean body across Gmail, Outlook, and Apple Mail samples. -10. `yarn harness:verify` green on `support-platform`. +10. An email to a product-mapped address creates a conversation with that `projectId`; an email to an + unmapped address creates one with `projectId` null. A reply to a conversation whose product an agent + corrected does not revert it. +11. `yarn harness:verify` green on `support-platform`. ## TODO items @@ -129,8 +133,8 @@ pass/fail result. - [ ] Implement attachment ingest to storage with inline `Content-ID` mapping and a per-message size cap - [ ] Implement auto-response detection (`Auto-Submitted`, `X-Autoreply`, null return-path) so bounces do not reopen or loop - [ ] Implement contact and CC-participant resolution from `From` and `Cc` -- [ ] Implement the IMAP driver with encrypted credentials and a scheduled poll via the Stage 00 scheduler -- [ ] Build the inbox channel configuration UI with provider setup, forwarding address, and a connection test +- [ ] Implement product attribution on conversation creation from the matched `supportInboxAddress.projectId`, never overwriting an existing conversation's product +- [ ] Build the inbox channel configuration UI on `/support/settings` with provider setup, the receiving-address list with per-address product mapping, forwarding address, and a connection test - [ ] Add E2E coverage: inbound mail creates a ticket, a reply threads onto it, a duplicate delivery does not double it ## Risks diff --git a/docs/plans/2026-08-11-support-platform/stage-09b-home.md b/docs/plans/2026-08-11-support-platform/stage-09b-home.md new file mode 100644 index 00000000..1690fd3e --- /dev/null +++ b/docs/plans/2026-08-11-support-platform/stage-09b-home.md @@ -0,0 +1,194 @@ +# Stage 09b — Home + +**Depends on:** Stage 02. **Blocks:** nothing. +**Read `design.md` first.** + +**Goal:** A fixed, always-visible **Home** destination showing what a user is personally involved in — +assigned tickets, recent feedback — across every team they belong to, without requiring them to switch +teams one at a time to find their own work. + +> Outline-level detail, recorded 2026-08-14 as a future task. Not scheduled, not dispatched. Refine into +> full step detail when unblocked. + +**Positioned at 09b at the requester's direction**, and numbered `09b` so Stages 10–13 are not renumbered +(they are referenced by number across `design.md`, `deltas.md`, and seven stage docs). + +**Its only dependency is Stage 02**, and it stays light because of a design choice explained below: Home +is a small set of its own cross-team **read** pages, not a mode that changes what `/support` and +`/feedback` mean. It can be pulled forward into the post-Stage-02 parallel group alongside Stages 10, 11, +and 13 whenever wanted. + +## Home is a nav destination, not a picker scope + +This went through two earlier drafts and it is worth recording why they were rejected, so nobody +re-derives the same dead ends. + +**Draft 1 — "Organization workspace"**, an org-wide stats view. Rejected: organization membership does +not imply membership of every team in it, so an org-wide page showing only a user's subset is a partial +view of something claiming to be complete, and "show everything in the org" is the obvious wrong +shortcut. + +**Draft 2 — "Home" as a third entry in the team picker**, alongside the teams, which reroutes every +team-scoped surface (`/support`, `/feedback`, `/products`, …) into a cross-team aggregate while selected. +This fixed the authorization framing but introduced a harder problem: **what happens when you click +through from an aggregate view to one specific item?** A ticket in the Home inbox belongs to exactly one +team. Does selecting it silently snap the picker to that team, or does the picker keep saying "Home" +while you act on a team-owned item? Answering that consistently means every surface needs its own +scope-transition rule, "new conversation" has no implicit team while Home is selected, and a live +cross-team inbox needs new realtime fan-out (subscribing to every inbox in every team approaches +`MAX_CHANNELS_PER_PEER`). + +**This draft — Home as a fixed sidebar destination, separate from the picker** — removes the problem +instead of solving it. The team picker goes back to being purely "which team's workspace am I working in +right now," unchanged in kind from today. Home is a small set of its own routes (`/home`, `/home/inbox`, +`/home/feedback`) that list things across teams and link out to their real, team-scoped pages. `/support` +and `/feedback` are not touched by this stage at all — no dual-mode logic, no team-set query rewiring, no +scope-transition question, because Home never claims to represent your current context. It is a jumping- +off list, the same relationship Linear's "My Issues" and GitHub's cross-org pull-request page have to +their respective team/org views. + +## Scope + +**In:** the `/dashboard` → `/home` rename, a fixed Home entry in the sidebar's Personal group (visible +regardless of active team or organization), and `/home/inbox` and `/home/feedback` as small cross-team +read views. + +**Out:** any change to the team picker beyond removing the misleading row described below — it does not +gain a Home option. Any change to `/support`, `/feedback`, or their APIs — they stay purely team-scoped. +Per-team or organization-wide *statistics* (needs Stage 09; recorded as a later extension). Cross-team +contact merging or conversation moves. Creating anything from a Home page — creation still happens inside +a team. + +**Scope boundary:** Home spans the teams the user belongs to **within the active organization**, not +across organizations. This matches the picker, which only ever lists the active organization's teams. + +## The existing picker entry gets deleted, not replaced + +`components/sidebar/TeamSwitcher.vue` renders a "Workspace" row above the team list, styled like a team +entry, that reads as a cross-team scope (delta D-30). It is not one: + +- `switchToDefaultTeam()` resolves the team **named `Default`** and activates it. The app remains in a + single-team scope afterwards. +- `additionalTeams` filters `name !== 'Default'`, hiding that team from the list and silently reusing it + as a pseudo-org entry. +- `displaySubtitle` renders "All projects" in that state, which is **false** as soon as a second team + owns a project. + +Because Home is no longer a picker concept, the fix is simpler than earlier drafts assumed: **delete the +row.** `switchToDefaultTeam` and the `additionalTeams` filter go with it, so the `Default` team appears in +the list like any other team, and the picker no longer makes a claim it can't back up. No new scope- +resolution layer is needed behind it. + +## `/dashboard` becomes `/home` + +`pages/dashboard/index.vue` already has two modes: + +- **Personal** (no active organization) — "Welcome, {name}", My Submissions / Completed / Total Votes. +- **Workspace** (organization active) — "Dashboard", *"Overview of your team's feedback activity"*, + scoped to the **active team**. + +The renamed `/home` keeps the workspace mode's presence (so it is still useful once teams exist) but is +no longer keyed on organization state — it becomes the fixed, always-personal destination, sitting above +the team list rather than swapping in when no team is active. The team-scoped "workspace" content that +used to live here either moves to being one of several Home sections or is dropped in favor of the +cross-team framing; decide when this is scheduled. + +Call sites to update: the redirect in `pages/index.vue`, the `Dashboard` entry in `personalItems` in +`AppSidebar.vue` (including the `hasActiveOrganization !== false` filter that currently hides it for +personal accounts — Home should now always be visible), `protectedRoutes` in `middleware/auth.global.ts`, +and any test selectors or fixtures referencing `/dashboard`. + +## What's under Home + +Three small, separate pages — not a mirror of the full team-scoped nav: + +- **`/home`** — overview: recent activity, counts, entry points into the other two. +- **`/home/inbox`** — support conversations assigned to the user, or in inboxes they're a member of, + across every team they belong to. Each row links to the real conversation page + (`/support/conversations/[id]`) in its owning team — clicking through is a normal navigation, not a + scope change. +- **`/home/feedback`** — feedback items the user authored, is watching, or is a team member on the + product for, across every team. Links to the real feedback item. + +Each gets its own small read endpoint (e.g. `GET /api/home/inbox`, `GET /api/home/feedback`) scoped to +`resolveHomeTeamScope(userId, organizationId)`. These are new, narrow endpoints — **not** the existing +`/api/support/conversations` or feedback list endpoints modified to accept a team set. Keeping them +separate is what keeps `/support` and `/feedback` untouched. + +## Realtime — reuse `user:`, no new fan-out + +Because Home is read-only triage, not a live team inbox, it does not need per-inbox or per-team +subscriptions. `conversation_assigned` and `conversation_mention` notifications already exist from Stage +02 and already publish on `user:`, which every client already subscribes to +(`NotificationBell.vue`'s pattern). Home's inbox and feedback pages refetch on the same signal. No change +to Stage 02's publish targets is needed — the earlier draft's `team:` publish requirement is +withdrawn along with the picker-scope design it existed for. + +## Known collisions with existing design + +**`displayId` is per team.** `conversation` is unique on `(teamId, displayId)`, so `/home/inbox` will show +tickets numbered `#41` from more than one team. Show a team badge or team-qualified reference next to the +number. + +**Contacts are per team.** `contact` is unique on `(teamId, email)` — out of scope here since Home does +not include a contacts page, but worth remembering if one is added later: the same person in three teams +is three unrelated rows, not one to be merged. + +## Work sketch + +1. **Picker cleanup** — delete the "Workspace" row, `switchToDefaultTeam`, and the `Default`-name filter + in `additionalTeams`; the team list becomes a plain list of the user's teams. +2. **Access layer** — `resolveHomeTeamScope(userId, organizationId)` returning the set of teams the user + belongs to in the active organization. An empty set is legitimate: render an empty Home, never fall + back to the full organization. +3. **Route rename** — `/dashboard` → `/home` with a redirect, updating all call sites listed above; Home + becomes unconditionally visible in the sidebar rather than gated on organization state. +4. **Home pages and endpoints** — `/home/inbox` and `/home/feedback` with their own narrow, cross-team + read endpoints; each row links to its real, team-scoped page. +5. **Realtime** — wire Home's lists to refetch on the existing `user:` notification signal. + +## Acceptance criteria + +1. `/dashboard` redirects to `/home`, and Home is visible in the sidebar regardless of which team is + active or whether an organization exists. +2. A user belonging to two of an organization's four teams sees only those two teams' items on + `/home/inbox` and `/home/feedback`. Verified by test, as cross-tenant isolation. +3. A user belonging to no team in the active organization sees an empty Home, not an error and not + everything. +4. Clicking an item on `/home/inbox` or `/home/feedback` opens that item's real, team-scoped page; the + team picker's selection is unaffected by browsing Home. +5. Two conversations with the same `displayId` in different teams are distinguishable on `/home/inbox`. +6. The team picker no longer contains a "Workspace" row, and its subtitle claims match what it actually + shows. +7. `/support` and `/feedback` and their APIs are unchanged by this stage. +8. `yarn harness:verify` green on `support-platform`. + +## TODO items + +- [ ] Delete the "Workspace" row, `switchToDefaultTeam`, and the `Default`-name filter from `TeamSwitcher.vue`; the team list shows every team plainly +- [ ] Add `resolveHomeTeamScope(userId, organizationId)` returning the user's teams in the active organization; unit tests for partial membership, full membership, and no membership +- [ ] Rename `/dashboard` to `/home` with a redirect; update `pages/index.vue`, `AppSidebar.vue` (make Home unconditionally visible), `protectedRoutes`, and test selectors +- [ ] Add `GET /api/home/inbox` and `GET /api/home/feedback` as new, narrow cross-team read endpoints scoped via `resolveHomeTeamScope`; do not modify the existing team-scoped list endpoints +- [ ] Build `/home`, `/home/inbox`, and `/home/feedback` with team-qualified references and links out to real team-scoped pages +- [ ] Wire Home's lists to refetch on the existing `user:` notification signal +- [ ] Add E2E coverage: a user in a subset of an organization's teams sees only those teams' items on Home; clicking through lands on the correct team-scoped page without changing the picker's selection + +## Possible later extension + +Per-team stat cards on the Home overview — volume, response times, SLA attainment per team, with +drill-through. This **would** depend on Stage 09's `supportMetricDaily` rollups; computing it live over +`conversationMessage` is the scaling mistake Stage 09 exists to prevent. Treat as a separate item. + +## Risks + +- **Re-coupling Home to the picker.** If a future change makes Home selectable in the picker again, the + click-through ambiguity this draft removed comes back. Keep it a fixed nav destination. +- **Forking the existing endpoints instead of adding narrow new ones.** Modifying + `/api/support/conversations` to accept a team set would silently reintroduce the coupling this draft + avoids. Home gets its own endpoints. +- **Route rename breakage.** `/dashboard` is the post-login landing route and appears in tests, the + sidebar, and the root redirect. Missing a call site strands users on a dead route immediately after + login. +- **The existing "Workspace" entry looks finished.** Anyone glancing at `TeamSwitcher.vue` may assume a + cross-team scope already exists. It does not — that row activates a team named `Default` and nothing + else. diff --git a/docs/plans/2026-08-11-support-platform/stage-10-customer-portal.md b/docs/plans/2026-08-11-support-platform/stage-10-customer-portal.md index 476421af..274a8592 100644 --- a/docs/plans/2026-08-11-support-platform/stage-10-customer-portal.md +++ b/docs/plans/2026-08-11-support-platform/stage-10-customer-portal.md @@ -16,6 +16,16 @@ Ticket submission form, ticket list, ticket detail with reply, on the existing p infrastructure. Reuses `anonymousSession`, `server/utils/public-auth-handoff.ts`, and the host-based routing already handling public boards in `pages/[...slug].vue`. +**Also in — the per-product Support toggle, deferred here from Stage 02** (deltas D-26, D-27). +`supportEnabled` joins `feedbackEnabled` / `roadmapEnabled` / `changelogEnabled` in +`ProductSettingsFeatures.vue`, plus a Support tab in `pages/products/[slug].vue`. This stage is where +they finally control something: the public entry point on that product's board. + +The toggle is **subordinate to the team-level Support module** in the `/settings` Tools tab. With the +module off, per-product toggles have no effect and should read as unavailable rather than silently +inert. Portal submissions attribute their product from page context, so they need no +`supportInboxAddress` mapping. + ## Behaviour - **Submission** — name, email, subject, message, attachments, and optional per-inbox custom fields. @@ -51,8 +61,10 @@ appearance settings (banner, colors, light/dark mode) so it looks like part of t ## TODO items - [ ] Add per-inbox portal visibility setting (off / submit-only / submit-and-track) and custom field config +- [ ] Add `supportEnabled` to `ProductSettingsFeatures.vue` and a product Support tab, gated on the team-level Support module being enabled (deferred from Stage 02) - [ ] Build the public ticket submission form with attachments, rate limiting, and existing anti-spam measures - [ ] Implement contact resolution or creation from a portal submission via `contactIdentity` +- [ ] Set `conversation.projectId` from page context on portal submissions - [ ] Implement magic-link verification granting a scoped, expiring contact session for anonymous customers - [ ] Implement logged-in customer access via `contactIdentity` on `kind: 'user'` - [ ] Build the ticket list and detail pages with reply, inheriting public appearance settings From 3a570d1be179fe5a929130c323e3660fc15b9bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:23:46 +0200 Subject: [PATCH 058/334] chore(todo): add Stage 02 backlog, UX debt notes, check off SUP-02-1 Enters Stage 02 (SUP-02-1..17) onto the board per the dispatch protocol, adds SUP-X-4 for the deferred admin-only module-toggle permission (delta D-28), and records three general UX debt items (#10-12) surfaced while designing Stage 09b: inconsistent sidebar visibility rules, permanently-disabled Roadmap/Changelog nav entries, and no UI to switch between organizations. Checks off SUP-02-1 (inbox and conversation schema), merged in 21ec059. Co-Authored-By: Claude Sonnet 5 --- TODO.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/TODO.md b/TODO.md index 6ed2ba14..46531814 100644 --- a/TODO.md +++ b/TODO.md @@ -176,6 +176,15 @@ MVP - [x] **#9 — Replace `console.error()` with structured logging** Introduced `server/utils/logger.ts` using `consola` (already shipped with Nuxt). All `console.error()` calls in server and lib code replaced with structured `logger.error()` calls that include context objects (feedbackId, userId, projectName, key, etc.). Each module creates a tagged child logger (e.g. `feedback`, `github`, `db`, `auth`) for easy filtering. +- [ ] **#10 — Sidebar visibility rules are inconsistent between nav items** + `AppSidebar.vue`'s `personalItems` computed conditionally hides `Dashboard` based on `hasActiveOrganization`, while `Roadmap`/`Changelog` are permanently `disabled: true` regardless of state, and everything else is either always shown or gated on the `hasActiveOrganization === true` block. Three different rules for "should this nav item show" in one component. Surfaced while designing Stage 09b (Home) in the support-platform plan — worth auditing once Home ships, since it adds a fourth item with its own visibility rule. + +- [ ] **#11 — Roadmap and Changelog sidebar entries are permanently disabled, not state-gated** + `feedbackItems` in `AppSidebar.vue` hardcodes `disabled: true` on `Roadmap` and `Changelog` unconditionally — not "disabled until a feature is enabled," just disabled. A user clicking either sees a dead nav item with no explanation of why or whether it will ever work. Either wire them to real state (they are already real, shippable features per `TODO.md`'s MVP section) or remove them until they are. + +- [ ] **#12 — No UI to switch between organizations, only teams within one** + `TeamSwitcher.vue` lets a user switch teams inside the active organization but has no affordance for moving to a _different_ organization the user belongs to. If a user is a member of more than one organization there is currently no visible way to change which one is active from the sidebar. Worth scoping properly (where does org switching live — same picker, a separate control?) rather than folding into the support-platform Home work, since it is a pre-existing gap unrelated to support. + ## Support Platform — Stage 00: Foundations Plan: `docs/plans/2026-08-11-support-platform/stage-00-foundations.md`. Read `design.md` in the same @@ -245,3 +254,32 @@ separate" in `design.md`. - [x] **SUP-X-2** Gate `scripts/seed.ts` behind an explicit env flag (delta D-13). `yarn build` runs `postbuild` → seed, which creates `test@preview.local` / `password123` in whatever database it points at - Board entry was stale. `productionSeedBlockReason()` in `scripts/seed.ts` refuses to run when `NODE_ENV=production` or `VERCEL_ENV=production`, with `ALLOW_PRODUCTION_SEED=true` as a deliberate override. Verified: blocks under both env vars, proceeds with the override set. - [ ] **SUP-X-3** (repo-wide, not support-specific) Build a build-time scanner that parses the `@openapi` JSDoc blocks already present on 24 endpoint files — auth, github, orgs, and support — and merges them into `server/api/openapi.json.get.ts`'s served `paths`, replacing the hand-maintained duplicate added for support in SUP-01-9 (delta D-23). Must run at build time: a request-time filesystem scan of `server/api/**/*.ts` would work in dev and self-hosted but produce an empty spec on Vercel, where only compiled output ships. Add `js-yaml` as a direct dependency — currently present only transitively via eslint. +- [ ] **SUP-X-4** Restrict module enable/disable in the `/settings` Tools tab to team admins (delta D-28). Deferred deliberately: `teamMember.role` has `admin` and `member` with currently equivalent permissions, and `design.md` freezes those semantics. Would be the first real differentiation of that column. Read D-28 before acting — the freeze was about keeping _support_ permissions off `teamMember.role`, which is arguably a different question from workspace administration. + +## Support Platform — Stage 02: Inbox + conversation core + +Plan: `docs/plans/2026-08-11-support-platform/stage-02-conversation-core.md`. Read `design.md` and +`deltas.md` first. Integration branch is **`support-platform`**. + +UI and configuration model settled 2026-08-14 (deltas D-26, D-27, D-28). Two surfaces, deliberately +separate: the **agent workspace** (`/support`, team-scoped, this stage) and the **customer entry point** +(per-product, public board, deferred to Stage 10). + +- [x] **SUP-02-1** Add inbox and conversation tables to `server/database/schema/support.ts` with all indexes, including `supportInboxAddress` and the nullable `conversation.projectId`; generate migration + - `019cf71`, merged `21ec059`. `supportInbox`, `supportInboxAddress`, `supportInboxMember`, `conversation` (with `projectId`), `supportCounter`, `conversationMessage`, `conversationAttachment`, `conversationParticipant`, `supportTag`/`conversationTag`, `supportEmailEvent` — 11 tables, 22 FKs, 27 indexes, migration `0022_lowly_machine_man.sql`. FK actions verified against `design.md` directly from the generated SQL (`restrict` on `conversation.inboxId`/`contactId`, `cascade` on team-owned rows, `set null` elsewhere). `design.md` had no column/index spec for `supportTag`/`conversationTag` beyond one line — filled in following the file's existing conventions; flagged for confirmation when the tag endpoints are built. +- [ ] **SUP-02-2** Extend `server/utils/support-access.ts` with `requireInboxAccess`, `requireConversationAccess`, `resolveInboxByAddress`; unit tests including the team-admin bypass +- [ ] **SUP-02-3** Replace the unconditional deny branch for `inbox:`/`conversation:` in `server/utils/realtime-channels.ts` with real access checks; update `tests/realtime-channels.test.ts` (delta D-04) +- [ ] **SUP-02-4** Implement `displayId` allocation via `supportCounter` with `SELECT … FOR UPDATE`; concurrency test with 100 parallel inserts +- [ ] **SUP-02-5** Add inbox CRUD + membership endpoints +- [ ] **SUP-02-6** Add receiving-address endpoints with per-address product mapping and same-team `projectId` validation +- [ ] **SUP-02-7** Add conversation list/create/get/patch endpoints with filters (including product) and cursor pagination; emit `activity` messages on every status, priority, assignee, and product change +- [ ] **SUP-02-8** Add message, participant, and tag endpoints; publish thin realtime envelopes on `conversation:` and `inbox:` for every write +- [ ] **SUP-02-9** Build the `/support` three-pane UI: inbox switcher, filtered conversation list, thread pane rendering all four message kinds, contact drawer +- [ ] **SUP-02-10** Build the composer with an unmistakable reply/note toggle; messages stored only, not sent, in this stage +- [ ] **SUP-02-11** Rename the existing `Support` sidebar group to `System`; add a real `Support` group with Inbox and Contacts; add `/support` to `protectedRoutes` +- [ ] **SUP-02-12** Add the per-team Tools tab to `/settings` with module toggles driving sidebar visibility, replacing the hardcoded `disabled: true` Roadmap/Changelog placeholders. Team membership only — no `teamMember.role` check (delta D-28) +- [ ] **SUP-02-13** Implement module disable semantics: hide nav and stop inbound processing while preserving conversations and contacts +- [ ] **SUP-02-14** Build `/support/settings` with inbox name, signature, agent membership, and the receiving-address list with product mapping +- [ ] **SUP-02-15** Add `conversation_assigned` and `conversation_mention` notification types and preference toggles +- [ ] **SUP-02-16** Register support inbox and conversation routes in the OpenAPI spec (hand-transcribe into `openapi.json.get.ts` until SUP-X-3 lands) +- [ ] **SUP-02-17** Add E2E coverage: create conversation, reply, add note, change status, verify activity message and live update From 2fe9a3e41c75378282eb94b895d2daa6ed086355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:09:51 +0200 Subject: [PATCH 059/334] feat(support): add inbox/conversation access checks, wire into realtime (SUP-02-2, SUP-02-3) requireInboxAccess and requireConversationAccess extend support-access.ts following the existing 404/403 pattern, with a supportInboxMember-or-team-admin bypass since support permissions live on supportInboxMember.role, not teamMember (delta D-28). resolveInboxByAddress matches supportInboxAddress case-insensitively and returns the matched address alongside the inbox so Stage 03 can read projectId without a second query. realtime-channels.ts's inbox:/conversation: branches, which have denied unconditionally since Stage 00 pending these tables (delta D-04), now check real access via the new helpers. --- TODO.md | 6 +- server/utils/realtime-channels.ts | 35 ++++++++-- server/utils/support-access.ts | 109 +++++++++++++++++++++++++++-- tests/realtime-channels.test.ts | 47 ++++++++++--- tests/support-access.test.ts | 110 +++++++++++++++++++++++++++++- 5 files changed, 284 insertions(+), 23 deletions(-) diff --git a/TODO.md b/TODO.md index 46531814..dfaa1bc2 100644 --- a/TODO.md +++ b/TODO.md @@ -267,8 +267,10 @@ separate: the **agent workspace** (`/support`, team-scoped, this stage) and the - [x] **SUP-02-1** Add inbox and conversation tables to `server/database/schema/support.ts` with all indexes, including `supportInboxAddress` and the nullable `conversation.projectId`; generate migration - `019cf71`, merged `21ec059`. `supportInbox`, `supportInboxAddress`, `supportInboxMember`, `conversation` (with `projectId`), `supportCounter`, `conversationMessage`, `conversationAttachment`, `conversationParticipant`, `supportTag`/`conversationTag`, `supportEmailEvent` — 11 tables, 22 FKs, 27 indexes, migration `0022_lowly_machine_man.sql`. FK actions verified against `design.md` directly from the generated SQL (`restrict` on `conversation.inboxId`/`contactId`, `cascade` on team-owned rows, `set null` elsewhere). `design.md` had no column/index spec for `supportTag`/`conversationTag` beyond one line — filled in following the file's existing conventions; flagged for confirmation when the tag endpoints are built. -- [ ] **SUP-02-2** Extend `server/utils/support-access.ts` with `requireInboxAccess`, `requireConversationAccess`, `resolveInboxByAddress`; unit tests including the team-admin bypass -- [ ] **SUP-02-3** Replace the unconditional deny branch for `inbox:`/`conversation:` in `server/utils/realtime-channels.ts` with real access checks; update `tests/realtime-channels.test.ts` (delta D-04) +- [x] **SUP-02-2** Extend `server/utils/support-access.ts` with `requireInboxAccess`, `requireConversationAccess`, `resolveInboxByAddress`; unit tests including the team-admin bypass + - `requireInboxAccess`: 404 if the inbox is missing, else allow on `supportInboxMember` row OR `teamMember.role === 'admin'` on the inbox's team (checks membership first, only queries team-admin if that misses). `requireConversationAccess` resolves the conversation then delegates to `requireInboxAccess` on its `inboxId`. `resolveInboxByAddress` matches `supportInboxAddress.address` case-insensitively and returns `{ inbox, address }` (not just the inbox) so Stage 03 gets the matched address's `projectId` for free without a second query; returns `null` on no match rather than throwing, per the stage doc's "don't 404 a mail provider" requirement. 21 unit tests in `tests/support-access.test.ts`, same queued-select stub pattern as the Stage 01 tests. +- [x] **SUP-02-3** Replace the unconditional deny branch for `inbox:`/`conversation:` in `server/utils/realtime-channels.ts` with real access checks; update `tests/realtime-channels.test.ts` (delta D-04) + - `ChannelAuthDeps` gained `canAccessInbox`/`canAccessConversation`; the real implementations wrap `requireInboxAccess`/`requireConversationAccess` and collapse their 404/403 split to a boolean — that distinction is API-facing detail, not useful at subscribe time. 6 new tests; `yarn test` (170 tests), typecheck, and lint (0 errors) all green afterward. - [ ] **SUP-02-4** Implement `displayId` allocation via `supportCounter` with `SELECT … FOR UPDATE`; concurrency test with 100 parallel inserts - [ ] **SUP-02-5** Add inbox CRUD + membership endpoints - [ ] **SUP-02-6** Add receiving-address endpoints with per-address product mapping and same-team `projectId` validation diff --git a/server/utils/realtime-channels.ts b/server/utils/realtime-channels.ts index 7d581e93..e778942d 100644 --- a/server/utils/realtime-channels.ts +++ b/server/utils/realtime-channels.ts @@ -2,6 +2,7 @@ import { and, eq } from 'drizzle-orm' import { db } from '~/server/database/drizzle' import { teamMember } from '~/server/database/schema/auth' import { parseChannel } from '~/server/services/realtime' +import { requireConversationAccess, requireInboxAccess } from '~/server/utils/support-access' /** * Subscribe-time authorization for realtime channels. @@ -22,6 +23,8 @@ function deny(reason: string): ChannelAuthResult { export interface ChannelAuthDeps { isTeamMember(teamId: string, userId: string): Promise + canAccessInbox(inboxId: string, userId: string): Promise + canAccessConversation(conversationId: string, userId: string): Promise } /** @@ -47,12 +50,10 @@ export async function authorizeChannelWith( return (await deps.isTeamMember(parsed.id, userId)) ? ALLOW : deny('Forbidden') case 'inbox': + return (await deps.canAccessInbox(parsed.id, userId)) ? ALLOW : deny('Forbidden') + case 'conversation': - // Stage 02 seam. `supportInbox` and `conversation` do not exist yet, so - // there is nothing to authorize against. Fail closed rather than allow — - // Stage 02 replaces this branch with a `requireInboxAccess` / - // `requireConversationAccess` lookup. - return deny('Support channels are not available yet') + return (await deps.canAccessConversation(parsed.id, userId)) ? ALLOW : deny('Forbidden') default: return deny('Unknown channel') @@ -69,7 +70,29 @@ async function isTeamMember(teamId: string, userId: string): Promise { return Boolean(row) } +// `requireInboxAccess` / `requireConversationAccess` throw 404/403 to carry +// API-facing detail. That distinction is not useful at subscribe time — a +// peer that cannot see an inbox and a peer whose inbox does not exist should +// both simply fail to join the channel — so both outcomes collapse to false. +async function canAccessInbox(inboxId: string, userId: string): Promise { + try { + await requireInboxAccess(inboxId, userId) + return true + } catch { + return false + } +} + +async function canAccessConversation(conversationId: string, userId: string): Promise { + try { + await requireConversationAccess(conversationId, userId) + return true + } catch { + return false + } +} + /** Authorize a peer's subscription request against the database. */ export function authorizeChannel(channel: string, userId: string): Promise { - return authorizeChannelWith(channel, userId, { isTeamMember }) + return authorizeChannelWith(channel, userId, { isTeamMember, canAccessInbox, canAccessConversation }) } diff --git a/server/utils/support-access.ts b/server/utils/support-access.ts index 51dec269..f868ab82 100644 --- a/server/utils/support-access.ts +++ b/server/utils/support-access.ts @@ -3,7 +3,7 @@ import { eq, and } from 'drizzle-orm' // can be unit tested outside the Nitro runtime. import { createError } from 'h3' import { db } from '~/server/database/drizzle' -import { contact, supportCompany } from '~/server/database/schema/support' +import { contact, supportCompany, supportInbox, supportInboxMember, supportInboxAddress, conversation } from '~/server/database/schema/support' import { teamMember } from '~/server/database/schema/auth' import { createErrorResponse, ErrorCode } from './response' @@ -13,9 +13,6 @@ import { createErrorResponse, ErrorCode } from './response' * Mirrors `server/utils/project-access.ts`: resolve the entity, resolve its * team, then check membership. 404 when the entity does not exist, 403 when it * does but the caller is not a member. - * - * Stage 02 extends this file with `requireInboxAccess`, - * `requireConversationAccess`, and `resolveInboxByAddress`. */ /** @@ -88,3 +85,107 @@ export async function requireTeamMembership(teamId: string, userId: string) { return membership } + +/** + * Verify the user may act on an inbox. + * + * Unlike `requireContactAccess`, support permissions do not live on + * `teamMember` — a caller is authorized if they are a `supportInboxMember` of + * this inbox, **or** hold the `admin` role on the inbox's team (support leads + * need to reach an inbox before anyone has explicitly added them to it). + * `teamMember.role` semantics are otherwise unchanged (delta D-28). + */ +export async function requireInboxAccess(inboxId: string, userId: string) { + const [inbox] = await db.select().from(supportInbox).where(eq(supportInbox.id, inboxId)).limit(1) + + if (!inbox) { + throw createError({ + statusCode: 404, + statusMessage: 'Not Found', + data: createErrorResponse(ErrorCode.NOT_FOUND, 'Inbox not found'), + }) + } + + const [membership] = await db + .select({ id: supportInboxMember.id }) + .from(supportInboxMember) + .where(and(eq(supportInboxMember.inboxId, inboxId), eq(supportInboxMember.userId, userId))) + .limit(1) + + if (membership) { + return inbox + } + + const [teamAdmin] = await db + .select({ id: teamMember.id }) + .from(teamMember) + .where(and(eq(teamMember.teamId, inbox.teamId), eq(teamMember.userId, userId), eq(teamMember.role, 'admin'))) + .limit(1) + + if (!teamAdmin) { + throw createError({ + statusCode: 403, + statusMessage: 'Forbidden', + data: createErrorResponse(ErrorCode.FORBIDDEN, 'You do not have access to this inbox'), + }) + } + + return inbox +} + +/** + * Verify the user may act on a conversation, via access to its inbox. + * + * There is no separate conversation-level permission — a conversation is only + * ever reachable through the inbox it belongs to, so the 404/403 split is + * inherited from `requireInboxAccess`. + */ +export async function requireConversationAccess(conversationId: string, userId: string) { + const [row] = await db.select().from(conversation).where(eq(conversation.id, conversationId)).limit(1) + + if (!row) { + throw createError({ + statusCode: 404, + statusMessage: 'Not Found', + data: createErrorResponse(ErrorCode.NOT_FOUND, 'Conversation not found'), + }) + } + + await requireInboxAccess(row.inboxId, userId) + + return row +} + +/** + * Resolve the inbox (and matched receiving address) an inbound email should + * land in, by exact match against `supportInboxAddress.address` (delta D-27). + * + * Returns `null` rather than throwing on no match — Stage 03 records the + * event as an error and returns 200 rather than 404ing, since the sender is a + * mail provider that would otherwise retry forever. Not an authorization + * check, so it takes no `userId`. + * + * Addresses are matched case-insensitively; callers do not need to normalize + * `emailAddress` first. + */ +export async function resolveInboxByAddress(emailAddress: string) { + const normalized = emailAddress.trim().toLowerCase() + + const [address] = await db + .select() + .from(supportInboxAddress) + .where(eq(supportInboxAddress.address, normalized)) + .limit(1) + + if (!address) { + return null + } + + const [inbox] = await db.select().from(supportInbox).where(eq(supportInbox.id, address.inboxId)).limit(1) + + if (!inbox) { + return null + } + + return { inbox, address } +} diff --git a/tests/realtime-channels.test.ts b/tests/realtime-channels.test.ts index 4ebdda05..dd0c6738 100644 --- a/tests/realtime-channels.test.ts +++ b/tests/realtime-channels.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import { authorizeChannelWith, type ChannelAuthDeps } from '../server/utils/realtime-channels' -function deps(isMember = false): ChannelAuthDeps { - return { isTeamMember: vi.fn(async () => isMember) } +function deps(isMember = false, canAccessInbox = false, canAccessConversation = false): ChannelAuthDeps { + return { + isTeamMember: vi.fn(async () => isMember), + canAccessInbox: vi.fn(async () => canAccessInbox), + canAccessConversation: vi.fn(async () => canAccessConversation), + } } describe('authorizeChannelWith', () => { @@ -36,19 +40,44 @@ describe('authorizeChannelWith', () => { expect(d.isTeamMember).toHaveBeenCalledWith('t42', 'u1') }) - it('denies support channels until Stage 02 introduces the tables', async () => { - // Fails closed rather than allowing. Stage 02 replaces this branch with - // requireInboxAccess / requireConversationAccess. - await expect(authorizeChannelWith('inbox:i1', 'u1', deps(true))).resolves.toEqual({ + it('allows an inbox channel when the peer has inbox access', async () => { + await expect(authorizeChannelWith('inbox:i1', 'u1', deps(false, true))).resolves.toEqual({ allowed: true }) + }) + + it('denies an inbox channel when the peer has no inbox access', async () => { + // Fails closed. Collapses requireInboxAccess's 404/403 split into one + // "Forbidden" — that distinction is API-facing detail, not useful here. + await expect(authorizeChannelWith('inbox:i1', 'u1', deps(false, false))).resolves.toEqual({ allowed: false, - reason: 'Support channels are not available yet', + reason: 'Forbidden', }) - await expect(authorizeChannelWith('conversation:c1', 'u1', deps(true))).resolves.toEqual({ + }) + + it('checks inbox access against the requested inbox, not the user id', async () => { + const d = deps(false, true) + await authorizeChannelWith('inbox:i42', 'u1', d) + expect(d.canAccessInbox).toHaveBeenCalledWith('i42', 'u1') + }) + + it('allows a conversation channel when the peer has conversation access', async () => { + await expect(authorizeChannelWith('conversation:c1', 'u1', deps(false, false, true))).resolves.toEqual({ + allowed: true, + }) + }) + + it('denies a conversation channel when the peer has no conversation access', async () => { + await expect(authorizeChannelWith('conversation:c1', 'u1', deps(false, false, false))).resolves.toEqual({ allowed: false, - reason: 'Support channels are not available yet', + reason: 'Forbidden', }) }) + it('checks conversation access against the requested conversation, not the user id', async () => { + const d = deps(false, false, true) + await authorizeChannelWith('conversation:c42', 'u1', d) + expect(d.canAccessConversation).toHaveBeenCalledWith('c42', 'u1') + }) + it('denies unknown or malformed channels', async () => { for (const channel of ['project:p1', 'team', 'team:', ':t1', '', 'nonsense']) { await expect(authorizeChannelWith(channel, 'u1', deps(true))).resolves.toMatchObject({ allowed: false }) diff --git a/tests/support-access.test.ts b/tests/support-access.test.ts index 2670389d..62a158a2 100644 --- a/tests/support-access.test.ts +++ b/tests/support-access.test.ts @@ -25,8 +25,14 @@ vi.mock('~/server/database/drizzle', () => { return { db: { select: chain } } }) -const { requireContactAccess, requireCompanyAccess, requireTeamMembership } = - await import('~/server/utils/support-access') +const { + requireContactAccess, + requireCompanyAccess, + requireTeamMembership, + requireInboxAccess, + requireConversationAccess, + resolveInboxByAddress, +} = await import('~/server/utils/support-access') async function expectStatus(promise: Promise, statusCode: number) { await expect(promise).rejects.toMatchObject({ statusCode }) @@ -104,3 +110,103 @@ describe('requireTeamMembership', () => { await expectStatus(requireTeamMembership('t1', 'u1'), 403) }) }) + +describe('requireInboxAccess', () => { + it('returns the inbox when the user is a supportInboxMember', async () => { + queueResult([{ id: 'i1', teamId: 't1' }]) + queueResult([{ id: 'sim1' }]) + + await expect(requireInboxAccess('i1', 'u1')).resolves.toMatchObject({ id: 'i1' }) + }) + + it('returns the inbox when the user is not a member but is a team admin', async () => { + // The bypass this stage adds: a support lead must be able to reach an + // inbox before anyone has explicitly added them as a supportInboxMember. + queueResult([{ id: 'i1', teamId: 't1' }]) + queueResult([]) + queueResult([{ id: 'm1' }]) + + await expect(requireInboxAccess('i1', 'admin1')).resolves.toMatchObject({ id: 'i1' }) + }) + + it('throws 404 when the inbox does not exist', async () => { + queueResult([]) + + await expectStatus(requireInboxAccess('missing', 'u1'), 404) + }) + + it('throws 403 when the user is neither a member nor a team admin', async () => { + queueResult([{ id: 'i1', teamId: 't1' }]) + queueResult([]) + queueResult([]) + + await expectStatus(requireInboxAccess('i1', 'outsider'), 403) + }) + + it('does not check team admin when already a supportInboxMember', async () => { + queueResult([{ id: 'i1', teamId: 't1' }]) + queueResult([{ id: 'sim1' }]) + + await requireInboxAccess('i1', 'u1') + expect(queued.length).toBe(0) + }) +}) + +describe('requireConversationAccess', () => { + it('returns the conversation when the user has inbox access', async () => { + queueResult([{ id: 'c1', inboxId: 'i1' }]) + queueResult([{ id: 'i1', teamId: 't1' }]) + queueResult([{ id: 'sim1' }]) + + await expect(requireConversationAccess('c1', 'u1')).resolves.toMatchObject({ id: 'c1' }) + }) + + it('throws 404 when the conversation does not exist', async () => { + queueResult([]) + + await expectStatus(requireConversationAccess('missing', 'u1'), 404) + }) + + it('throws 403 when the user has no access to the parent inbox', async () => { + queueResult([{ id: 'c1', inboxId: 'i1' }]) + queueResult([{ id: 'i1', teamId: 't1' }]) + queueResult([]) + queueResult([]) + + await expectStatus(requireConversationAccess('c1', 'outsider'), 403) + }) +}) + +describe('resolveInboxByAddress', () => { + it('returns the inbox and matched address on an exact match', async () => { + queueResult([{ id: 'addr1', inboxId: 'i1', address: 'support@acme.com', projectId: null }]) + queueResult([{ id: 'i1', teamId: 't1' }]) + + await expect(resolveInboxByAddress('support@acme.com')).resolves.toMatchObject({ + inbox: { id: 'i1' }, + address: { id: 'addr1' }, + }) + }) + + it('matches case-insensitively', async () => { + queueResult([{ id: 'addr1', inboxId: 'i1', address: 'support@acme.com', projectId: null }]) + queueResult([{ id: 'i1', teamId: 't1' }]) + + await expect(resolveInboxByAddress('Support@Acme.com')).resolves.toMatchObject({ inbox: { id: 'i1' } }) + }) + + it('returns null when no address matches, rather than throwing', async () => { + // Stage 03 records the event as an error and returns 200 on a miss, + // rather than 404ing a mail provider that would otherwise retry forever. + queueResult([]) + + await expect(resolveInboxByAddress('nobody@acme.com')).resolves.toBeNull() + }) + + it('returns null when the address exists but its inbox is gone', async () => { + queueResult([{ id: 'addr1', inboxId: 'i1', address: 'support@acme.com', projectId: null }]) + queueResult([]) + + await expect(resolveInboxByAddress('support@acme.com')).resolves.toBeNull() + }) +}) From 485f9d449a24bcc1b8df49288db6e7293414dc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:16:00 +0200 Subject: [PATCH 060/334] feat(support): implement displayId allocation with concurrency coverage (SUP-02-4) allocateConversationDisplayId takes its transaction as a parameter and must run inside the same transaction as the conversation insert it belongs to. Existing counter row: SELECT ... FOR UPDATE then increment, matching the locking pattern already used in the contact-merge endpoint. No counter row yet: INSERT ... ON CONFLICT DO NOTHING claims displayId 1 outright; a transaction that loses that race falls through to the same locked-select path, which Postgres blocks until the winner commits. Verified against real concurrency, not just unit-level branch coverage: a new guarded Postgres integration test runs 100 real concurrent transactions and asserts the results are exactly {1..100}, run live against docker-compose-dev's db service. Split the dependency guard in two (Redis, Postgres) rather than growing the existing Redis one, since a machine can have either dependency without the other; both guards now target only their own file instead of the whole tests/integration/ glob. --- AGENTS.md | 10 +++ TODO.md | 3 +- package.json | 1 + scripts/harness-verify.mjs | 6 ++ .../run-postgres-integration-if-available.mjs | 77 ++++++++++++++++ .../run-redis-integration-if-available.mjs | 2 +- server/utils/support-counter.ts | 67 ++++++++++++++ tests/integration/support-counter.test.ts | 54 +++++++++++ tests/support-counter.test.ts | 90 +++++++++++++++++++ 9 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 scripts/run-postgres-integration-if-available.mjs create mode 100644 server/utils/support-counter.ts create mode 100644 tests/integration/support-counter.test.ts create mode 100644 tests/support-counter.test.ts diff --git a/AGENTS.md b/AGENTS.md index 38e482d7..f114697f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Run these after every change: - `yarn lint` - `yarn test:e2e:if-available` - `yarn test:integration:if-available` +- `yarn test:integration:postgres:if-available` Or run the harness command: @@ -56,6 +57,15 @@ Or run the harness command: whenever Redis is up. - If it skips, report the skip reason in updates/final output. +## Postgres Integration Guard + +- `yarn test:integration:postgres:if-available` runs concurrency tests that need a real database (e.g. + the `displayId` allocation test) only when Postgres is reachable via `PG*`/`DATABASE_URL`. Start it with + `docker compose -f docker-compose-dev.yml up -d db`, then `yarn db:migrate`. +- Guarded separately from the Redis suite, not bundled — a machine with one dependency but not the other + still gets partial coverage instead of an all-or-nothing skip. +- If it skips, report the skip reason in updates/final output. + ## UI Change Rule - Any user-facing UI behavior change requires Playwright coverage updates for the affected workflow. diff --git a/TODO.md b/TODO.md index dfaa1bc2..c0fb7393 100644 --- a/TODO.md +++ b/TODO.md @@ -271,7 +271,8 @@ separate: the **agent workspace** (`/support`, team-scoped, this stage) and the - `requireInboxAccess`: 404 if the inbox is missing, else allow on `supportInboxMember` row OR `teamMember.role === 'admin'` on the inbox's team (checks membership first, only queries team-admin if that misses). `requireConversationAccess` resolves the conversation then delegates to `requireInboxAccess` on its `inboxId`. `resolveInboxByAddress` matches `supportInboxAddress.address` case-insensitively and returns `{ inbox, address }` (not just the inbox) so Stage 03 gets the matched address's `projectId` for free without a second query; returns `null` on no match rather than throwing, per the stage doc's "don't 404 a mail provider" requirement. 21 unit tests in `tests/support-access.test.ts`, same queued-select stub pattern as the Stage 01 tests. - [x] **SUP-02-3** Replace the unconditional deny branch for `inbox:`/`conversation:` in `server/utils/realtime-channels.ts` with real access checks; update `tests/realtime-channels.test.ts` (delta D-04) - `ChannelAuthDeps` gained `canAccessInbox`/`canAccessConversation`; the real implementations wrap `requireInboxAccess`/`requireConversationAccess` and collapse their 404/403 split to a boolean — that distinction is API-facing detail, not useful at subscribe time. 6 new tests; `yarn test` (170 tests), typecheck, and lint (0 errors) all green afterward. -- [ ] **SUP-02-4** Implement `displayId` allocation via `supportCounter` with `SELECT … FOR UPDATE`; concurrency test with 100 parallel inserts +- [x] **SUP-02-4** Implement `displayId` allocation via `supportCounter` with `SELECT … FOR UPDATE`; concurrency test with 100 parallel inserts + - `server/utils/support-counter.ts` exports `allocateConversationDisplayId(tx, teamId)`, taking the transaction as a parameter — it must run inside the same transaction as the conversation insert, so the counter row's lock covers both writes. Existing-row path: `SELECT … FOR UPDATE` then `UPDATE … + 1`, matching the pattern already used in `contacts/[id]/merge.post.ts`. Bootstrap path (no counter row yet): `INSERT … ON CONFLICT DO NOTHING` claims `displayId` 1 outright; a transaction that loses that race falls through to the same `SELECT … FOR UPDATE` path, which Postgres blocks on until the winner commits, so it can't observe a half-written row. 4 unit tests against a hand-rolled fake `tx` (function takes `tx` as a parameter, so no module mock was needed) plus a new guarded Postgres integration test (`tests/integration/support-counter.test.ts`, `yarn test:integration:postgres:if-available`) that runs 100 real concurrent `db.transaction()` calls against a fixture team and asserts the results are exactly `{1..100}` with the counter row landing on 101 — verified live against `docker compose -f docker-compose-dev.yml up -d db`. Added a second dependency guard (`scripts/run-postgres-integration-if-available.mjs`) alongside the Redis one rather than folding into it, since a machine could have one dependency but not the other; both guards now target only their own file under `vitest.integration.config.ts` instead of the whole `tests/integration/` glob. Wired into `harness:verify` and `AGENTS.md`. - [ ] **SUP-02-5** Add inbox CRUD + membership endpoints - [ ] **SUP-02-6** Add receiving-address endpoints with per-address product mapping and same-team `projectId` validation - [ ] **SUP-02-7** Add conversation list/create/get/patch endpoints with filters (including product) and cursor pagination; emit `activity` messages on every status, priority, assignee, and product change diff --git a/package.json b/package.json index 7ed36509..bdb32fa7 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test:e2e:if-available": "node scripts/run-playwright-if-available.mjs", "test:integration": "vitest run -c vitest.integration.config.ts", "test:integration:if-available": "node scripts/run-redis-integration-if-available.mjs", + "test:integration:postgres:if-available": "node scripts/run-postgres-integration-if-available.mjs", "harness:context": "node scripts/harness-context.mjs", "harness:docs": "node scripts/harness-docs-check.mjs", "harness:verify": "node scripts/harness-verify.mjs", diff --git a/scripts/harness-verify.mjs b/scripts/harness-verify.mjs index d34d98ba..227e733f 100644 --- a/scripts/harness-verify.mjs +++ b/scripts/harness-verify.mjs @@ -15,6 +15,12 @@ const steps = [ args: ['test:integration:if-available'], shell: isWindows, }, + { + label: 'Postgres integration (guarded)', + command: yarnCommand, + args: ['test:integration:postgres:if-available'], + shell: isWindows, + }, ] const timings = [] diff --git a/scripts/run-postgres-integration-if-available.mjs b/scripts/run-postgres-integration-if-available.mjs new file mode 100644 index 00000000..78966df2 --- /dev/null +++ b/scripts/run-postgres-integration-if-available.mjs @@ -0,0 +1,77 @@ +import { spawnSync } from 'node:child_process' +import { Client } from 'pg' +import 'dotenv/config' + +/** + * Guarded runner for the Postgres integration suite (SUP-02-4's + * `displayId` concurrency test). + * + * Mirrors `run-redis-integration-if-available.mjs`: skip cleanly with a clear + * reason when no database is reachable, so `yarn harness:verify` stays green + * without one running, while still exercising the real `SELECT … FOR UPDATE` + * row-locking behavior wherever Postgres is available. Guarded separately + * from the Redis suite so a machine with one dependency but not the other + * still gets partial coverage instead of an all-or-nothing skip. + */ + +const isCloudEnvironment = Boolean( + process.env.GITHUB_ACTIONS || process.env.VERCEL || process.env.CIRCLECI || process.env.BUILDKITE || process.env.CI +) +const failOnPreflightSkip = + process.env.POSTGRES_INTEGRATION_SKIP_IS_FAILURE === '1' || + (isCloudEnvironment && process.env.POSTGRES_INTEGRATION_SKIP_IS_FAILURE !== '0') + +function createClient() { + return process.env.DATABASE_URL + ? new Client({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 2_000 }) + : new Client({ + host: process.env.PGHOST || 'localhost', + port: Number(process.env.PGPORT) || 5432, + user: process.env.PGUSER || 'veerify', + password: process.env.PGPASSWORD || 'veerifypassword', + database: process.env.PGDATABASE || 'veerifydb', + connectionTimeoutMillis: 2_000, + }) +} + +async function verifyPostgresAvailable() { + const client = createClient() + + try { + await client.connect() + const result = await client.query('SELECT 1') + return result.rowCount === 1 + } catch { + return false + } finally { + await client.end().catch(() => {}) + } +} + +const postgresAvailable = await verifyPostgresAvailable() + +if (!postgresAvailable) { + const reason = 'Postgres is not reachable' + + if (failOnPreflightSkip) { + console.error(`[postgres-integration] Preflight failed: ${reason}.`) + process.exit(1) + } + + console.log(`[postgres-integration] Skipping: ${reason}.`) + console.log('[postgres-integration] Start it locally with: docker compose -f docker-compose-dev.yml up -d db') + console.log('[postgres-integration] Then apply migrations with: yarn db:migrate') + process.exit(0) +} + +const command = process.platform === 'win32' ? 'yarn.cmd' : 'yarn' +const result = spawnSync(command, ['test:integration', 'tests/integration/support-counter.test.ts'], { + stdio: 'inherit', + shell: process.platform === 'win32', +}) + +if (result.error) { + throw result.error +} + +process.exit(result.status ?? 1) diff --git a/scripts/run-redis-integration-if-available.mjs b/scripts/run-redis-integration-if-available.mjs index 25fdd4bd..b2e0e196 100644 --- a/scripts/run-redis-integration-if-available.mjs +++ b/scripts/run-redis-integration-if-available.mjs @@ -66,7 +66,7 @@ if (!redisAvailable) { } const command = process.platform === 'win32' ? 'yarn.cmd' : 'yarn' -const result = spawnSync(command, ['test:integration'], { +const result = spawnSync(command, ['test:integration', 'tests/integration/redis.test.ts'], { stdio: 'inherit', env: { ...process.env, REDIS_URL: redisUrl }, shell: process.platform === 'win32', diff --git a/server/utils/support-counter.ts b/server/utils/support-counter.ts new file mode 100644 index 00000000..1447f57e --- /dev/null +++ b/server/utils/support-counter.ts @@ -0,0 +1,67 @@ +import { eq } from 'drizzle-orm' +import type { db } from '~/server/database/drizzle' +import { supportCounter } from '~/server/database/schema/support' + +/** + * `displayId` allocation for conversations (delta-free, matches `design.md` → + * Data model → Stage 02 exactly: `SELECT … FOR UPDATE` on a per-team counter + * row, not a sequence, because the number must be per-team and gap-free + * enough to read as a ticket number like Zendesk's). + * + * Must be called inside the same transaction as the conversation insert — the + * row lock only holds for the lifetime of that transaction, and a caller that + * allocates outside it could hand out an id that a concurrent transaction + * also allocates. + */ + +type Tx = Parameters[0]>[0] + +export async function allocateConversationDisplayId(tx: Tx, teamId: string): Promise { + const [existing] = await tx + .select() + .from(supportCounter) + .where(eq(supportCounter.teamId, teamId)) + .for('update') + + if (existing) { + await tx + .update(supportCounter) + .set({ nextConversationDisplayId: existing.nextConversationDisplayId + 1 }) + .where(eq(supportCounter.teamId, teamId)) + + return existing.nextConversationDisplayId + } + + // No counter row yet for this team. Claim displayId 1 by creating one - + // `onConflictDoNothing` handles two transactions racing to create the same + // team's row: at most one INSERT survives, so at most one caller returns + // here. Postgres blocks the loser's INSERT on the winner's uncommitted row + // until the winner commits, so the loser's fallthrough re-select below is + // guaranteed to see it. + const inserted = await tx + .insert(supportCounter) + .values({ teamId, nextConversationDisplayId: 2 }) + .onConflictDoNothing() + .returning({ teamId: supportCounter.teamId }) + + if (inserted.length > 0) { + return 1 + } + + const [row] = await tx + .select() + .from(supportCounter) + .where(eq(supportCounter.teamId, teamId)) + .for('update') + + if (!row) { + throw new Error(`support_counter row for team ${teamId} vanished between insert and re-select`) + } + + await tx + .update(supportCounter) + .set({ nextConversationDisplayId: row.nextConversationDisplayId + 1 }) + .where(eq(supportCounter.teamId, teamId)) + + return row.nextConversationDisplayId +} diff --git a/tests/integration/support-counter.test.ts b/tests/integration/support-counter.test.ts new file mode 100644 index 00000000..21a94fc8 --- /dev/null +++ b/tests/integration/support-counter.test.ts @@ -0,0 +1,54 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { db } from '../../server/database/drizzle' +import { organization, team } from '../../server/database/schema/auth' +import { supportCounter } from '../../server/database/schema/support' +import { allocateConversationDisplayId } from '../../server/utils/support-counter' + +/** + * The concurrency property `allocateConversationDisplayId` exists for - + * `SELECT … FOR UPDATE` actually serializing racing transactions - can't be + * exercised by the fake-tx unit tests in `tests/support-counter.test.ts`, + * since those run one branch at a time on a single fake connection. This + * needs a real Postgres connection pool with genuinely overlapping + * transactions. Guarded like the Redis integration suite: skips cleanly when + * no database is reachable. + */ + +describe('allocateConversationDisplayId (real Postgres)', () => { + const orgId = `org_counter_test_${randomUUID()}` + const teamId = `team_counter_test_${randomUUID()}` + + beforeAll(async () => { + await db.insert(organization).values({ + id: orgId, + name: 'Support Counter Integration Test Org', + slug: `support-counter-test-org-${randomUUID()}`, + }) + await db.insert(team).values({ + id: teamId, + name: 'Support Counter Integration Test Team', + slug: `support-counter-test-team-${randomUUID()}`, + organizationId: orgId, + }) + }) + + afterAll(async () => { + // Cascades: organization -> team -> support_counter. + await db.delete(organization).where(eq(organization.id, orgId)) + }) + + it('allocates 100 distinct sequential displayIds under real concurrency, no duplicates or gaps', async () => { + const allocations = await Promise.all( + Array.from({ length: 100 }, () => db.transaction((tx) => allocateConversationDisplayId(tx, teamId))) + ) + + const sorted = [...allocations].sort((a, b) => a - b) + expect(sorted).toEqual(Array.from({ length: 100 }, (_, i) => i + 1)) + + const [counter] = await db.select().from(supportCounter).where(eq(supportCounter.teamId, teamId)).limit(1) + expect(counter?.nextConversationDisplayId).toBe(101) + }) +}) diff --git a/tests/support-counter.test.ts b/tests/support-counter.test.ts new file mode 100644 index 00000000..ef9c25b4 --- /dev/null +++ b/tests/support-counter.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' + +import { allocateConversationDisplayId } from '../server/utils/support-counter' + +/** + * `allocateConversationDisplayId` takes its transaction as a parameter, so + * unlike `support-access.ts` there is no module to mock: a hand-rolled fake + * `tx` exercises each branch directly. True concurrent-allocation behavior + * (the `SELECT … FOR UPDATE` row lock actually serializing) needs a real + * Postgres connection and is covered separately by the guarded integration + * test in `tests/integration/support-counter.test.ts`. + */ + +interface FakeTxOptions { + selectResults: unknown[][] + insertResults?: unknown[][] +} + +function fakeTx({ selectResults, insertResults = [] }: FakeTxOptions) { + const updates: unknown[] = [] + let selectCalls = 0 + let insertCalls = 0 + + const tx = { + select: () => ({ + from: () => ({ + where: () => ({ + for: () => Promise.resolve(selectResults[selectCalls++] ?? []), + }), + }), + }), + insert: () => ({ + values: () => ({ + onConflictDoNothing: () => ({ + returning: () => Promise.resolve(insertResults[insertCalls++] ?? []), + }), + }), + }), + update: () => ({ + set: (values: unknown) => { + updates.push(values) + return { where: () => Promise.resolve() } + }, + }), + } + + return { tx, updates } +} + +describe('allocateConversationDisplayId', () => { + it('allocates the current value and increments the existing row', async () => { + const { tx, updates } = fakeTx({ selectResults: [[{ teamId: 't1', nextConversationDisplayId: 5 }]] }) + + await expect(allocateConversationDisplayId(tx as any, 't1')).resolves.toBe(5) + expect(updates).toEqual([{ nextConversationDisplayId: 6 }]) + }) + + it('allocates 1 and creates the counter row when none exists yet', async () => { + const { tx, updates } = fakeTx({ + selectResults: [[]], + insertResults: [[{ teamId: 't1' }]], + }) + + await expect(allocateConversationDisplayId(tx as any, 't1')).resolves.toBe(1) + // The insert already seeds nextConversationDisplayId to 2 - no separate + // update is needed to "spend" the first allocation. + expect(updates).toEqual([]) + }) + + it('falls back to locking the row when it loses the bootstrap race', async () => { + // Two transactions raced to create the row for the same team; this one's + // INSERT ... ON CONFLICT DO NOTHING found the other had already won. + const { tx, updates } = fakeTx({ + selectResults: [[], [{ teamId: 't1', nextConversationDisplayId: 7 }]], + insertResults: [[]], + }) + + await expect(allocateConversationDisplayId(tx as any, 't1')).resolves.toBe(7) + expect(updates).toEqual([{ nextConversationDisplayId: 8 }]) + }) + + it('throws if the row is gone by the fallback re-select', async () => { + const { tx } = fakeTx({ + selectResults: [[], []], + insertResults: [[]], + }) + + await expect(allocateConversationDisplayId(tx as any, 't1')).rejects.toThrow('vanished') + }) +}) From f97e8f14fa4ecb1f96e5e9312aaaa45e800e1348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:24:04 +0200 Subject: [PATCH 061/334] feat(support): add inbox CRUD and membership endpoints (SUP-02-5) GET/POST /api/support/inboxes, GET/PUT/DELETE /api/support/inboxes/[id], and GET/POST /api/support/inboxes/[id]/members plus member removal. The creator is added as a supportInboxMember with role admin in the same transaction as inbox creation, so a non-team-admin creator isn't left unable to reach the inbox they just made. Adding a member requires the target user to already be a team member. Channel/provider fields (emailAddress, channelConfig, auto-reply) are intentionally absent from the update body - Stage 03 owns those. Added isForeignKeyViolation to support-errors.ts, mirroring isUniqueViolation's .cause-unwrapping, to turn the conversation.inboxId restrict FK into a clean 409 on delete instead of a 500. Verified live against a running dev server and real Postgres rather than per-endpoint unit tests, matching the existing companies/contacts convention: full inbox lifecycle, duplicate-slug 409, cross-tenant 403, member add/duplicate/remove/access-revoked cycle, and the FK-restrict-delete 409 path. --- TODO.md | 3 +- server/api/support/inboxes/[id].delete.ts | 51 ++++++++ server/api/support/inboxes/[id].get.ts | 29 +++++ server/api/support/inboxes/[id].put.ts | 111 ++++++++++++++++++ .../inboxes/[id]/members/[memberId].delete.ts | 51 ++++++++ .../support/inboxes/[id]/members/index.get.ts | 47 ++++++++ .../inboxes/[id]/members/index.post.ts | 86 ++++++++++++++ server/api/support/inboxes/index.get.ts | 45 +++++++ server/api/support/inboxes/index.post.ts | 98 ++++++++++++++++ server/utils/support-errors.ts | 30 +++++ tests/support-errors.test.ts | 33 +++++- 11 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 server/api/support/inboxes/[id].delete.ts create mode 100644 server/api/support/inboxes/[id].get.ts create mode 100644 server/api/support/inboxes/[id].put.ts create mode 100644 server/api/support/inboxes/[id]/members/[memberId].delete.ts create mode 100644 server/api/support/inboxes/[id]/members/index.get.ts create mode 100644 server/api/support/inboxes/[id]/members/index.post.ts create mode 100644 server/api/support/inboxes/index.get.ts create mode 100644 server/api/support/inboxes/index.post.ts diff --git a/TODO.md b/TODO.md index c0fb7393..8c104744 100644 --- a/TODO.md +++ b/TODO.md @@ -273,7 +273,8 @@ separate: the **agent workspace** (`/support`, team-scoped, this stage) and the - `ChannelAuthDeps` gained `canAccessInbox`/`canAccessConversation`; the real implementations wrap `requireInboxAccess`/`requireConversationAccess` and collapse their 404/403 split to a boolean — that distinction is API-facing detail, not useful at subscribe time. 6 new tests; `yarn test` (170 tests), typecheck, and lint (0 errors) all green afterward. - [x] **SUP-02-4** Implement `displayId` allocation via `supportCounter` with `SELECT … FOR UPDATE`; concurrency test with 100 parallel inserts - `server/utils/support-counter.ts` exports `allocateConversationDisplayId(tx, teamId)`, taking the transaction as a parameter — it must run inside the same transaction as the conversation insert, so the counter row's lock covers both writes. Existing-row path: `SELECT … FOR UPDATE` then `UPDATE … + 1`, matching the pattern already used in `contacts/[id]/merge.post.ts`. Bootstrap path (no counter row yet): `INSERT … ON CONFLICT DO NOTHING` claims `displayId` 1 outright; a transaction that loses that race falls through to the same `SELECT … FOR UPDATE` path, which Postgres blocks on until the winner commits, so it can't observe a half-written row. 4 unit tests against a hand-rolled fake `tx` (function takes `tx` as a parameter, so no module mock was needed) plus a new guarded Postgres integration test (`tests/integration/support-counter.test.ts`, `yarn test:integration:postgres:if-available`) that runs 100 real concurrent `db.transaction()` calls against a fixture team and asserts the results are exactly `{1..100}` with the counter row landing on 101 — verified live against `docker compose -f docker-compose-dev.yml up -d db`. Added a second dependency guard (`scripts/run-postgres-integration-if-available.mjs`) alongside the Redis one rather than folding into it, since a machine could have one dependency but not the other; both guards now target only their own file under `vitest.integration.config.ts` instead of the whole `tests/integration/` glob. Wired into `harness:verify` and `AGENTS.md`. -- [ ] **SUP-02-5** Add inbox CRUD + membership endpoints +- [x] **SUP-02-5** Add inbox CRUD + membership endpoints + - `GET/POST /api/support/inboxes`, `GET/PUT/DELETE /api/support/inboxes/[id]`, `GET/POST /api/support/inboxes/[id]/members`, `DELETE /api/support/inboxes/[id]/members/[memberId]`. No pagination on the list endpoint — a team's inboxes are a short settings list, not an open-ended feed, unlike contacts/companies. The creator is added as a `supportInboxMember` with role `admin` in the same transaction as inbox creation — otherwise a non-team-admin creator would create an inbox they immediately have no access to. Adding a member requires the target `userId` to already be a `teamMember` of the inbox's team (400 otherwise); who may call the add/remove endpoints is gated only by general inbox access (any role, or the team-admin bypass) — the stage doc does not specify finer-grained RBAC here, matching this stage's team-membership-only permission model elsewhere (delta D-28). Delete added `isForeignKeyViolation` to `support-errors.ts` (mirrors `isUniqueViolation`'s `.cause`-unwrapping) to turn the `conversation.inboxId` restrict FK into a clean 409 rather than a 500. PUT's `defaultAssigneeUserId` and `projectId` are validated same-team before write, matching the contact/company pattern. Channel/provider fields (`emailAddress`, `channelConfig`, auto-reply) are intentionally not in the PUT body — Stage 03 owns those per the stage doc. No unit tests per endpoint file, matching the existing convention for `companies`/`contacts` (covered by E2E once UI exists, SUP-02-17); instead verified live against a running dev server and real Postgres: full inbox lifecycle, duplicate-slug 409, cross-tenant 403, member add/duplicate-409/remove/access-revoked cycle, and the FK-restrict-delete 409 path (confirmed by hand-inserting a fixture conversation row, since conversation CRUD is SUP-02-7). - [ ] **SUP-02-6** Add receiving-address endpoints with per-address product mapping and same-team `projectId` validation - [ ] **SUP-02-7** Add conversation list/create/get/patch endpoints with filters (including product) and cursor pagination; emit `activity` messages on every status, priority, assignee, and product change - [ ] **SUP-02-8** Add message, participant, and tag endpoints; publish thin realtime envelopes on `conversation:` and `inbox:` for every write diff --git a/server/api/support/inboxes/[id].delete.ts b/server/api/support/inboxes/[id].delete.ts new file mode 100644 index 00000000..877bf2f1 --- /dev/null +++ b/server/api/support/inboxes/[id].delete.ts @@ -0,0 +1,51 @@ +/** + * @openapi + * /api/support/inboxes/{id}: + * delete: + * tags: [Support] + * summary: Delete an inbox + * description: > + * conversation.inboxId is a restrict FK - an inbox with any + * conversations cannot be deleted. Delete or reassign them first. + * operationId: deleteSupportInbox + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Inbox deleted } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox not found } + * 409: { description: Inbox still has conversations } + */ +import { eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' +import { isForeignKeyViolation } from '~/server/utils/support-errors' +import { db } from '~/server/database/drizzle' +import { supportInbox } from '~/server/database/schema/support' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const inboxId = getRouterParam(event, 'id') as string + + await requireInboxAccess(inboxId, session.user.id) + + try { + await db.delete(supportInbox).where(eq(supportInbox.id, inboxId)) + } catch (error) { + if (isForeignKeyViolation(error)) { + throw createError({ + statusCode: 409, + statusMessage: 'Conflict', + data: createErrorResponse(ErrorCode.CONFLICT, 'This inbox still has conversations and cannot be deleted'), + }) + } + throw error + } + + return createSuccessResponse({ deleted: true }) +}) diff --git a/server/api/support/inboxes/[id].get.ts b/server/api/support/inboxes/[id].get.ts new file mode 100644 index 00000000..19d36f18 --- /dev/null +++ b/server/api/support/inboxes/[id].get.ts @@ -0,0 +1,29 @@ +/** + * @openapi + * /api/support/inboxes/{id}: + * get: + * tags: [Support] + * summary: Get an inbox + * operationId: getSupportInbox + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Inbox detail } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox not found } + */ +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const inboxId = getRouterParam(event, 'id') as string + + const inbox = await requireInboxAccess(inboxId, session.user.id) + + return createSuccessResponse({ inbox }) +}) diff --git a/server/api/support/inboxes/[id].put.ts b/server/api/support/inboxes/[id].put.ts new file mode 100644 index 00000000..c20f8dcb --- /dev/null +++ b/server/api/support/inboxes/[id].put.ts @@ -0,0 +1,111 @@ +/** + * @openapi + * /api/support/inboxes/{id}: + * put: + * tags: [Support] + * summary: Update inbox settings + * description: > + * Covers name, slug, product mapping, default assignee, signature, and + * enabled state. Channel/provider configuration (sending address, + * forwarding, auto-reply) is Stage 03. + * operationId: updateSupportInbox + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Inbox updated } + * 400: { description: projectId or defaultAssigneeUserId does not belong to this team } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox not found } + * 409: { description: Another inbox in the team already uses this slug } + */ +import { z } from 'zod' +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' +import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody, commonSchemas } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { supportInbox } from '~/server/database/schema/support' +import { project } from '~/server/database/schema/feedback' +import { teamMember } from '~/server/database/schema/auth' + +const bodySchema = z.object({ + name: z.string().trim().min(1).max(200).optional(), + slug: commonSchemas.slug.optional(), + projectId: z.string().nullable().optional(), + defaultAssigneeUserId: z.string().nullable().optional(), + signature: z.string().max(10_000).nullable().optional(), + isEnabled: z.boolean().optional(), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const inboxId = getRouterParam(event, 'id') as string + const body = await validateBody(event, bodySchema) + + const inbox = await requireInboxAccess(inboxId, session.user.id) + + if (body.projectId) { + const [matchedProject] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.id, body.projectId), eq(project.teamId, inbox.teamId))) + .limit(1) + + if (!matchedProject) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Project is not part of this team'), + }) + } + } + + if (body.defaultAssigneeUserId) { + const [matchedMember] = await db + .select({ id: teamMember.id }) + .from(teamMember) + .where(and(eq(teamMember.teamId, inbox.teamId), eq(teamMember.userId, body.defaultAssigneeUserId))) + .limit(1) + + if (!matchedMember) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'defaultAssigneeUserId is not a member of this team'), + }) + } + } + + try { + const [updated] = await db + .update(supportInbox) + .set({ + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.slug !== undefined ? { slug: body.slug } : {}), + ...(body.projectId !== undefined ? { projectId: body.projectId } : {}), + ...(body.defaultAssigneeUserId !== undefined ? { defaultAssigneeUserId: body.defaultAssigneeUserId } : {}), + ...(body.signature !== undefined ? { signature: body.signature } : {}), + ...(body.isEnabled !== undefined ? { isEnabled: body.isEnabled } : {}), + updatedAt: new Date(), + }) + .where(eq(supportInbox.id, inboxId)) + .returning() + + return createSuccessResponse({ inbox: updated }) + } catch (error) { + if (isUniqueViolation(error)) { + throw createError({ + statusCode: 409, + statusMessage: 'Conflict', + data: createErrorResponse(ErrorCode.CONFLICT, 'Another inbox in this team already uses this slug'), + }) + } + throw error + } +}) diff --git a/server/api/support/inboxes/[id]/members/[memberId].delete.ts b/server/api/support/inboxes/[id]/members/[memberId].delete.ts new file mode 100644 index 00000000..32a9f547 --- /dev/null +++ b/server/api/support/inboxes/[id]/members/[memberId].delete.ts @@ -0,0 +1,51 @@ +/** + * @openapi + * /api/support/inboxes/{id}/members/{memberId}: + * delete: + * tags: [Support] + * summary: Remove an agent from an inbox + * operationId: removeSupportInboxMember + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * - in: path + * name: memberId + * required: true + * schema: { type: string } + * responses: + * 200: { description: Member removed } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox or member not found } + */ +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' +import { db } from '~/server/database/drizzle' +import { supportInboxMember } from '~/server/database/schema/support' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const inboxId = getRouterParam(event, 'id') as string + const memberId = getRouterParam(event, 'memberId') as string + + await requireInboxAccess(inboxId, session.user.id) + + const [deleted] = await db + .delete(supportInboxMember) + .where(and(eq(supportInboxMember.id, memberId), eq(supportInboxMember.inboxId, inboxId))) + .returning({ id: supportInboxMember.id }) + + if (!deleted) { + throw createError({ + statusCode: 404, + statusMessage: 'Not Found', + data: createErrorResponse(ErrorCode.NOT_FOUND, 'Member not found on this inbox'), + }) + } + + return createSuccessResponse({ deleted: true }) +}) diff --git a/server/api/support/inboxes/[id]/members/index.get.ts b/server/api/support/inboxes/[id]/members/index.get.ts new file mode 100644 index 00000000..75d798e7 --- /dev/null +++ b/server/api/support/inboxes/[id]/members/index.get.ts @@ -0,0 +1,47 @@ +/** + * @openapi + * /api/support/inboxes/{id}/members: + * get: + * tags: [Support] + * summary: List an inbox's agent members + * operationId: listSupportInboxMembers + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Member list } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox not found } + */ +import { eq } from 'drizzle-orm' +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' +import { db } from '~/server/database/drizzle' +import { supportInboxMember } from '~/server/database/schema/support' +import { user } from '~/server/database/schema/auth' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const inboxId = getRouterParam(event, 'id') as string + + await requireInboxAccess(inboxId, session.user.id) + + const members = await db + .select({ + id: supportInboxMember.id, + userId: supportInboxMember.userId, + role: supportInboxMember.role, + createdAt: supportInboxMember.createdAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(supportInboxMember) + .innerJoin(user, eq(supportInboxMember.userId, user.id)) + .where(eq(supportInboxMember.inboxId, inboxId)) + + return createSuccessResponse({ members }) +}) diff --git a/server/api/support/inboxes/[id]/members/index.post.ts b/server/api/support/inboxes/[id]/members/index.post.ts new file mode 100644 index 00000000..758f358e --- /dev/null +++ b/server/api/support/inboxes/[id]/members/index.post.ts @@ -0,0 +1,86 @@ +/** + * @openapi + * /api/support/inboxes/{id}/members: + * post: + * tags: [Support] + * summary: Add an agent to an inbox + * description: > + * The target user must already be a member of the inbox's team - an + * inbox agent is a team member granted support access, not an + * independent identity. + * operationId: addSupportInboxMember + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Member added } + * 400: { description: userId is not a member of this team } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox not found } + * 409: { description: User is already a member of this inbox } + */ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' +import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { supportInboxMember } from '~/server/database/schema/support' +import { teamMember } from '~/server/database/schema/auth' + +const bodySchema = z.object({ + userId: z.string().min(1), + role: z.enum(['agent', 'supervisor', 'admin']), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const inboxId = getRouterParam(event, 'id') as string + const body = await validateBody(event, bodySchema) + + const inbox = await requireInboxAccess(inboxId, session.user.id) + + const [matchedMember] = await db + .select({ id: teamMember.id }) + .from(teamMember) + .where(and(eq(teamMember.teamId, inbox.teamId), eq(teamMember.userId, body.userId))) + .limit(1) + + if (!matchedMember) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'userId is not a member of this team'), + }) + } + + try { + const [created] = await db + .insert(supportInboxMember) + .values({ + id: randomUUID(), + inboxId, + userId: body.userId, + role: body.role, + createdAt: new Date(), + }) + .returning() + + return createSuccessResponse({ member: created }) + } catch (error) { + if (isUniqueViolation(error)) { + throw createError({ + statusCode: 409, + statusMessage: 'Conflict', + data: createErrorResponse(ErrorCode.CONFLICT, 'This user is already a member of this inbox'), + }) + } + throw error + } +}) diff --git a/server/api/support/inboxes/index.get.ts b/server/api/support/inboxes/index.get.ts new file mode 100644 index 00000000..4c2bb393 --- /dev/null +++ b/server/api/support/inboxes/index.get.ts @@ -0,0 +1,45 @@ +/** + * @openapi + * /api/support/inboxes: + * get: + * tags: [Support] + * summary: List inboxes for a team + * operationId: listSupportInboxes + * parameters: + * - in: query + * name: teamId + * required: true + * schema: { type: string } + * responses: + * 200: { description: Inbox list } + * 403: { description: Not a member of the team } + */ +import { z } from 'zod' +import { asc, eq } from 'drizzle-orm' +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireTeamMembership } from '~/server/utils/support-access' +import { validateQuery } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { supportInbox } from '~/server/database/schema/support' + +const querySchema = z.object({ + teamId: z.string().min(1), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const query = validateQuery(event, querySchema) + + await requireTeamMembership(query.teamId, session.user.id) + + // A team's inboxes are a short, fully-loaded settings list, not an + // open-ended feed - unlike contacts/companies, this does not paginate. + const inboxes = await db + .select() + .from(supportInbox) + .where(eq(supportInbox.teamId, query.teamId)) + .orderBy(asc(supportInbox.createdAt)) + + return createSuccessResponse({ inboxes }) +}) diff --git a/server/api/support/inboxes/index.post.ts b/server/api/support/inboxes/index.post.ts new file mode 100644 index 00000000..3ed59501 --- /dev/null +++ b/server/api/support/inboxes/index.post.ts @@ -0,0 +1,98 @@ +/** + * @openapi + * /api/support/inboxes: + * post: + * tags: [Support] + * summary: Create an inbox + * description: > + * The creator is added as a supportInboxMember with role "admin" in the + * same transaction - otherwise a team member who isn't a team admin + * could create an inbox they have no access to afterward. + * operationId: createSupportInbox + * responses: + * 200: { description: Inbox created } + * 400: { description: projectId does not belong to this team } + * 403: { description: Not a member of the team } + * 409: { description: An inbox with this slug already exists in the team } + */ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireTeamMembership } from '~/server/utils/support-access' +import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody, commonSchemas } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { supportInbox, supportInboxMember } from '~/server/database/schema/support' +import { project } from '~/server/database/schema/feedback' + +const bodySchema = z.object({ + teamId: z.string().min(1), + name: z.string().trim().min(1).max(200), + slug: commonSchemas.slug, + projectId: z.string().optional(), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const body = await validateBody(event, bodySchema) + + await requireTeamMembership(body.teamId, session.user.id) + + if (body.projectId) { + const [matchedProject] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.id, body.projectId), eq(project.teamId, body.teamId))) + .limit(1) + + if (!matchedProject) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Project is not part of this team'), + }) + } + } + + const now = new Date() + const inboxId = randomUUID() + + try { + return await db.transaction(async (tx) => { + const [created] = await tx + .insert(supportInbox) + .values({ + id: inboxId, + teamId: body.teamId, + projectId: body.projectId ?? null, + name: body.name, + slug: body.slug, + createdAt: now, + updatedAt: now, + }) + .returning() + + await tx.insert(supportInboxMember).values({ + id: randomUUID(), + inboxId, + userId: session.user.id, + role: 'admin', + createdAt: now, + }) + + return createSuccessResponse({ inbox: created }) + }) + } catch (error) { + if (isUniqueViolation(error)) { + throw createError({ + statusCode: 409, + statusMessage: 'Conflict', + data: createErrorResponse(ErrorCode.CONFLICT, 'An inbox with this slug or email address already exists'), + }) + } + throw error + } +}) diff --git a/server/utils/support-errors.ts b/server/utils/support-errors.ts index 1e61e442..0367f002 100644 --- a/server/utils/support-errors.ts +++ b/server/utils/support-errors.ts @@ -9,6 +9,9 @@ /** Postgres `unique_violation`. */ const UNIQUE_VIOLATION = '23505' +/** Postgres `foreign_key_violation`. */ +const FOREIGN_KEY_VIOLATION = '23503' + /** How many `.cause` links to unwrap before giving up. Real chains are one deep; this is a safety bound, not a design target. */ const MAX_CAUSE_DEPTH = 5 @@ -47,3 +50,30 @@ export function isUniqueViolation(error: unknown, constraint?: string): boolean return false } + +/** + * Did this error come from an `onDelete: 'restrict'` foreign key? + * + * Used to turn a delete blocked by a dependent row (e.g. an inbox that still + * has conversations) into a clean 409 rather than a 500. Same `.cause` + * unwrapping as `isUniqueViolation`, for the same reason. + */ +export function isForeignKeyViolation(error: unknown, constraint?: string): boolean { + let current = error + let depth = 0 + + while (current && typeof current === 'object' && depth < MAX_CAUSE_DEPTH) { + const code = (current as { code?: unknown }).code + + if (code === FOREIGN_KEY_VIOLATION) { + if (!constraint) return true + const name = (current as { constraint?: unknown }).constraint + return typeof name === 'string' && name === constraint + } + + current = (current as { cause?: unknown }).cause + depth++ + } + + return false +} diff --git a/tests/support-errors.test.ts b/tests/support-errors.test.ts index bbfdc13c..623e3c52 100644 --- a/tests/support-errors.test.ts +++ b/tests/support-errors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isUniqueViolation } from '../server/utils/support-errors' +import { isForeignKeyViolation, isUniqueViolation } from '../server/utils/support-errors' describe('isUniqueViolation', () => { it('matches a raw pg-style error with code 23505', () => { @@ -51,3 +51,34 @@ describe('isUniqueViolation', () => { expect(isUniqueViolation(new Error('plain error, no code'))).toBe(false) }) }) + +describe('isForeignKeyViolation', () => { + it('matches a raw pg-style error with code 23503', () => { + expect(isForeignKeyViolation({ code: '23503' })).toBe(true) + }) + + it('matches when the real error is wrapped in .cause, like DrizzleQueryError', () => { + const pgError = { code: '23503', constraint: 'conversation_inbox_id_support_inbox_id_fk' } + const drizzleQueryError = { message: 'Failed query: delete from "support_inbox" ...', cause: pgError } + + expect(isForeignKeyViolation(drizzleQueryError)).toBe(true) + }) + + it('matches a specific constraint name through the wrapper', () => { + const wrapped = { cause: { code: '23503', constraint: 'conversation_inbox_id_support_inbox_id_fk' } } + + expect(isForeignKeyViolation(wrapped, 'conversation_inbox_id_support_inbox_id_fk')).toBe(true) + expect(isForeignKeyViolation(wrapped, 'some_other_fk')).toBe(false) + }) + + it('does not match a different error code, wrapped or not', () => { + expect(isForeignKeyViolation({ code: '23505' })).toBe(false) + expect(isForeignKeyViolation({ cause: { code: '23505' } })).toBe(false) + }) + + it('does not match non-error inputs', () => { + expect(isForeignKeyViolation(null)).toBe(false) + expect(isForeignKeyViolation(undefined)).toBe(false) + expect(isForeignKeyViolation('not an error')).toBe(false) + }) +}) From e3f1d9f1ebbe272897628a057c2d24fc03bbfe7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:27:44 +0200 Subject: [PATCH 062/334] feat(support): add inbox receiving-address endpoints (SUP-02-6) GET/POST /api/support/inboxes/[id]/addresses and address deletion. Address is normalized to lowercase on write, matching resolveInboxByAddress's case-insensitive read, so two rows differing only by case can't both silently claim the same inbound mail. isPrimary is treated as exclusive per inbox (design.md doesn't specify this - a self-consistent reading of "primary" implying one). projectId validated same-team before write. Verified live, including resolveInboxByAddress's first exercise against real data: case-insensitive duplicate 409, primary-exclusivity, cross-team projectId 400, delete, and a direct resolveInboxByAddress call confirming correct case-insensitive resolution and a clean null on miss. --- TODO.md | 3 +- .../[id]/addresses/[addressId].delete.ts | 51 +++++++++ .../inboxes/[id]/addresses/index.get.ts | 38 +++++++ .../inboxes/[id]/addresses/index.post.ts | 102 ++++++++++++++++++ 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 server/api/support/inboxes/[id]/addresses/[addressId].delete.ts create mode 100644 server/api/support/inboxes/[id]/addresses/index.get.ts create mode 100644 server/api/support/inboxes/[id]/addresses/index.post.ts diff --git a/TODO.md b/TODO.md index 8c104744..75d1287d 100644 --- a/TODO.md +++ b/TODO.md @@ -275,7 +275,8 @@ separate: the **agent workspace** (`/support`, team-scoped, this stage) and the - `server/utils/support-counter.ts` exports `allocateConversationDisplayId(tx, teamId)`, taking the transaction as a parameter — it must run inside the same transaction as the conversation insert, so the counter row's lock covers both writes. Existing-row path: `SELECT … FOR UPDATE` then `UPDATE … + 1`, matching the pattern already used in `contacts/[id]/merge.post.ts`. Bootstrap path (no counter row yet): `INSERT … ON CONFLICT DO NOTHING` claims `displayId` 1 outright; a transaction that loses that race falls through to the same `SELECT … FOR UPDATE` path, which Postgres blocks on until the winner commits, so it can't observe a half-written row. 4 unit tests against a hand-rolled fake `tx` (function takes `tx` as a parameter, so no module mock was needed) plus a new guarded Postgres integration test (`tests/integration/support-counter.test.ts`, `yarn test:integration:postgres:if-available`) that runs 100 real concurrent `db.transaction()` calls against a fixture team and asserts the results are exactly `{1..100}` with the counter row landing on 101 — verified live against `docker compose -f docker-compose-dev.yml up -d db`. Added a second dependency guard (`scripts/run-postgres-integration-if-available.mjs`) alongside the Redis one rather than folding into it, since a machine could have one dependency but not the other; both guards now target only their own file under `vitest.integration.config.ts` instead of the whole `tests/integration/` glob. Wired into `harness:verify` and `AGENTS.md`. - [x] **SUP-02-5** Add inbox CRUD + membership endpoints - `GET/POST /api/support/inboxes`, `GET/PUT/DELETE /api/support/inboxes/[id]`, `GET/POST /api/support/inboxes/[id]/members`, `DELETE /api/support/inboxes/[id]/members/[memberId]`. No pagination on the list endpoint — a team's inboxes are a short settings list, not an open-ended feed, unlike contacts/companies. The creator is added as a `supportInboxMember` with role `admin` in the same transaction as inbox creation — otherwise a non-team-admin creator would create an inbox they immediately have no access to. Adding a member requires the target `userId` to already be a `teamMember` of the inbox's team (400 otherwise); who may call the add/remove endpoints is gated only by general inbox access (any role, or the team-admin bypass) — the stage doc does not specify finer-grained RBAC here, matching this stage's team-membership-only permission model elsewhere (delta D-28). Delete added `isForeignKeyViolation` to `support-errors.ts` (mirrors `isUniqueViolation`'s `.cause`-unwrapping) to turn the `conversation.inboxId` restrict FK into a clean 409 rather than a 500. PUT's `defaultAssigneeUserId` and `projectId` are validated same-team before write, matching the contact/company pattern. Channel/provider fields (`emailAddress`, `channelConfig`, auto-reply) are intentionally not in the PUT body — Stage 03 owns those per the stage doc. No unit tests per endpoint file, matching the existing convention for `companies`/`contacts` (covered by E2E once UI exists, SUP-02-17); instead verified live against a running dev server and real Postgres: full inbox lifecycle, duplicate-slug 409, cross-tenant 403, member add/duplicate-409/remove/access-revoked cycle, and the FK-restrict-delete 409 path (confirmed by hand-inserting a fixture conversation row, since conversation CRUD is SUP-02-7). -- [ ] **SUP-02-6** Add receiving-address endpoints with per-address product mapping and same-team `projectId` validation +- [x] **SUP-02-6** Add receiving-address endpoints with per-address product mapping and same-team `projectId` validation + - `GET/POST /api/support/inboxes/[id]/addresses`, `DELETE /api/support/inboxes/[id]/addresses/[addressId]`. Address is normalized to lowercase on write (zod `.toLowerCase()`), matching `resolveInboxByAddress`'s case-insensitive read — otherwise two rows differing only by case could both silently claim the same inbound mail. `isPrimary` is treated as exclusive per inbox: setting it on one address clears it on the inbox's others in the same transaction; `design.md` doesn't specify this, it's the self-consistent reading of "primary" implying one, flagged here for confirmation like the SUP-02-1 tag-column gap. `projectId` validated same-team before write; global unique-address conflict caught via `isUniqueViolation` → 409. Verified live: case-insensitive duplicate 409, primary-exclusivity across two addresses, cross-team `projectId` 400, delete, and — since this was `resolveInboxByAddress`'s first exercise against real data — a direct call confirming it resolves the correct inbox+address case-insensitively and returns `null` cleanly on no match. - [ ] **SUP-02-7** Add conversation list/create/get/patch endpoints with filters (including product) and cursor pagination; emit `activity` messages on every status, priority, assignee, and product change - [ ] **SUP-02-8** Add message, participant, and tag endpoints; publish thin realtime envelopes on `conversation:` and `inbox:` for every write - [ ] **SUP-02-9** Build the `/support` three-pane UI: inbox switcher, filtered conversation list, thread pane rendering all four message kinds, contact drawer diff --git a/server/api/support/inboxes/[id]/addresses/[addressId].delete.ts b/server/api/support/inboxes/[id]/addresses/[addressId].delete.ts new file mode 100644 index 00000000..815f8fbe --- /dev/null +++ b/server/api/support/inboxes/[id]/addresses/[addressId].delete.ts @@ -0,0 +1,51 @@ +/** + * @openapi + * /api/support/inboxes/{id}/addresses/{addressId}: + * delete: + * tags: [Support] + * summary: Remove a receiving address from an inbox + * operationId: deleteSupportInboxAddress + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * - in: path + * name: addressId + * required: true + * schema: { type: string } + * responses: + * 200: { description: Address deleted } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox or address not found } + */ +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' +import { db } from '~/server/database/drizzle' +import { supportInboxAddress } from '~/server/database/schema/support' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const inboxId = getRouterParam(event, 'id') as string + const addressId = getRouterParam(event, 'addressId') as string + + await requireInboxAccess(inboxId, session.user.id) + + const [deleted] = await db + .delete(supportInboxAddress) + .where(and(eq(supportInboxAddress.id, addressId), eq(supportInboxAddress.inboxId, inboxId))) + .returning({ id: supportInboxAddress.id }) + + if (!deleted) { + throw createError({ + statusCode: 404, + statusMessage: 'Not Found', + data: createErrorResponse(ErrorCode.NOT_FOUND, 'Address not found on this inbox'), + }) + } + + return createSuccessResponse({ deleted: true }) +}) diff --git a/server/api/support/inboxes/[id]/addresses/index.get.ts b/server/api/support/inboxes/[id]/addresses/index.get.ts new file mode 100644 index 00000000..87f861cc --- /dev/null +++ b/server/api/support/inboxes/[id]/addresses/index.get.ts @@ -0,0 +1,38 @@ +/** + * @openapi + * /api/support/inboxes/{id}/addresses: + * get: + * tags: [Support] + * summary: List an inbox's receiving addresses + * operationId: listSupportInboxAddresses + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Address list } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox not found } + */ +import { asc, eq } from 'drizzle-orm' +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' +import { db } from '~/server/database/drizzle' +import { supportInboxAddress } from '~/server/database/schema/support' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const inboxId = getRouterParam(event, 'id') as string + + await requireInboxAccess(inboxId, session.user.id) + + const addresses = await db + .select() + .from(supportInboxAddress) + .where(eq(supportInboxAddress.inboxId, inboxId)) + .orderBy(asc(supportInboxAddress.createdAt)) + + return createSuccessResponse({ addresses }) +}) diff --git a/server/api/support/inboxes/[id]/addresses/index.post.ts b/server/api/support/inboxes/[id]/addresses/index.post.ts new file mode 100644 index 00000000..1800bedf --- /dev/null +++ b/server/api/support/inboxes/[id]/addresses/index.post.ts @@ -0,0 +1,102 @@ +/** + * @openapi + * /api/support/inboxes/{id}/addresses: + * post: + * tags: [Support] + * summary: Add a receiving address to an inbox + * description: > + * Address matching in resolveInboxByAddress is case-insensitive, so the + * address is normalized to lowercase on write - two rows differing only + * by case would otherwise both silently match the same inbound mail. + * isPrimary is exclusive: setting it clears the flag on the inbox's + * other addresses in the same transaction (not specified in + * design.md - a self-consistent reading of "primary" implying one). + * operationId: createSupportInboxAddress + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Address created } + * 400: { description: projectId does not belong to this team } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox not found } + * 409: { description: This address is already in use } + */ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' +import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { supportInboxAddress } from '~/server/database/schema/support' +import { project } from '~/server/database/schema/feedback' + +const bodySchema = z.object({ + address: z.string().trim().toLowerCase().email().max(320), + projectId: z.string().optional(), + isPrimary: z.boolean().optional().default(false), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const inboxId = getRouterParam(event, 'id') as string + const body = await validateBody(event, bodySchema) + + const inbox = await requireInboxAccess(inboxId, session.user.id) + + if (body.projectId) { + const [matchedProject] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.id, body.projectId), eq(project.teamId, inbox.teamId))) + .limit(1) + + if (!matchedProject) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Project is not part of this team'), + }) + } + } + + try { + return await db.transaction(async (tx) => { + if (body.isPrimary) { + await tx + .update(supportInboxAddress) + .set({ isPrimary: false }) + .where(eq(supportInboxAddress.inboxId, inboxId)) + } + + const [created] = await tx + .insert(supportInboxAddress) + .values({ + id: randomUUID(), + inboxId, + address: body.address, + projectId: body.projectId ?? null, + isPrimary: body.isPrimary, + createdAt: new Date(), + }) + .returning() + + return createSuccessResponse({ address: created }) + }) + } catch (error) { + if (isUniqueViolation(error)) { + throw createError({ + statusCode: 409, + statusMessage: 'Conflict', + data: createErrorResponse(ErrorCode.CONFLICT, 'This address is already in use'), + }) + } + throw error + } +}) From 2cd5990b921ce0a1886cf65d3b069d8839a6f25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:04:09 +0200 Subject: [PATCH 063/334] feat(support): add conversation CRUD with activity messages (SUP-02-7) Conversation list, create, detail, and patch endpoints, completing the Stage 02 entry point (manual creation; no mail pipeline until Stage 03). - List filters by inbox, status, assignee, contact, tag, and product, with the shared (createdAt, id) cursor from Stage 01. - Create allocates displayId through supportCounter inside the insert transaction, and falls back to the inbox's own product link when the request names none (the single-product case from delta D-27). - Patch records an activity message per status, priority, assignee, and product change, in the same transaction as the update so the two can never diverge. Subject is updatable but deliberately silent. - Change detection is extracted as the pure diffConversationPatch(), following Stage 01's contact-merge pattern, so absent-vs-null, no-op patches, and resolvedAt stamping are unit-testable without a database. - Assignee and product are validated as belonging to the conversation's team; a foreign key proves existence, not tenancy. 19 unit tests. yarn harness:verify green, including the guarded Postgres and Redis suites. Co-Authored-By: Claude Opus 5 (1M context) --- server/api/support/conversations/[id].get.ts | 60 +++++++ .../api/support/conversations/[id].patch.ts | 117 ++++++++++++ server/api/support/conversations/index.get.ts | 112 ++++++++++++ .../api/support/conversations/index.post.ts | 115 ++++++++++++ server/utils/conversation-activity.ts | 158 ++++++++++++++++ server/utils/support-realtime.ts | 33 ++++ tests/conversation-activity.test.ts | 168 ++++++++++++++++++ 7 files changed, 763 insertions(+) create mode 100644 server/api/support/conversations/[id].get.ts create mode 100644 server/api/support/conversations/[id].patch.ts create mode 100644 server/api/support/conversations/index.get.ts create mode 100644 server/api/support/conversations/index.post.ts create mode 100644 server/utils/conversation-activity.ts create mode 100644 server/utils/support-realtime.ts create mode 100644 tests/conversation-activity.test.ts diff --git a/server/api/support/conversations/[id].get.ts b/server/api/support/conversations/[id].get.ts new file mode 100644 index 00000000..5976188c --- /dev/null +++ b/server/api/support/conversations/[id].get.ts @@ -0,0 +1,60 @@ +/** + * @openapi + * /api/support/conversations/{id}: + * get: + * tags: [Support] + * summary: Get a conversation with its contact and participants + * operationId: getSupportConversation + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Conversation detail } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Conversation not found } + */ +import { eq } from 'drizzle-orm' +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireConversationAccess } from '~/server/utils/support-access' +import { db } from '~/server/database/drizzle' +import { contact, conversationParticipant } from '~/server/database/schema/support' +import { user } from '~/server/database/schema/auth' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const conversationId = getRouterParam(event, 'id') as string + + // Resolves through the inbox and throws 404/403 before anything is read. + const row = await requireConversationAccess(conversationId, session.user.id) + + const [conversationContact] = await db.select().from(contact).where(eq(contact.id, row.contactId)).limit(1) + + // Participants carry either a contactId (a CC'd customer) or a userId (an + // internal follower), never both - resolve each side's display fields so the + // contact drawer doesn't need a second round trip per participant. + const participantRows = await db + .select({ + id: conversationParticipant.id, + role: conversationParticipant.role, + contactId: conversationParticipant.contactId, + userId: conversationParticipant.userId, + createdAt: conversationParticipant.createdAt, + contactName: contact.name, + contactEmail: contact.email, + userName: user.name, + userEmail: user.email, + }) + .from(conversationParticipant) + .leftJoin(contact, eq(conversationParticipant.contactId, contact.id)) + .leftJoin(user, eq(conversationParticipant.userId, user.id)) + .where(eq(conversationParticipant.conversationId, conversationId)) + + return createSuccessResponse({ + conversation: row, + contact: conversationContact ?? null, + participants: participantRows, + }) +}) diff --git a/server/api/support/conversations/[id].patch.ts b/server/api/support/conversations/[id].patch.ts new file mode 100644 index 00000000..6597a164 --- /dev/null +++ b/server/api/support/conversations/[id].patch.ts @@ -0,0 +1,117 @@ +/** + * @openapi + * /api/support/conversations/{id}: + * patch: + * tags: [Support] + * summary: Update a conversation's status, priority, assignee, subject, or product + * description: > + * Every status, priority, assignee, and product change also writes an + * `activity` message into the thread, in the same transaction as the + * update. Subject changes deliberately do not - they are not an + * operational event worth an inline feed entry. + * operationId: updateSupportConversation + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Conversation updated } + * 400: { description: assigneeUserId or projectId does not belong to this team } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Conversation not found } + */ +import { z } from 'zod' +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireConversationAccess } from '~/server/utils/support-access' +import { diffConversationPatch, recordConversationActivity } from '~/server/utils/conversation-activity' +import { publishConversationEvent } from '~/server/utils/support-realtime' +import { validateBody } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { conversation } from '~/server/database/schema/support' +import { teamMember } from '~/server/database/schema/auth' +import { project } from '~/server/database/schema/feedback' + +const bodySchema = z.object({ + status: z.enum(['open', 'pending', 'resolved', 'snoozed', 'closed']).optional(), + priority: z.enum(['low', 'normal', 'high', 'urgent']).nullable().optional(), + assigneeUserId: z.string().nullable().optional(), + subject: z.string().trim().max(500).nullable().optional(), + projectId: z.string().nullable().optional(), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const conversationId = getRouterParam(event, 'id') as string + const body = await validateBody(event, bodySchema) + + const existing = await requireConversationAccess(conversationId, session.user.id) + + // A foreign key proves the user and project exist, not that they belong to + // this conversation's team - same cross-tenant gap Stage 01 closed for + // contacts and companies. + if (body.assigneeUserId) { + const [membership] = await db + .select({ id: teamMember.id }) + .from(teamMember) + .where(and(eq(teamMember.teamId, existing.teamId), eq(teamMember.userId, body.assigneeUserId))) + .limit(1) + + if (!membership) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Assignee is not a member of this team'), + }) + } + } + + if (body.projectId) { + const [matchedProject] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.id, body.projectId), eq(project.teamId, existing.teamId))) + .limit(1) + + if (!matchedProject) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Project is not part of this team'), + }) + } + } + + const now = new Date() + const { changes, updates } = diffConversationPatch(existing, body, now) + + if (Object.keys(updates).length === 0) { + return createSuccessResponse({ conversation: existing, changed: false }) + } + + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(conversation) + .set({ ...updates, lastActivityAt: now, updatedAt: now }) + .where(eq(conversation.id, conversationId)) + .returning() + + // Same transaction as the update it describes, so the two can never + // diverge. + await recordConversationActivity(tx, conversationId, changes, session.user.id) + + return row + }) + + await publishConversationEvent({ + type: 'conversation.updated', + teamId: existing.teamId, + inboxId: existing.inboxId, + conversationId, + }) + + return createSuccessResponse({ conversation: updated, changed: true }) +}) diff --git a/server/api/support/conversations/index.get.ts b/server/api/support/conversations/index.get.ts new file mode 100644 index 00000000..7294fd6c --- /dev/null +++ b/server/api/support/conversations/index.get.ts @@ -0,0 +1,112 @@ +/** + * @openapi + * /api/support/conversations: + * get: + * tags: [Support] + * summary: List conversations in an inbox + * operationId: listSupportConversations + * parameters: + * - in: query + * name: inboxId + * required: true + * schema: { type: string } + * - in: query + * name: status + * schema: { type: string } + * - in: query + * name: assigneeUserId + * schema: { type: string } + * - in: query + * name: contactId + * schema: { type: string } + * - in: query + * name: tagId + * schema: { type: string } + * - in: query + * name: projectId + * schema: { type: string } + * - in: query + * name: limit + * schema: { type: integer, minimum: 1, maximum: 100, default: 25 } + * - in: query + * name: cursor + * schema: { type: string } + * responses: + * 200: { description: Conversation page } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox not found } + */ +import { z } from 'zod' +import { and, desc, eq, inArray, lt, or } from 'drizzle-orm' +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' +import { validateQuery } from '~/server/utils/validation' +import { decodeListCursor, encodeListCursor } from '~/server/utils/list-cursor' +import { db } from '~/server/database/drizzle' +import { conversation, conversationTag } from '~/server/database/schema/support' + +const querySchema = z.object({ + inboxId: z.string().min(1), + status: z.enum(['open', 'pending', 'resolved', 'snoozed', 'closed']).optional(), + assigneeUserId: z.string().optional(), + contactId: z.string().optional(), + tagId: z.string().optional(), + projectId: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), + cursor: z.string().optional(), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const query = validateQuery(event, querySchema) + + await requireInboxAccess(query.inboxId, session.user.id) + + const conditions = [eq(conversation.inboxId, query.inboxId)] + + if (query.status) conditions.push(eq(conversation.status, query.status)) + if (query.assigneeUserId) conditions.push(eq(conversation.assigneeUserId, query.assigneeUserId)) + if (query.contactId) conditions.push(eq(conversation.contactId, query.contactId)) + if (query.projectId) conditions.push(eq(conversation.projectId, query.projectId)) + + if (query.tagId) { + conditions.push( + inArray( + conversation.id, + db + .select({ id: conversationTag.conversationId }) + .from(conversationTag) + .where(eq(conversationTag.tagId, query.tagId)) + ) + ) + } + + if (query.cursor) { + const cursor = decodeListCursor(query.cursor, 'conversation') + conditions.push( + or( + lt(conversation.createdAt, cursor.createdAt), + and(eq(conversation.createdAt, cursor.createdAt), lt(conversation.id, cursor.id)) + )! + ) + } + + const rows = await db + .select() + .from(conversation) + .where(and(...conditions)) + .orderBy(desc(conversation.createdAt), desc(conversation.id)) + .limit(query.limit + 1) + + const hasMore = rows.length > query.limit + const items = hasMore ? rows.slice(0, query.limit) : rows + + return createSuccessResponse({ + conversations: items, + hasMore, + nextCursor: hasMore + ? encodeListCursor({ createdAt: items[items.length - 1].createdAt, id: items[items.length - 1].id }) + : null, + }) +}) diff --git a/server/api/support/conversations/index.post.ts b/server/api/support/conversations/index.post.ts new file mode 100644 index 00000000..76f604ec --- /dev/null +++ b/server/api/support/conversations/index.post.ts @@ -0,0 +1,115 @@ +/** + * @openapi + * /api/support/conversations: + * post: + * tags: [Support] + * summary: Create a conversation + * description: > + * Stage 02's manual entry point - no mail pipeline yet. Creates the + * ticket shell only; messages are added afterward via + * /conversations/{id}/messages. + * operationId: createSupportConversation + * responses: + * 200: { description: Conversation created } + * 400: { description: contactId or projectId does not belong to this team } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Inbox not found } + */ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireInboxAccess } from '~/server/utils/support-access' +import { allocateConversationDisplayId } from '~/server/utils/support-counter' +import { publishConversationEvent } from '~/server/utils/support-realtime' +import { validateBody } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { contact, conversation } from '~/server/database/schema/support' +import { project } from '~/server/database/schema/feedback' + +const bodySchema = z.object({ + inboxId: z.string().min(1), + contactId: z.string().min(1), + subject: z.string().trim().max(500).optional(), + priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(), + projectId: z.string().optional(), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const body = await validateBody(event, bodySchema) + + const inbox = await requireInboxAccess(body.inboxId, session.user.id) + + const [matchedContact] = await db + .select({ id: contact.id }) + .from(contact) + .where(and(eq(contact.id, body.contactId), eq(contact.teamId, inbox.teamId))) + .limit(1) + + if (!matchedContact) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Contact is not part of this team'), + }) + } + + if (body.projectId) { + const [matchedProject] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.id, body.projectId), eq(project.teamId, inbox.teamId))) + .limit(1) + + if (!matchedProject) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Project is not part of this team'), + }) + } + } + + // Falls back to the inbox's own product link when the request doesn't pick + // one - the single-product case from delta D-27 shouldn't require every + // manual ticket to specify a projectId that only ever has one value anyway. + const projectId = body.projectId ?? inbox.projectId ?? null + + const now = new Date() + const conversationId = randomUUID() + + const created = await db.transaction(async (tx) => { + const displayId = await allocateConversationDisplayId(tx, inbox.teamId) + + const [row] = await tx + .insert(conversation) + .values({ + id: conversationId, + inboxId: inbox.id, + teamId: inbox.teamId, + contactId: body.contactId, + projectId, + displayId, + subject: body.subject ?? null, + priority: body.priority ?? null, + lastActivityAt: now, + createdAt: now, + updatedAt: now, + }) + .returning() + + return row + }) + + await publishConversationEvent({ + type: 'conversation.created', + teamId: inbox.teamId, + inboxId: inbox.id, + conversationId, + }) + + return createSuccessResponse({ conversation: created }) +}) diff --git a/server/utils/conversation-activity.ts b/server/utils/conversation-activity.ts new file mode 100644 index 00000000..0c6ab25d --- /dev/null +++ b/server/utils/conversation-activity.ts @@ -0,0 +1,158 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import type { db } from '~/server/database/drizzle' +import { conversationMessage } from '~/server/database/schema/support' +import { user } from '~/server/database/schema/auth' +import { project } from '~/server/database/schema/feedback' + +/** + * Activity messages for the Chatwoot-style inline event feed (`design.md` → + * Stage 02 → API → "Activity messages"). `kind: 'activity'` rows live in the + * same `conversationMessage` table as replies and notes, ordered by + * `createdAt` alongside them - there is deliberately no separate audit table. + * + * `isPrivate: true` on every row here: "assigned to Bob" / "status → + * resolved" is operational detail, not something a future customer-facing + * view (Stage 10's portal) should show. Not specified in `design.md`; this is + * the conservative reading, flagged for confirmation like the SUP-02-1 tag + * columns. + */ + +type Tx = Parameters[0]>[0] + +export interface ConversationChange { + field: 'status' | 'priority' | 'assigneeUserId' | 'projectId' + from: string | null + to: string | null +} + +/** + * Fields whose change is worth an inline activity entry. `subject` is + * deliberately absent: it is updatable, but renaming a ticket is not an + * operational event the thread should narrate. + */ +const TRACKED_FIELDS = ['status', 'priority', 'assigneeUserId', 'projectId'] as const + +export interface ConversationPatch { + status?: string + priority?: string | null + assigneeUserId?: string | null + subject?: string | null + projectId?: string | null +} + +interface ConversationState { + status?: string | null + priority?: string | null + assigneeUserId?: string | null + subject?: string | null + projectId?: string | null +} + +export interface ConversationPatchDiff { + changes: ConversationChange[] + updates: Record +} + +/** + * Work out what a PATCH actually changes. Pure, so the semantics below are + * unit-testable without a database: + * + * - A field absent from the patch is untouched (absent != null; explicitly + * sending null clears the field and *is* a change). + * - A field present but equal to the current value is not a change - re-sending + * the same status must not spam the thread with activity messages that say + * nothing happened. + * - `resolvedAt` is stamped when the conversation reaches a terminal status and + * cleared when it is reopened, so a reopened-then-resolved ticket measures + * from its second resolution. Stage 06's SLA resolution metric and Stage 08's + * CSAT attribution both read it. + */ +export function diffConversationPatch( + existing: ConversationState, + patch: ConversationPatch, + now: Date +): ConversationPatchDiff { + const changes: ConversationChange[] = [] + const updates: Record = {} + + for (const field of TRACKED_FIELDS) { + if (!(field in patch)) continue + + const next = patch[field] ?? null + const previous = existing[field] ?? null + if (next === previous) continue + + updates[field] = next + changes.push({ field, from: previous, to: next }) + } + + if ('subject' in patch && (patch.subject ?? null) !== (existing.subject ?? null)) { + updates.subject = patch.subject ?? null + } + + if ('status' in updates) { + updates.resolvedAt = updates.status === 'resolved' || updates.status === 'closed' ? now : null + } + + return { changes, updates } +} + +async function describeChange(tx: Tx, change: ConversationChange): Promise { + switch (change.field) { + case 'status': + return `Status changed from ${change.from} to ${change.to}.` + + case 'priority': + if (!change.to) return `Priority cleared.` + return `Priority set to ${change.to}.` + + case 'assigneeUserId': { + if (!change.to) return `Unassigned.` + const [assignee] = await tx.select({ name: user.name }).from(user).where(eq(user.id, change.to)).limit(1) + return `Assigned to ${assignee?.name ?? change.to}.` + } + + case 'projectId': { + if (!change.to) return `Product cleared.` + const [matchedProject] = await tx + .select({ name: project.name }) + .from(project) + .where(eq(project.id, change.to)) + .limit(1) + return `Product set to ${matchedProject?.name ?? change.to}.` + } + } +} + +/** + * Insert one `activity` message per change. Must run inside the same + * transaction as the conversation update it describes, so the two can never + * diverge (an update that "succeeds" with no matching activity row, or vice + * versa). + */ +export async function recordConversationActivity( + tx: Tx, + conversationId: string, + changes: ConversationChange[], + actorUserId: string +): Promise { + if (changes.length === 0) return + + const now = new Date() + + for (const change of changes) { + const body = await describeChange(tx, change) + + await tx.insert(conversationMessage).values({ + id: randomUUID(), + conversationId, + kind: 'activity', + body, + senderKind: 'system', + senderUserId: actorUserId, + isPrivate: true, + createdAt: now, + }) + } +} diff --git a/server/utils/support-realtime.ts b/server/utils/support-realtime.ts new file mode 100644 index 00000000..528a23ba --- /dev/null +++ b/server/utils/support-realtime.ts @@ -0,0 +1,33 @@ +import { conversationChannel, inboxChannel, publishRealtime } from '~/server/services/realtime' + +/** + * Every conversation-domain write publishes on both `conversation:` (the + * thread pane, open on this one ticket) and `inbox:` (the list pane, + * showing every ticket in the inbox) - see `design.md` → Stage 02 → + * Realtime. One envelope, two channel-scoped publishes: `envelopeMatchesChannel` + * checks the envelope's own ids against each channel independently, so the + * same envelope (carrying both `inboxId` and `conversationId`) legitimately + * matches both. + */ +export interface ConversationEventInput { + type: string + teamId: string + inboxId: string + conversationId: string + messageId?: string +} + +export async function publishConversationEvent(input: ConversationEventInput): Promise { + const envelope = { + type: input.type, + teamId: input.teamId, + inboxId: input.inboxId, + conversationId: input.conversationId, + ...(input.messageId ? { messageId: input.messageId } : {}), + } + + await Promise.all([ + publishRealtime(conversationChannel(input.conversationId), envelope), + publishRealtime(inboxChannel(input.inboxId), envelope), + ]) +} diff --git a/tests/conversation-activity.test.ts b/tests/conversation-activity.test.ts new file mode 100644 index 00000000..32bc5537 --- /dev/null +++ b/tests/conversation-activity.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' + +import { + diffConversationPatch, + recordConversationActivity, + type ConversationChange, +} from '../server/utils/conversation-activity' + +/** + * Same approach as `support-counter.test.ts`: the function takes `tx` as a + * parameter, so a hand-rolled fake stands in rather than mocking the + * database module. + */ + +function fakeTx(selectResults: unknown[][] = []) { + const inserted: unknown[] = [] + let selectCalls = 0 + + const tx = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve(selectResults[selectCalls++] ?? []), + }), + }), + }), + insert: () => ({ + values: (values: unknown) => { + inserted.push(values) + return Promise.resolve() + }, + }), + } + + return { tx, inserted } +} + +async function bodies(changes: ConversationChange[], selectResults: unknown[][] = []) { + const { tx, inserted } = fakeTx(selectResults) + await recordConversationActivity(tx as any, 'conv1', changes, 'actor1') + return inserted as { body: string; kind: string; senderKind: string; senderUserId: string; isPrivate: boolean }[] +} + +describe('recordConversationActivity', () => { + it('does nothing for an empty change set', async () => { + const rows = await bodies([]) + expect(rows).toEqual([]) + }) + + it('describes a status change', async () => { + const rows = await bodies([{ field: 'status', from: 'open', to: 'resolved' }]) + expect(rows[0].body).toBe('Status changed from open to resolved.') + }) + + it('describes priority being set', async () => { + const rows = await bodies([{ field: 'priority', from: null, to: 'high' }]) + expect(rows[0].body).toBe('Priority set to high.') + }) + + it('describes priority being cleared', async () => { + const rows = await bodies([{ field: 'priority', from: 'high', to: null }]) + expect(rows[0].body).toBe('Priority cleared.') + }) + + it('describes an assignment, looking up the display name', async () => { + const rows = await bodies([{ field: 'assigneeUserId', from: null, to: 'u1' }], [[{ name: 'Jane' }]]) + expect(rows[0].body).toBe('Assigned to Jane.') + }) + + it('falls back to the id when the assignee lookup misses', async () => { + const rows = await bodies([{ field: 'assigneeUserId', from: null, to: 'u1' }], [[]]) + expect(rows[0].body).toBe('Assigned to u1.') + }) + + it('describes unassignment', async () => { + const rows = await bodies([{ field: 'assigneeUserId', from: 'u1', to: null }]) + expect(rows[0].body).toBe('Unassigned.') + }) + + it('describes a product being set, looking up the project name', async () => { + const rows = await bodies([{ field: 'projectId', from: null, to: 'p1' }], [[{ name: 'Billing' }]]) + expect(rows[0].body).toBe('Product set to Billing.') + }) + + it('describes a product being cleared', async () => { + const rows = await bodies([{ field: 'projectId', from: 'p1', to: null }]) + expect(rows[0].body).toBe('Product cleared.') + }) + + it('inserts one activity row per change, all private and attributed to the actor', async () => { + const rows = await bodies( + [ + { field: 'status', from: 'open', to: 'pending' }, + { field: 'priority', from: null, to: 'urgent' }, + ], + [] + ) + + expect(rows).toHaveLength(2) + for (const row of rows) { + expect(row.kind).toBe('activity') + expect(row.senderKind).toBe('system') + expect(row.senderUserId).toBe('actor1') + expect(row.isPrivate).toBe(true) + } + }) +}) + +describe('diffConversationPatch', () => { + const now = new Date('2026-08-15T12:00:00.000Z') + const open = { status: 'open', priority: null, assigneeUserId: null, subject: 'Broken login', projectId: null } + + it('treats an empty patch as no change', () => { + const { changes, updates } = diffConversationPatch(open, {}, now) + expect(changes).toEqual([]) + expect(updates).toEqual({}) + }) + + it('ignores a field whose value is unchanged', () => { + const { changes, updates } = diffConversationPatch(open, { status: 'open' }, now) + expect(changes).toEqual([]) + expect(updates).toEqual({}) + }) + + it('records a changed field once, with both sides', () => { + const { changes, updates } = diffConversationPatch(open, { status: 'resolved' }, now) + expect(changes).toEqual([{ field: 'status', from: 'open', to: 'resolved' }]) + expect(updates.status).toBe('resolved') + }) + + it('distinguishes an absent field from an explicit null', () => { + const assigned = { ...open, assigneeUserId: 'u1' } + + // Absent - untouched. + expect(diffConversationPatch(assigned, {}, now).changes).toEqual([]) + + // Explicitly null - an unassignment, which is a real change. + expect(diffConversationPatch(assigned, { assigneeUserId: null }, now).changes).toEqual([ + { field: 'assigneeUserId', from: 'u1', to: null }, + ]) + }) + + it('updates the subject without recording it as an activity change', () => { + const { changes, updates } = diffConversationPatch(open, { subject: 'Cannot sign in' }, now) + expect(changes).toEqual([]) + expect(updates.subject).toBe('Cannot sign in') + }) + + it('stamps resolvedAt on resolve and on close', () => { + expect(diffConversationPatch(open, { status: 'resolved' }, now).updates.resolvedAt).toBe(now) + expect(diffConversationPatch(open, { status: 'closed' }, now).updates.resolvedAt).toBe(now) + }) + + it('clears resolvedAt when a resolved conversation is reopened', () => { + const resolved = { ...open, status: 'resolved' } + expect(diffConversationPatch(resolved, { status: 'open' }, now).updates.resolvedAt).toBeNull() + }) + + it('leaves resolvedAt alone when status is not part of the patch', () => { + const { updates } = diffConversationPatch(open, { priority: 'high' }, now) + expect('resolvedAt' in updates).toBe(false) + }) + + it('records several changes from one patch', () => { + const { changes } = diffConversationPatch(open, { status: 'pending', priority: 'high', projectId: 'p1' }, now) + expect(changes.map((c) => c.field)).toEqual(['status', 'priority', 'projectId']) + }) +}) From 8b595212bde3fc1efa978dfbcfac2de6e545103a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:04:26 +0200 Subject: [PATCH 064/334] chore(todo): check off SUP-02-7 conversation CRUD --- TODO.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 75d1287d..e82b5c22 100644 --- a/TODO.md +++ b/TODO.md @@ -277,7 +277,8 @@ separate: the **agent workspace** (`/support`, team-scoped, this stage) and the - `GET/POST /api/support/inboxes`, `GET/PUT/DELETE /api/support/inboxes/[id]`, `GET/POST /api/support/inboxes/[id]/members`, `DELETE /api/support/inboxes/[id]/members/[memberId]`. No pagination on the list endpoint — a team's inboxes are a short settings list, not an open-ended feed, unlike contacts/companies. The creator is added as a `supportInboxMember` with role `admin` in the same transaction as inbox creation — otherwise a non-team-admin creator would create an inbox they immediately have no access to. Adding a member requires the target `userId` to already be a `teamMember` of the inbox's team (400 otherwise); who may call the add/remove endpoints is gated only by general inbox access (any role, or the team-admin bypass) — the stage doc does not specify finer-grained RBAC here, matching this stage's team-membership-only permission model elsewhere (delta D-28). Delete added `isForeignKeyViolation` to `support-errors.ts` (mirrors `isUniqueViolation`'s `.cause`-unwrapping) to turn the `conversation.inboxId` restrict FK into a clean 409 rather than a 500. PUT's `defaultAssigneeUserId` and `projectId` are validated same-team before write, matching the contact/company pattern. Channel/provider fields (`emailAddress`, `channelConfig`, auto-reply) are intentionally not in the PUT body — Stage 03 owns those per the stage doc. No unit tests per endpoint file, matching the existing convention for `companies`/`contacts` (covered by E2E once UI exists, SUP-02-17); instead verified live against a running dev server and real Postgres: full inbox lifecycle, duplicate-slug 409, cross-tenant 403, member add/duplicate-409/remove/access-revoked cycle, and the FK-restrict-delete 409 path (confirmed by hand-inserting a fixture conversation row, since conversation CRUD is SUP-02-7). - [x] **SUP-02-6** Add receiving-address endpoints with per-address product mapping and same-team `projectId` validation - `GET/POST /api/support/inboxes/[id]/addresses`, `DELETE /api/support/inboxes/[id]/addresses/[addressId]`. Address is normalized to lowercase on write (zod `.toLowerCase()`), matching `resolveInboxByAddress`'s case-insensitive read — otherwise two rows differing only by case could both silently claim the same inbound mail. `isPrimary` is treated as exclusive per inbox: setting it on one address clears it on the inbox's others in the same transaction; `design.md` doesn't specify this, it's the self-consistent reading of "primary" implying one, flagged here for confirmation like the SUP-02-1 tag-column gap. `projectId` validated same-team before write; global unique-address conflict caught via `isUniqueViolation` → 409. Verified live: case-insensitive duplicate 409, primary-exclusivity across two addresses, cross-team `projectId` 400, delete, and — since this was `resolveInboxByAddress`'s first exercise against real data — a direct call confirming it resolves the correct inbox+address case-insensitively and returns `null` cleanly on no match. -- [ ] **SUP-02-7** Add conversation list/create/get/patch endpoints with filters (including product) and cursor pagination; emit `activity` messages on every status, priority, assignee, and product change +- [x] **SUP-02-7** Add conversation list/create/get/patch endpoints with filters (including product) and cursor pagination; emit `activity` messages on every status, priority, assignee, and product change + - List/create landed as WIP from a second concurrent session; detail, patch, and the activity wiring completed here. Change detection is extracted as the pure `diffConversationPatch()` in `server/utils/conversation-activity.ts`, following Stage 01's `contact-merge.ts` pattern, so absent-vs-explicit-null, no-op patches, and `resolvedAt` stamping are unit-testable without a database — 19 tests. Activity rows are written in the same transaction as the update they describe. Subject is updatable but deliberately emits no activity message (renaming a ticket is not an operational event); `design.md` names only status/priority/assignee, and product was added by delta D-27. Assignee and product are validated as same-team before write — a foreign key proves existence, not tenancy. Two judgment calls flagged for confirmation, both undocumented in `design.md`: activity messages are `isPrivate: true` (so Stage 10's portal cannot surface them), and `resolvedAt` is cleared on reopen so a reopened-then-resolved ticket measures from its second resolution. - [ ] **SUP-02-8** Add message, participant, and tag endpoints; publish thin realtime envelopes on `conversation:` and `inbox:` for every write - [ ] **SUP-02-9** Build the `/support` three-pane UI: inbox switcher, filtered conversation list, thread pane rendering all four message kinds, contact drawer - [ ] **SUP-02-10** Build the composer with an unmistakable reply/note toggle; messages stored only, not sent, in this stage From c411a3ad05929e5b96972bb1c270d8021d1e9624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:24:23 +0200 Subject: [PATCH 065/334] docs(support): add parallel-agent contract for the rest of Stage 02 Two Claude sessions are working Stage 02 concurrently and were sharing one working tree, which nearly caused a collision on SUP-02-2/02-4. Splits the remaining 10 items by file territory so the two agents touch disjoint files, records the worktree/branch each uses, and defines support-platform as the single integration point -- both pull from it and push verified items to it, rather than merging the two agent branches into each other. Co-Authored-By: Claude Opus 5 (1M context) --- .../parallel-agents.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/plans/2026-08-11-support-platform/parallel-agents.md diff --git a/docs/plans/2026-08-11-support-platform/parallel-agents.md b/docs/plans/2026-08-11-support-platform/parallel-agents.md new file mode 100644 index 00000000..5d431648 --- /dev/null +++ b/docs/plans/2026-08-11-support-platform/parallel-agents.md @@ -0,0 +1,176 @@ +# Stage 02 — Parallel agent split + +**Created:** August 15, 2026. **Applies to:** the remainder of Stage 02 only. + +Two Claude sessions are working this stage concurrently. This document is the contract between them. +**Agent 1 reads this first.** Agent 2 wrote it and is working from `D:\veerify`. + +--- + +## Why this exists + +Both sessions were previously operating on the **same working tree** (`D:\veerify`). On 2026-08-15 that +nearly caused a collision: Agent 2 dispatched subagents for SUP-02-2 and SUP-02-4 at the same time Agent 1 +was committing those exact items. Nothing was lost only because Agent 2's subagents hit the session limit +before committing. That was luck. + +Separate worktrees remove the shared-file hazard. This document removes the duplicated-work hazard. + +--- + +## Workspaces + +| | Agent 1 | Agent 2 | +| ----------------- | ----------------------- | ------------------ | +| Working directory | **`D:\veerify-agent1`** | `D:\veerify` | +| Branch | **`agent1/stage-02`** | `support-platform` | + +The worktree and branch **already exist** — created by Agent 2, with `.env` copied in. Agent 1 does not +need to create them. Just: + +```bash +cd D:/veerify-agent1 +git status # expect: On branch agent1/stage-02 +yarn install # node_modules is NOT shared between worktrees +``` + +If `D:\veerify-agent1` is inaccessible (different Windows user, permissions), recreate it anywhere you +can write: + +```bash +git -C D:/veerify worktree add -b agent1/stage-02 support-platform +``` + +…and say so in your first report, so Agent 2 knows the path changed. + +### Two things that are shared and will bite you + +- **The Postgres dev database is shared.** Both worktrees point at the same `veerify-db` container. Read + and write freely, but see the migration rule below. +- **Ports collide.** If you run `yarn dev`, use a different port than 3001 (Agent 2 uses 3001): + `PORT=3002 yarn dev`. + +--- + +## Work split + +Assigned by **file territory**, not by convenience — the split is chosen so the two agents touch disjoint +files. + +### Agent 1 — server + notifications + docs + +- [ ] **SUP-02-8** Message, participant, and tag endpoints; publish thin realtime envelopes on + `conversation:` and `inbox:` for every write. + Use the existing `publishConversationEvent()` in `server/utils/support-realtime.ts` — do not write a + second publisher. + **This is the critical path**: Agent 2's thread pane (SUP-02-9) cannot render messages until the + `GET .../messages` endpoint exists. Do this first. +- [ ] **SUP-02-14** Build `/support/settings`: inbox name, signature, agent membership, and the + receiving-address list with product mapping. APIs already exist (SUP-02-5, SUP-02-6). +- [ ] **SUP-02-15** Add `conversation_assigned` and `conversation_mention` notification types and + preference toggles. Reuse the existing notification infrastructure; do not build a parallel one. + Touches `server/utils/notifications.ts` and `components/settings/SettingsNotifications.vue` only. +- [ ] **SUP-02-16** Register support inbox and conversation routes in the OpenAPI spec. Hand-transcribe + into `server/api/openapi.json.get.ts` (delta D-23 — there is no route registry; SUP-X-3 is the real + fix and is not in this stage). + +### Agent 2 — agent UI + navigation + +- [ ] **SUP-02-9** `/support` three-pane UI (inbox switcher, conversation list, thread pane, contact drawer) +- [ ] **SUP-02-10** Composer with the reply/note toggle +- [ ] **SUP-02-11** Sidebar: rename the existing `Support` group to `System`, add a real `Support` group +- [ ] **SUP-02-12** Per-team Tools tab in `/settings` +- [ ] **SUP-02-13** Module disable semantics +- [ ] **SUP-02-17** E2E coverage — **last**, after both sides land + +### File boundaries — do not cross without saying so + +| Agent 1 owns | Agent 2 owns | +| ----------------------------------------------- | ----------------------------------- | +| `server/api/support/conversations/[id]/**` | `pages/support/index.vue` | +| `server/api/support/tags/**` | `components/support/**` | +| `pages/support/settings.vue` | `components/sidebar/AppSidebar.vue` | +| `server/utils/notifications.ts` | `pages/settings/index.vue` | +| `components/settings/SettingsNotifications.vue` | `middleware/auth.global.ts` | +| `server/api/openapi.json.get.ts` | | + +`server/utils/support-realtime.ts` and `server/utils/conversation-activity.ts` are **shared and stable** — +read them, extend only if genuinely necessary, and flag it if you do. + +--- + +## Syncing — through `support-platform`, not with each other + +**Do not merge `agent1/stage-02` and `support-platform` into each other ad hoc, and never merge Agent 2's +in-progress work directly.** `support-platform` is the single integration point. Both agents pull from it +and push finished items to it. + +### Pull often — at minimum at the start of every item + +```bash +cd D:/veerify-agent1 +git fetch origin +git merge origin/support-platform # bring in Agent 2's landed work +``` + +Doing this at least once per item keeps divergence to hours, not days. If it has been more than ~2 hours +of active work, pull again before starting anything new. + +### Push a finished item + +Only after the item is complete **and** `yarn harness:verify` is green: + +```bash +# 1. sync first, so you verify what the merge will actually produce +git fetch origin +git merge origin/support-platform +yarn harness:verify # must be green AFTER the merge, not before + +# 2. integrate +git checkout support-platform +git pull --rebase origin support-platform +git merge --no-ff agent1/stage-02 +yarn harness:verify # green again on the integration branch +git push origin support-platform + +# 3. return to your branch +git checkout agent1/stage-02 +git merge support-platform # fast-forward, keeps the branches level +``` + +**A push race is expected occasionally** — both agents push to `support-platform`. If `git push` is +rejected, `git pull --rebase origin support-platform`, re-run `yarn harness:verify`, and push again. Do +not force-push. + +**`git checkout support-platform` will fail** if Agent 2 has it checked out in `D:\veerify` — git does not +allow one branch in two worktrees. If that happens, push your branch (`git push -u origin +agent1/stage-02`), report it as ready, and let Agent 2 integrate it. That is the normal fallback, not an +error condition. + +--- + +## Rules that are not negotiable + +1. **`TODO.md` is edited by whoever integrates, in a separate `chore(todo):` commit** — never inside a + feature commit. Check an item off only after it is merged into `support-platform` and verified there. +2. **No migrations without coordinating first.** Neither agent's remaining items should need a schema + change; the Stage 02 tables all landed in SUP-02-1 (`0022`). If you believe you need one, **stop and + say so** — two agents generating `0023` independently is the exact collision the README documents + between `support-platform` and `sleekplan-export`. +3. **Read before writing.** `.agents/CLAUDE.md`, then `design.md`, then `deltas.md` (several entries + override the original stage docs), then `stage-02-conversation-core.md`. +4. **Options API only.** No ` - + - Support + System - + Date: Sat, 15 Aug 2026 17:50:12 +0200 Subject: [PATCH 068/334] chore(todo): check off SUP-02-11 sidebar restructure --- TODO.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index e82b5c22..0d1af0a8 100644 --- a/TODO.md +++ b/TODO.md @@ -282,7 +282,8 @@ separate: the **agent workspace** (`/support`, team-scoped, this stage) and the - [ ] **SUP-02-8** Add message, participant, and tag endpoints; publish thin realtime envelopes on `conversation:` and `inbox:` for every write - [ ] **SUP-02-9** Build the `/support` three-pane UI: inbox switcher, filtered conversation list, thread pane rendering all four message kinds, contact drawer - [ ] **SUP-02-10** Build the composer with an unmistakable reply/note toggle; messages stored only, not sent, in this stage -- [ ] **SUP-02-11** Rename the existing `Support` sidebar group to `System`; add a real `Support` group with Inbox and Contacts; add `/support` to `protectedRoutes` +- [x] **SUP-02-11** Rename the existing `Support` sidebar group to `System`; add a real `Support` group with Inbox and Contacts; add `/support` to `protectedRoutes` + - `d2dbf42`. Rename covers both the label and the backing array (`supportItems` → `systemItems`). New Support group sits inside the `hasActiveOrganization === true` block, so it does not render for personal accounts. `/support` turned out to be **already** in `protectedRoutes` from earlier work, so no middleware change was needed. No E2E selector referenced the old group label — the specs use `a[href=…]` and role selectors — so no test churn. `Roadmap`/`Changelog` deliberately left hardcoded `disabled: true`; that is Technical Debt #11 and belongs to SUP-02-12. **Transient state on the integration branch:** the Inbox link points at `/support`, which does not exist until SUP-02-9 lands. - [ ] **SUP-02-12** Add the per-team Tools tab to `/settings` with module toggles driving sidebar visibility, replacing the hardcoded `disabled: true` Roadmap/Changelog placeholders. Team membership only — no `teamMember.role` check (delta D-28) - [ ] **SUP-02-13** Implement module disable semantics: hide nav and stop inbound processing while preserving conversations and contacts - [ ] **SUP-02-14** Build `/support/settings` with inbox name, signature, agent membership, and the receiving-address list with product mapping From c8bc29d0295eab038e6c135510cd855867f0358c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:55:23 +0200 Subject: [PATCH 069/334] feat(support): add message, participant, and tag endpoints (SUP-02-8) Completes the Stage 02 conversation API surface: - GET/POST /api/support/conversations/[id]/messages - POST/DELETE /api/support/conversations/[id]/participants - GET/POST/DELETE /api/support/conversations/[id]/tags - GET/POST/DELETE /api/support/tags The message thread GET anchors to the newest window rather than paginating from the start: it fetches limit+1 rows descending and reverses them into the ascending order the thread pane renders, so `hasMore` means older history exists above. All four kinds are returned including private notes - this is the agent-facing endpoint. On POST, `isPrivate` is derived from `kind` server-side and never read from the request body; a note rendered as a customer-visible reply is the worst failure mode in a support tool. Only `outgoing` touches lastAgentReplyAt and firstResponseAt - a private note is not a reply to the customer. Stage 06's SLA metrics read firstResponseAt, which is why it is captured here. Participants enforce exactly-one-of contactId/userId, which the schema allows but does not constrain. Tag assignment verifies the tag's team matches the conversation's, since the foreign key proves only that the tag exists. Every write publishes through the existing publishConversationEvent; tag and participant changes reuse `conversation.updated` rather than adding bespoke event types, since envelopes carry no detail and clients refetch. --- .../conversations/[id]/messages/index.get.ts | 65 +++++++++ .../conversations/[id]/messages/index.post.ts | 105 +++++++++++++++ .../participants/[participantId].delete.ts | 61 +++++++++ .../[id]/participants/index.post.ts | 127 ++++++++++++++++++ .../conversations/[id]/tags/[tagId].delete.ts | 60 +++++++++ .../conversations/[id]/tags/index.get.ts | 45 +++++++ .../conversations/[id]/tags/index.post.ts | 92 +++++++++++++ server/api/support/tags/[id].delete.ts | 51 +++++++ server/api/support/tags/index.get.ts | 45 +++++++ server/api/support/tags/index.post.ts | 62 +++++++++ 10 files changed, 713 insertions(+) create mode 100644 server/api/support/conversations/[id]/messages/index.get.ts create mode 100644 server/api/support/conversations/[id]/messages/index.post.ts create mode 100644 server/api/support/conversations/[id]/participants/[participantId].delete.ts create mode 100644 server/api/support/conversations/[id]/participants/index.post.ts create mode 100644 server/api/support/conversations/[id]/tags/[tagId].delete.ts create mode 100644 server/api/support/conversations/[id]/tags/index.get.ts create mode 100644 server/api/support/conversations/[id]/tags/index.post.ts create mode 100644 server/api/support/tags/[id].delete.ts create mode 100644 server/api/support/tags/index.get.ts create mode 100644 server/api/support/tags/index.post.ts diff --git a/server/api/support/conversations/[id]/messages/index.get.ts b/server/api/support/conversations/[id]/messages/index.get.ts new file mode 100644 index 00000000..4cf8f04d --- /dev/null +++ b/server/api/support/conversations/[id]/messages/index.get.ts @@ -0,0 +1,65 @@ +/** + * @openapi + * /api/support/conversations/{id}/messages: + * get: + * tags: [Support] + * summary: Get a conversation's message thread + * description: > + * Returns the most recent window of messages in ascending (oldest-first) + * order, ready to render as a chat thread. `hasMore: true` means older + * history exists above the returned window - this endpoint always + * anchors to the newest messages, never to an arbitrary page. All + * kinds (`incoming`, `outgoing`, `note`, `activity`) are included, + * private notes too, since this is the agent-facing thread pane. + * operationId: listSupportConversationMessages + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * - in: query + * name: limit + * schema: { type: integer, minimum: 1, maximum: 500, default: 200 } + * responses: + * 200: { description: Message thread, oldest first } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Conversation not found } + */ +import { z } from 'zod' +import { desc, eq } from 'drizzle-orm' +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireConversationAccess } from '~/server/utils/support-access' +import { validateQuery } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { conversationMessage } from '~/server/database/schema/support' + +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(500).default(200), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const conversationId = getRouterParam(event, 'id') as string + const query = validateQuery(event, querySchema) + + await requireConversationAccess(conversationId, session.user.id) + + // Fetch the newest window in descending order, then reverse it into the + // ascending order the thread pane renders - a plain ascending scan with + // OFFSET would have to walk the entire history to find the tail. + const rows = await db + .select() + .from(conversationMessage) + .where(eq(conversationMessage.conversationId, conversationId)) + .orderBy(desc(conversationMessage.createdAt), desc(conversationMessage.id)) + .limit(query.limit + 1) + + const hasMore = rows.length > query.limit + const items = (hasMore ? rows.slice(0, query.limit) : rows).reverse() + + return createSuccessResponse({ + messages: items, + hasMore, + }) +}) diff --git a/server/api/support/conversations/[id]/messages/index.post.ts b/server/api/support/conversations/[id]/messages/index.post.ts new file mode 100644 index 00000000..cea716a4 --- /dev/null +++ b/server/api/support/conversations/[id]/messages/index.post.ts @@ -0,0 +1,105 @@ +/** + * @openapi + * /api/support/conversations/{id}/messages: + * post: + * tags: [Support] + * summary: Write an agent reply or an internal note to a conversation + * description: > + * Only `outgoing` (customer-visible reply) and `note` (internal-only) + * kinds may be created here - `incoming` is written by the mail + * pipeline and `activity` by the system, never by an agent directly. + * `isPrivate` is derived from `kind` server-side and is never taken + * from the request body, since a private note rendered as a public + * reply is the worst failure mode in a support tool. Stage 02 only + * stores the message; Stage 04 is responsible for actually sending it, + * so `deliveryStatus` starts at `pending`. + * operationId: createSupportConversationMessage + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Message created } + * 400: { description: Validation failed } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Conversation not found } + */ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { eq } from 'drizzle-orm' +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireConversationAccess } from '~/server/utils/support-access' +import { publishConversationEvent } from '~/server/utils/support-realtime' +import { validateBody } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { conversation, conversationMessage } from '~/server/database/schema/support' + +const bodySchema = z.object({ + kind: z.enum(['outgoing', 'note']), + body: z.string().trim().min(1).max(50000), + bodyHtml: z.string().max(200000).optional(), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const conversationId = getRouterParam(event, 'id') as string + const body = await validateBody(event, bodySchema) + + const existing = await requireConversationAccess(conversationId, session.user.id) + + const isPrivate = body.kind === 'note' + const now = new Date() + + const created = await db.transaction(async (tx) => { + const [message] = await tx + .insert(conversationMessage) + .values({ + id: randomUUID(), + conversationId, + kind: body.kind, + body: body.body, + bodyHtml: body.bodyHtml ?? null, + senderKind: 'agent', + senderContactId: null, + senderUserId: session.user.id, + isPrivate, + // Stage 02 only stores replies - Stage 04 owns actually sending them. + deliveryStatus: 'pending', + createdAt: now, + }) + .returning() + + const conversationUpdates: Partial = { + lastActivityAt: now, + updatedAt: now, + } + + // A private note is not a reply to the customer, so it must not touch + // either of these - only `outgoing` counts as a response. + if (body.kind === 'outgoing') { + conversationUpdates.lastAgentReplyAt = now + // Stage 06's SLA metrics read `firstResponseAt`, so it is captured here + // on the first outgoing reply and never overwritten after that. + if (!existing.firstResponseAt) { + conversationUpdates.firstResponseAt = now + } + } + + await tx.update(conversation).set(conversationUpdates).where(eq(conversation.id, conversationId)) + + return message + }) + + // Published after the transaction commits, never inside it. + await publishConversationEvent({ + type: 'message.created', + teamId: existing.teamId, + inboxId: existing.inboxId, + conversationId, + messageId: created.id, + }) + + return createSuccessResponse({ message: created }) +}) diff --git a/server/api/support/conversations/[id]/participants/[participantId].delete.ts b/server/api/support/conversations/[id]/participants/[participantId].delete.ts new file mode 100644 index 00000000..26445ba4 --- /dev/null +++ b/server/api/support/conversations/[id]/participants/[participantId].delete.ts @@ -0,0 +1,61 @@ +/** + * @openapi + * /api/support/conversations/{id}/participants/{participantId}: + * delete: + * tags: [Support] + * summary: Remove a CC or follower from a conversation + * operationId: removeSupportConversationParticipant + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * - in: path + * name: participantId + * required: true + * schema: { type: string } + * responses: + * 200: { description: Participant removed } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Conversation or participant not found } + */ +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireConversationAccess } from '~/server/utils/support-access' +import { publishConversationEvent } from '~/server/utils/support-realtime' +import { db } from '~/server/database/drizzle' +import { conversationParticipant } from '~/server/database/schema/support' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const conversationId = getRouterParam(event, 'id') as string + const participantId = getRouterParam(event, 'participantId') as string + + const existing = await requireConversationAccess(conversationId, session.user.id) + + // Scoping the delete to conversationId prevents deleting another + // conversation's participant by id. + const [deleted] = await db + .delete(conversationParticipant) + .where(and(eq(conversationParticipant.id, participantId), eq(conversationParticipant.conversationId, conversationId))) + .returning({ id: conversationParticipant.id }) + + if (!deleted) { + throw createError({ + statusCode: 404, + statusMessage: 'Not Found', + data: createErrorResponse(ErrorCode.NOT_FOUND, 'Participant not found on this conversation'), + }) + } + + await publishConversationEvent({ + type: 'conversation.updated', + teamId: existing.teamId, + inboxId: existing.inboxId, + conversationId, + }) + + return createSuccessResponse({ deleted: true }) +}) diff --git a/server/api/support/conversations/[id]/participants/index.post.ts b/server/api/support/conversations/[id]/participants/index.post.ts new file mode 100644 index 00000000..8151eb47 --- /dev/null +++ b/server/api/support/conversations/[id]/participants/index.post.ts @@ -0,0 +1,127 @@ +/** + * @openapi + * /api/support/conversations/{id}/participants: + * post: + * tags: [Support] + * summary: Add a CC or follower to a conversation + * description: > + * A participant is either a CC'd customer (`contactId`) or an internal + * follower (`userId`) - never both. Exactly one of the two must be set. + * operationId: addSupportConversationParticipant + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Participant added } + * 400: { description: Neither or both of contactId/userId set, or the target does not belong to this team } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Conversation not found } + * 409: { description: This participant is already on the conversation } + */ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireConversationAccess } from '~/server/utils/support-access' +import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody } from '~/server/utils/validation' +import { publishConversationEvent } from '~/server/utils/support-realtime' +import { db } from '~/server/database/drizzle' +import { contact, conversationParticipant } from '~/server/database/schema/support' +import { teamMember } from '~/server/database/schema/auth' + +const bodySchema = z + .object({ + contactId: z.string().min(1).optional(), + userId: z.string().min(1).optional(), + role: z.enum(['cc', 'follower']), + }) + // A row with both set (or neither) is meaningless - the DB has no + // constraint that catches this, so it's enforced here. + .refine((data) => Boolean(data.contactId) !== Boolean(data.userId), { + message: 'Exactly one of contactId or userId must be set', + }) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const conversationId = getRouterParam(event, 'id') as string + const body = await validateBody(event, bodySchema) + + const existing = await requireConversationAccess(conversationId, session.user.id) + + // A foreign key proves the contact/user exists, not that it belongs to + // this conversation's team - same cross-tenant gap Stage 01 closed for + // contacts. + if (body.contactId) { + const [matchedContact] = await db + .select({ id: contact.id }) + .from(contact) + .where(and(eq(contact.id, body.contactId), eq(contact.teamId, existing.teamId))) + .limit(1) + + if (!matchedContact) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Contact is not part of this team'), + }) + } + } + + if (body.userId) { + const [matchedMember] = await db + .select({ id: teamMember.id }) + .from(teamMember) + .where(and(eq(teamMember.teamId, existing.teamId), eq(teamMember.userId, body.userId))) + .limit(1) + + if (!matchedMember) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'User is not a member of this team'), + }) + } + } + + try { + const [created] = await db + .insert(conversationParticipant) + .values({ + id: randomUUID(), + conversationId, + contactId: body.contactId ?? null, + userId: body.userId ?? null, + role: body.role, + createdAt: new Date(), + }) + .returning() + + // Reuses 'conversation.updated' rather than a bespoke type - envelopes + // carry no detail and clients refetch, so the PATCH endpoint's handler + // already covers this without the UI needing a new event type. + // publishRealtime swallows its own errors, so this cannot turn a + // successful write into a failed request. + await publishConversationEvent({ + type: 'conversation.updated', + teamId: existing.teamId, + inboxId: existing.inboxId, + conversationId, + }) + + return createSuccessResponse({ participant: created }) + } catch (error) { + if (isUniqueViolation(error)) { + throw createError({ + statusCode: 409, + statusMessage: 'Conflict', + data: createErrorResponse(ErrorCode.CONFLICT, 'This participant is already on the conversation'), + }) + } + throw error + } +}) diff --git a/server/api/support/conversations/[id]/tags/[tagId].delete.ts b/server/api/support/conversations/[id]/tags/[tagId].delete.ts new file mode 100644 index 00000000..ebf5e816 --- /dev/null +++ b/server/api/support/conversations/[id]/tags/[tagId].delete.ts @@ -0,0 +1,60 @@ +/** + * @openapi + * /api/support/conversations/{id}/tags/{tagId}: + * delete: + * tags: [Support] + * summary: Remove a tag from a conversation + * operationId: removeSupportConversationTag + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * - in: path + * name: tagId + * required: true + * schema: { type: string } + * responses: + * 200: { description: Tag removed } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Conversation not found, or tag is not on the conversation } + */ +import { and, eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireConversationAccess } from '~/server/utils/support-access' +import { publishConversationEvent } from '~/server/utils/support-realtime' +import { db } from '~/server/database/drizzle' +import { conversationTag } from '~/server/database/schema/support' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const conversationId = getRouterParam(event, 'id') as string + const tagId = getRouterParam(event, 'tagId') as string + + const existing = await requireConversationAccess(conversationId, session.user.id) + + const [deleted] = await db + .delete(conversationTag) + .where(and(eq(conversationTag.conversationId, conversationId), eq(conversationTag.tagId, tagId))) + .returning({ id: conversationTag.id }) + + if (!deleted) { + throw createError({ + statusCode: 404, + statusMessage: 'Not Found', + data: createErrorResponse(ErrorCode.NOT_FOUND, 'Tag is not on this conversation'), + }) + } + + // Same event as the add path - see the comment there. + await publishConversationEvent({ + type: 'conversation.updated', + teamId: existing.teamId, + inboxId: existing.inboxId, + conversationId, + }) + + return createSuccessResponse({ deleted: true }) +}) diff --git a/server/api/support/conversations/[id]/tags/index.get.ts b/server/api/support/conversations/[id]/tags/index.get.ts new file mode 100644 index 00000000..739a70fa --- /dev/null +++ b/server/api/support/conversations/[id]/tags/index.get.ts @@ -0,0 +1,45 @@ +/** + * @openapi + * /api/support/conversations/{id}/tags: + * get: + * tags: [Support] + * summary: List a conversation's tags + * operationId: listSupportConversationTags + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Tag list } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Conversation not found } + */ +import { asc, eq } from 'drizzle-orm' +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireConversationAccess } from '~/server/utils/support-access' +import { db } from '~/server/database/drizzle' +import { conversationTag, supportTag } from '~/server/database/schema/support' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const conversationId = getRouterParam(event, 'id') as string + + await requireConversationAccess(conversationId, session.user.id) + + const tags = await db + .select({ + id: supportTag.id, + name: supportTag.name, + color: supportTag.color, + conversationTagId: conversationTag.id, + createdAt: conversationTag.createdAt, + }) + .from(conversationTag) + .innerJoin(supportTag, eq(conversationTag.tagId, supportTag.id)) + .where(eq(conversationTag.conversationId, conversationId)) + .orderBy(asc(supportTag.name)) + + return createSuccessResponse({ tags }) +}) diff --git a/server/api/support/conversations/[id]/tags/index.post.ts b/server/api/support/conversations/[id]/tags/index.post.ts new file mode 100644 index 00000000..3b1acccb --- /dev/null +++ b/server/api/support/conversations/[id]/tags/index.post.ts @@ -0,0 +1,92 @@ +/** + * @openapi + * /api/support/conversations/{id}/tags: + * post: + * tags: [Support] + * summary: Add a tag to a conversation + * operationId: addSupportConversationTag + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Tag added } + * 400: { description: Tag is not part of this team } + * 403: { description: Not a member of this inbox or a team admin } + * 404: { description: Conversation not found } + * 409: { description: This tag is already on the conversation } + */ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireConversationAccess } from '~/server/utils/support-access' +import { isUniqueViolation } from '~/server/utils/support-errors' +import { publishConversationEvent } from '~/server/utils/support-realtime' +import { validateBody } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { conversationTag, supportTag } from '~/server/database/schema/support' + +const bodySchema = z.object({ + tagId: z.string().min(1), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const conversationId = getRouterParam(event, 'id') as string + const body = await validateBody(event, bodySchema) + + const existing = await requireConversationAccess(conversationId, session.user.id) + + // A foreign key proves the tag exists, not that it belongs to this + // conversation's team - without this check one team could tag another + // team's conversation. + const [tag] = await db.select().from(supportTag).where(eq(supportTag.id, body.tagId)).limit(1) + + if (!tag || tag.teamId !== existing.teamId) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Tag is not part of this team'), + }) + } + + try { + const [created] = await db + .insert(conversationTag) + .values({ + id: randomUUID(), + conversationId, + tagId: body.tagId, + createdAt: new Date(), + }) + .returning() + + // `conversation.updated` rather than a bespoke type - envelopes carry no + // detail and clients refetch, so reusing the type PATCH already emits + // means the UI needs no new handler. + await publishConversationEvent({ + type: 'conversation.updated', + teamId: existing.teamId, + inboxId: existing.inboxId, + conversationId, + }) + + return createSuccessResponse({ tag: created }) + } catch (error) { + // `conversationTag` is uniquely indexed on (conversationId, tagId) - two + // concurrent adds can both pass a pre-check and one still fails, so the + // constraint is the real arbiter, not a SELECT before the insert. + if (isUniqueViolation(error)) { + throw createError({ + statusCode: 409, + statusMessage: 'Conflict', + data: createErrorResponse(ErrorCode.CONFLICT, 'This tag is already on the conversation'), + }) + } + throw error + } +}) diff --git a/server/api/support/tags/[id].delete.ts b/server/api/support/tags/[id].delete.ts new file mode 100644 index 00000000..17f168ca --- /dev/null +++ b/server/api/support/tags/[id].delete.ts @@ -0,0 +1,51 @@ +/** + * @openapi + * /api/support/tags/{id}: + * delete: + * tags: [Support] + * summary: Delete a tag + * description: > + * Hard delete. `conversationTag` rows referencing this tag cascade with + * it, so deleting a tag unassigns it from every conversation it was on. + * operationId: deleteSupportTag + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: { description: Tag deleted } + * 403: { description: Not a member of the tag's team } + * 404: { description: Tag not found } + */ +import { eq } from 'drizzle-orm' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireTeamMembership } from '~/server/utils/support-access' +import { db } from '~/server/database/drizzle' +import { supportTag } from '~/server/database/schema/support' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const tagId = getRouterParam(event, 'id') as string + + const [tag] = await db.select().from(supportTag).where(eq(supportTag.id, tagId)).limit(1) + + if (!tag) { + throw createError({ + statusCode: 404, + statusMessage: 'Not Found', + data: createErrorResponse(ErrorCode.NOT_FOUND, 'Tag not found'), + }) + } + + // Resolve-then-check (rather than a helper like `requireCompanyAccess`, since + // there is no `requireTagAccess`) so a caller cannot delete another team's tag. + await requireTeamMembership(tag.teamId, session.user.id) + + // Cascades to `conversationTag` - see @openapi description above. + await db.delete(supportTag).where(eq(supportTag.id, tagId)) + + return createSuccessResponse({ deleted: true }) +}) diff --git a/server/api/support/tags/index.get.ts b/server/api/support/tags/index.get.ts new file mode 100644 index 00000000..c68308a1 --- /dev/null +++ b/server/api/support/tags/index.get.ts @@ -0,0 +1,45 @@ +/** + * @openapi + * /api/support/tags: + * get: + * tags: [Support] + * summary: List tags for a team + * operationId: listSupportTags + * parameters: + * - in: query + * name: teamId + * required: true + * schema: { type: string } + * responses: + * 200: { description: Tag list } + * 403: { description: Not a member of the team } + */ +import { asc, eq } from 'drizzle-orm' +import { z } from 'zod' +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireTeamMembership } from '~/server/utils/support-access' +import { validateQuery } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { supportTag } from '~/server/database/schema/support' + +const querySchema = z.object({ + teamId: z.string().min(1), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const query = validateQuery(event, querySchema) + + await requireTeamMembership(query.teamId, session.user.id) + + // A team's tag vocabulary is a short controlled list, not a feed - no + // pagination, same reasoning as the inbox address/member list endpoints. + const tags = await db + .select() + .from(supportTag) + .where(eq(supportTag.teamId, query.teamId)) + .orderBy(asc(supportTag.name)) + + return createSuccessResponse({ tags }) +}) diff --git a/server/api/support/tags/index.post.ts b/server/api/support/tags/index.post.ts new file mode 100644 index 00000000..9896dbe1 --- /dev/null +++ b/server/api/support/tags/index.post.ts @@ -0,0 +1,62 @@ +/** + * @openapi + * /api/support/tags: + * post: + * tags: [Support] + * summary: Create a tag + * operationId: createSupportTag + * responses: + * 200: { description: Tag created } + * 403: { description: Not a member of the team } + * 409: { description: A tag with this name already exists in this team } + */ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { createError } from 'h3' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { requireTeamMembership } from '~/server/utils/support-access' +import { isUniqueViolation } from '~/server/utils/support-errors' +import { validateBody } from '~/server/utils/validation' +import { db } from '~/server/database/drizzle' +import { supportTag } from '~/server/database/schema/support' + +const bodySchema = z.object({ + teamId: z.string().min(1), + name: z.string().trim().min(1).max(100), + color: z.string().trim().max(32).optional(), +}) + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const body = await validateBody(event, bodySchema) + + await requireTeamMembership(body.teamId, session.user.id) + + try { + const [created] = await db + .insert(supportTag) + .values({ + id: randomUUID(), + teamId: body.teamId, + name: body.name, + color: body.color ?? null, + createdAt: new Date(), + }) + .returning() + + return createSuccessResponse({ tag: created }) + } catch (error) { + // `supportTag` is uniquely indexed on (teamId, name) - two concurrent + // creates can both pass a pre-check and one still fails, so the + // constraint is the real arbiter, not a SELECT before the insert. + if (isUniqueViolation(error)) { + throw createError({ + statusCode: 409, + statusMessage: 'Conflict', + data: createErrorResponse(ErrorCode.CONFLICT, 'A tag with this name already exists in this team'), + }) + } + throw error + } +}) From bbc2b7d02e359a3a4b4ac927f391ba00c02b7e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:01:10 +0200 Subject: [PATCH 070/334] feat(support): add /support/settings inbox configuration page (SUP-02-14) Inbox general settings (name, signature), agent membership, and the receiving-address list with per-address product mapping - the in-context configuration surface, deliberately not a tab under /settings, since Stages 05-07 add macros, SLA, and automation here. Includes a minimal create-inbox form in the empty state: without it a team with no inbox has a dead page, and the API already supports creation. Everything lives in the single page file rather than components/support/, which belongs to the parallel agent building the three-pane UI. Pickers use native