diff --git a/.agents/CLAUDE.md b/.agents/CLAUDE.md index de46f9e8..3e4be999 100644 --- a/.agents/CLAUDE.md +++ b/.agents/CLAUDE.md @@ -93,7 +93,7 @@ Veerify is a feedback management and verification platform built with **Nuxt 3** ### Prerequisites -- Node.js 18+ +- Node.js 22.12+ - Yarn - Docker (for local PostgreSQL + Mailpit) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..61c80cd2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +node_modules +.output +.data +.nuxt +.nitro +.cache +dist +.git +.github +.vscode +.idea +.fleet +.claude +.agents +.cursor +docs +playwright-report +test-results +tests +*.log +.env +.env.* +!.env.example +.DS_Store +README.md +export-*.csv diff --git a/.env.example b/.env.example index ee514ac8..c796ee7d 100644 --- a/.env.example +++ b/.env.example @@ -28,8 +28,15 @@ APP_DOMAIN=localhost # Dashboard domain (used by login/signup links from public boards) # Defaults to app. when omitted in non-local environments. APP_DASHBOARD_DOMAIN=localhost +# Optional public URL used in outbound CSAT rating links. Set this to the +# customer-facing HTTPS origin in production; it must be an absolute URL. +APP_URL= # Optional public origin; leave empty to fall back to BETTER_AUTH_URL # Optional: "self-hosted" or "cloud". When omitted, auto-detected from platform env vars. APP_DEPLOYMENT_MODE=self-hosted +# Optional, development only. Comma-separated extra Host headers the Vite dev server +# will accept, for reaching `yarn dev` through an HTTPS reverse proxy such as +# Tailscale Serve. Leave empty unless you use one. Example: dev.my-tailnet.ts.net +NUXT_DEV_ALLOWED_HOSTS= # CNAME target for custom domain setup (point users' CNAMEs here) CNAME_TARGET=cname.veerify.io @@ -44,6 +51,46 @@ VERCEL_PROJECT_ID= VERCEL_TEAM_ID= VERCEL_TEAM_SLUG= +# Inbound support email provider. Webhook-only: there is no IMAP polling +# (delta D-29). The [provider] segment of /api/support/inbound/[provider] +# selects the driver per request; this names the deployment's default. +SUPPORT_CHANNEL_PROVIDER=postmark + +# Postmark does NOT sign inbound webhooks - its documented protection is HTTP +# Basic Auth in the webhook URL plus IP allowlisting. Set both, then register +# https://user:password@your-host/api/support/inbound/postmark with Postmark. +# Inbound mail is rejected while these are unset; an empty credential must +# never mean "accept anything". +SUPPORT_POSTMARK_WEBHOOK_USER= +SUPPORT_POSTMARK_WEBHOOK_PASSWORD= + +# Required when using Mailgun. This is the webhook signing key (not the API +# key); Mailgun signs HMAC-SHA256(timestamp + token) with it. +SUPPORT_MAILGUN_SIGNING_KEY= + +# OPTIONAL, Stage 04. Used only to check whether an inbox's From address sits +# on a domain the provider will actually accept, so /support/settings can warn +# before mail silently fails at send time. Leave unset and the check reports +# "cannot verify" rather than a false warning - nothing else is affected. +# +# Postmark: this is the ACCOUNT token, not a server token. The /domains +# endpoint is account-level and a server token returns 401 there. +SUPPORT_POSTMARK_ACCOUNT_TOKEN= +# Non-secret deployment account identifier persisted with outbound deliveries. +# MUST equal the value the provider reports on its own delivery webhooks, or the +# delivery-correlation fallback can never match: for Postmark that is the numeric +# `ServerID`. The primary correlation path uses our own metadata key and works +# regardless, but the fallback is the only defence when a provider drops that +# metadata on an event. Unverified against a live account - see +# docs/plans/2026-08-11-support-platform/stage-01-04-provider-checklist.md. +SUPPORT_POSTMARK_ACCOUNT_KEY= +# Mailgun: the private API key. Set the base URL only for EU accounts +# (https://api.eu.mailgun.net). +SUPPORT_MAILGUN_API_KEY= +# As above: must equal what Mailgun reports on its webhooks, i.e. the sending domain. +SUPPORT_MAILGUN_ACCOUNT_KEY= +SUPPORT_MAILGUN_API_BASE_URL= + # SMTP Configuration for nodemailer SMTP_HOST=localhost @@ -80,7 +127,41 @@ STORAGE_FORCE_PATH_STYLE=false # Optional public base URL for storage object links (for CDN/custom host) STORAGE_PUBLIC_BASE_URL= +# Keep proxy-required unless your S3-compatible target demonstrably enforces +# the signed Content-Length. Accepted value: content-length-enforced. +STORAGE_DIRECT_UPLOAD_CONSTRAINTS=proxy-required # Upload token signing secret (REQUIRED — generate a random secret, e.g. `openssl rand -base64 32`) # The server will refuse to start if this is not set. UPLOAD_TOKEN_SECRET= + +# --- Realtime / Redis ------------------------------------------------------- +# Redis connection string. Written against the Redis wire protocol, so any +# provider works: Upstash on cloud, or the `valkey` service in docker-compose +# when self-hosting. Leave empty to run single-instance with in-memory drivers. +# Example: redis://localhost:6379 or rediss://user:pass@host:6379 +REDIS_URL= + +# Realtime transport driver: `redis` | `memory`. +# Unset infers `redis` when REDIS_URL is set, otherwise `memory`. +# `memory` is single-instance ONLY — events do not cross app instances. +REALTIME_DRIVER= + +# Rate limiter backing store: `redis` | `memory`. +# Same inference rule as REALTIME_DRIVER. `memory` does not enforce limits +# across instances. Shares the REDIS_URL connection; adds no extra socket. +RATE_LIMIT_STORE= + +# --- Scheduled tasks -------------------------------------------------------- +# Shared secret for Vercel Cron HTTP endpoints under /api/cron/*. +# REQUIRED on cloud deployments: the endpoints fail closed, so an unset secret +# means every scheduled task returns 401 and silently never runs. +# Not needed when self-hosting, where Nitro runs tasks in-process. +# Generate with: openssl rand -base64 32 +CRON_SECRET= + +# --- Self-hosted deployment (docker-compose.yml) ----------------------------- +# Public hostname serving uploaded assets through the Caddy reverse proxy. +# Required by docker-compose.yml and the Caddyfile when self-hosting. +# Example: assets.example.com +STORAGE_DOMAIN= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dd0ae9c2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Shell scripts must keep LF line endings regardless of core.autocrlf so +# they run correctly inside Linux containers (a CRLF shebang breaks `/bin/sh`). +*.sh text eol=lf +Caddyfile text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a4532e4..7f14cd18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '22' cache: yarn - run: yarn install --frozen-lockfile @@ -36,7 +36,7 @@ jobs: - uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '22' cache: yarn - run: yarn install --frozen-lockfile @@ -53,7 +53,7 @@ jobs: - uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '22' cache: yarn - run: yarn install --frozen-lockfile @@ -69,15 +69,12 @@ jobs: - uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '22' cache: yarn - run: yarn install --frozen-lockfile - # Run nuxt build directly (not `yarn build`) to skip the - # postbuild lifecycle hook, which runs db migrations and - # requires a live PostgreSQL connection. - - run: npx nuxt build + - run: yarn build e2e: name: E2E (Playwright) @@ -108,13 +105,19 @@ jobs: PGPASSWORD: veerifypassword PGDATABASE: veerifydb BETTER_AUTH_URL: http://localhost:4173 + UPLOAD_TOKEN_SECRET: playwright-e2e-upload-secret + # The OAuth test only validates the generated GitHub authorization URL; + # these placeholder credentials keep the provider enabled without using + # a real GitHub application or secret in CI. + GITHUB_CLIENT_ID: playwright-e2e-github-client + GITHUB_CLIENT_SECRET: playwright-e2e-github-secret PLAYWRIGHT_SKIP_IS_FAILURE: '1' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '22' cache: yarn - run: yarn install --frozen-lockfile @@ -123,7 +126,7 @@ jobs: run: npx playwright install --with-deps chromium - name: Run migrations - run: yarn db:migrate + run: yarn db:migrate:deploy - name: Seed test data run: yarn db:seed:e2e diff --git a/.github/workflows/neon.yml b/.github/workflows/neon.yml index afb029f4..a740ea94 100644 --- a/.github/workflows/neon.yml +++ b/.github/workflows/neon.yml @@ -26,7 +26,7 @@ jobs: name: Create Neon Branch outputs: db_url: ${{ steps.create_neon_branch.outputs.db_url }} - db_url_with_pooler: ${{ steps.create_neon_branch.outputs.db_url_with_pooler }} + db_url_pooled: ${{ steps.create_neon_branch.outputs.db_url_pooled }} needs: setup if: | github.event_name == 'pull_request' && ( @@ -57,14 +57,39 @@ jobs: || github.event.action == 'reopened') runs-on: ubuntu-latest env: - DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} BETTER_AUTH_URL: http://localhost:4173 + UPLOAD_TOKEN_SECRET: playwright-e2e-upload-secret + # The OAuth test only validates the generated GitHub authorization URL; + # these placeholder credentials keep the provider enabled without using + # a real GitHub application or secret in CI. + GITHUB_CLIENT_ID: playwright-e2e-github-client + GITHUB_CLIENT_SECRET: playwright-e2e-github-secret steps: - uses: actions/checkout@v4 + # Job outputs containing connection strings are treated as secrets by + # GitHub and are not reliably forwarded between jobs. Resolve the + # already-created branch again in this job so its pooled URL is available + # to the migration, seed, and Playwright steps without crossing a job + # boundary. + - name: Resolve Neon branch connection + id: resolve_neon_branch + uses: neondatabase/create-branch-action@v6 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch_name: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} + api_key: ${{ secrets.NEON_API_KEY }} + role: neondb_owner + database: neondb + ssl: require + suspend_timeout: 0 + + - name: Export Neon database URL + run: echo "DATABASE_URL=${{ steps.resolve_neon_branch.outputs.db_url_pooled }}" >> "$GITHUB_ENV" + - uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '22' cache: yarn - run: yarn install --frozen-lockfile @@ -103,7 +128,7 @@ jobs: # You may want to do something with the new branch, such as run migrations, run tests # on it, or send the connection details to a hosting platform environment. # The branch DATABASE_URL is available to you via: - # "${{ steps.create_neon_branch.outputs.db_url_with_pooler }}". + # "${{ steps.create_neon_branch.outputs.db_url_pooled }}". # It's important you don't log the DATABASE_URL as output as it contains a username and # password for your database. # For example, you can uncomment the lines below to run a database migration command: @@ -111,7 +136,7 @@ jobs: # run: npm run db:migrate # env: # # to use pooled connection - # DATABASE_URL: "${{ steps.create_neon_branch.outputs.db_url_with_pooler }}" + # DATABASE_URL: "${{ steps.create_neon_branch.outputs.db_url_pooled }}" # # OR to use unpooled connection # # DATABASE_URL: "${{ steps.create_neon_branch.outputs.db_url }}" diff --git a/.gitignore b/.gitignore index 20fb52e1..03337ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,15 @@ test-results .env.* !.env.example .vercel + +# Agent git worktrees (nested checkouts of this repo) +.claude/worktrees/ + +# Sleekplan/CSV import test exports dropped in the repo root by manual importer runs. +# These contain real customer data (names, email addresses) and must never be committed. +export-*.csv + +# Generated SDD review packages: a full `git diff -U10` of a task range, often +# megabytes, and reproducible from history at any time with `review-package`. +# The task reports beside them are hand-written and stay tracked. +.superpowers/sdd/**/review-*.diff diff --git a/.prettierignore b/.prettierignore index 79bee781..0481695e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ node_modules dist .cache server/database/migrations +server/generated/openapi-routes.ts diff --git a/.prettierrc.json b/.prettierrc.json index 3b5eb66a..b7fad275 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -4,5 +4,6 @@ "trailingComma": "es5", "printWidth": 120, "tabWidth": 2, - "bracketSpacing": true + "bracketSpacing": true, + "endOfLine": "auto" } diff --git a/.superpowers/sdd/stage-01-04-hardening-implementation/task-4-report.md b/.superpowers/sdd/stage-01-04-hardening-implementation/task-4-report.md new file mode 100644 index 00000000..6b01c4e8 --- /dev/null +++ b/.superpowers/sdd/stage-01-04-hardening-implementation/task-4-report.md @@ -0,0 +1,266 @@ +# Task 4 implementation report + +## Scope + +Implemented capability-aware support navigation and settings for team admin, +inbox admin, supervisor, agent, and unassigned team members. The UI consumes +server capability payloads, keeps privileged controls hidden for lower roles, +adds accessible labels/focusable controls and purposeful empty/error states, +and recovers from a forbidden deep link without exposing the rejected inbox +name. + +The live permission workflow also exposed and fixed a pre-existing server bug: +the non-admin inbox list used inferred Drizzle join keys, so valid memberships +could be filtered out. The route now uses an explicit `{ inbox, role }` +projection and strict role parsing in a separate non-admin branch. + +## Changed files + +- `pages/support/index.vue` +- `pages/support/settings.vue` +- `components/support/SupportInboxSidebar.vue` +- `server/api/support/inboxes/index.get.ts` +- `tests/support-inbox-list.test.ts` +- `tests/e2e/helpers/selectors.ts` +- `tests/e2e/helpers/support-permissions.ts` +- `tests/e2e/support-conversation-flow.spec.ts` +- `tests/e2e/support-permissions.spec.ts` + +## Browser setup and TDD evidence + +- `npx` prerequisite verified at `C:\nvm4w\nodejs\npx.ps1`, version `11.6.2`. +- Isolated database: `127.0.0.1:5432/veerify_task4_20260825`; migrated and + seeded, and verified distinct from the default `veerifydb` database. +- The required guarded prerequisite ran real Playwright with + `PLAYWRIGHT_FORCE=1` and printed `Running 87 tests using 8 workers`; no + guard skip was accepted as browser proof. +- Focused RED was established with 6 tests discovered: the first assertion + failed at the missing `support-team-policy` locator after fixture setup, + login, and page rendering completed. +- Focused unit RED/GREEN for the join projection was captured during the route + diagnosis; final `tests/support-inbox-list.test.ts` is GREEN at 7/7. +- Fixture creates unique users through public signup, inserts fixed team and + inbox memberships through Drizzle, self-checks every expected membership, + uses fresh browser contexts with programmatic login/session assertions, and + cleans owned IDs in reverse FK order. + +## Validation + +- `yarn typecheck`: passed. +- `yarn test`: passed, 42 files / 457 tests. +- `yarn lint`: passed, 0 errors / 188 warnings (existing repository debt). +- Focused permission Playwright, serial `--workers=1`: passed, 6/6 in 18.7s + against `http://127.0.0.1:4913` and the isolated database. +- Forced broad Playwright prerequisite/stress run: real run, 87 discovered; + 39 passed, 36 failed, 5 skipped, 7 did not run. It is not a pass; failures + are unrelated broad-suite contention/environment failures (including the + parallel permission fixture receiving `Missing or null Origin`). +- Existing `support-conversation-flow.spec.ts`, serial: failed before the + affected workflow at inbox creation (`expect(inboxResponse.ok()).toBeTruthy`, + response body not asserted); this is separate from the 6/6 Task 4 workflow. +- `yarn harness:verify` with explicit isolated `DATABASE_URL` and + `PLAYWRIGHT_FORCE=0`: passed all non-browser gates. E2E explicitly skipped + with the documented local guard reason; Redis passed 1 file / 6 tests; + Postgres passed 6 files / 42 tests. +- The forced harness run did execute Playwright and failed its broad stage, so + the final harness result is recorded as stress evidence rather than a green + release gate. + +## Commits + +## Reviewer hardening round 1 + +Implemented the reviewer follow-up from `025d205`: settings channel status is +queried with the selected `inboxId`; index and settings share generic 403 +recovery; stale inbox-scoped state is cleared during team/inbox switches; and +stale responses are ignored with a monotonic request token. Self-removal now +has explicit loss-of-access confirmation, remove controls name their target, +and unassigned users see only the intentional no-assignment state. The +permission fixture tracks partial ownership, cleans verification and auth rows +in reverse-FK order, and proves owned users/inboxes are gone. Auth requests use +one origin/referer helper and browser tests use one programmatic page login with +session identity assertions. + +The conversation-flow fixture asserts the intended agent receives a 403 and +`FORBIDDEN` body when creating an inbox, creates the inbox through a unique +explicit team-admin setup identity, grants the agent inbox access, and removes +all owned setup rows in `finally`. + +### Round evidence + +- Server/browser target: `http://localhost:4913`, with explicit isolated + database `postgres://veerify:veerifypassword@127.0.0.1:5432/veerify_task4_20260825`. +- Before each focused browser invocation, the guard printed real Playwright + execution (`Running 90 tests using 8 workers`); the broad guard was then + terminated as prerequisite proof and never counted as a focused pass. +- `tests/e2e/support-permissions.spec.ts --workers=1`: 9/9 passed, including + channel query binding, self-removal confirmation, deep-link recovery, + unassigned controls, and deterministic revocation between list/detail. +- `tests/e2e/support-conversation-flow.spec.ts --workers=1`: 1/1 passed. +- `yarn typecheck`: passed. +- `yarn test`: 42 files / 457 tests passed. +- `yarn lint`: passed with 0 errors / 188 warnings (repository warning debt). +- `yarn harness:verify` with explicit isolated `DATABASE_URL` and + `PLAYWRIGHT_FORCE=0`: passed. Its guarded E2E stage explicitly skipped for + the local non-cloud guard; Redis passed 1 file / 6 tests; Postgres passed 6 + files / 42 tests. +- The earlier forced broad 8-worker run remains separate stress evidence and + is not claimed as a pass; the controller recorded 36 failed test IDs. + +- `5e071f8` — `fix(support): preserve joined inbox access` +- `025d205` — `feat(support): reflect inbox permissions in the UI` + +## Reviewer hardening round 2 + +Round 2 closes the remaining stale-context and recovery findings from +`task-4-rereview.md`. The index now carries a monotonic context generation +through team, inbox, member, conversation, contact, and recovery requests. +Settings mutations and status reads carry request/team/inbox snapshots, and +recovery-mode status 403s clear the rejected context through the central +handler. Auth fixtures share one base URL helper, claim unique emails before +signup and recover ambiguous created IDs, and cleanup asserts that owned auth, +membership, contact, conversation, and inbox rows are gone. The self-removal +test uses the non-team-admin inbox-admin identity so its loss-of-access copy is +accurate. + +### Round 2 TDD and validation evidence + +- `npx` prerequisite remains verified at `C:\nvm4w\nodejs\npx.ps1`; the + isolated server returned HTTP 200 for `/login` before browser runs. +- Every focused browser invocation was preceded by the forced guard, which + printed real Playwright execution (`Running 93`/`94 tests using 8 workers`); + no guard skip was accepted as browser proof. +- RED: the recovery-mode status regression first reached the generic access + alert but left the stale settings cards rendered; the first failing + assertion was `support-permissions.spec.ts:289` on the missing + `support-no-assignment` state. GREEN followed by clearing selected inbox + state and rendering the intentional no-assignment card. +- GREEN: permission workflow serial `--workers=1`: 13/13 passed. + This includes delayed old-team index response, team-policy 403 recovery, + recovery-mode status 403, delayed mutation reload after inbox switch, + revocation between list/detail, all five roles, deep links, and self-removal. +- GREEN: `support-conversation-flow.spec.ts --workers=1`: 1/1 passed. +- GREEN: `yarn typecheck`; `yarn test`: 42 files / 457 tests; `yarn lint`: + 0 errors / 188 warnings. +- The broad forced Playwright run remains separate stress evidence and is not + claimed as a pass. The normal explicit-DB harness and Redis/Postgres guard + totals remain those recorded above; guarded local E2E is reported as a skip + when `PLAYWRIGHT_FORCE=0`. + +### Round 2 changed files + +- `pages/support/index.vue` +- `pages/support/settings.vue` +- `tests/e2e/helpers/auth.ts` +- `tests/e2e/helpers/support-permissions.ts` +- `tests/e2e/support-permissions.spec.ts` + +### Round 2 commit + +`2e74a5b` — `fix(support): close stale context recovery races` + +## Reviewer hardening round 3 (final) + +Final review fixes add ownership checks before settings 403 recovery or stale +cleanup, make recovery generation-owned through its `finally`, and refresh +team settings/capabilities after a team-policy 403. Index conversation detail, +messages, contact/timeline/previous-conversation reads, and support mutations +now validate team/inbox/conversation snapshots before success, error, recovery, +and cleanup writes. The settings mutation regression now fulfills a +distinguishable `STALE OLD RESPONSE` payload and asserts it never appears. + +### Round 3 evidence + +- Isolated target remained `postgres://veerify:veerifypassword@127.0.0.1:5432/veerify_task4_20260825`, with the ready worktree server at `http://localhost:4913`. +- Each focused browser invocation was preceded by a forced guard that printed + real Playwright execution (`Running 94 tests using 8 workers`); guard output + was prerequisite evidence only. +- Focused policy-capability regression: 1/1 passed; recovery now receives a + refreshed `canManageTeamSupport: false` payload and the policy card is absent. +- Strengthened stale mutation regression: 1/1 passed with the explicit stale + response assertion. +- Full permission workflow serial `--workers=1`: 13/13 passed. +- Conversation-flow serial `--workers=1`: 1/1 passed. +- Final harness with explicit isolated `DATABASE_URL` and `PLAYWRIGHT_FORCE=0`: + all gates passed; unit 42 files / 457 tests, Redis 1 file / 6 tests, + Postgres 6 files / 42 tests, typecheck passed, lint 0 errors / 188 warnings. + Guarded local E2E explicitly skipped. The prior broad forced parallel stress + result remains separate and is not claimed as a pass. + +## Reviewer hardening round 4 + +This round addresses the three Important findings in the acceptance +re-review. Tag-list 403 recovery now uses the current team/inbox snapshot and +cannot reference an undefined conversation ID. Contact-panel 403 recovery now +requires the conversation snapshot to remain current, so a delayed request +from conversation A cannot clear a newly selected conversation B. Settings +recovery now owns a token, rechecks that token and team before every fallback, +query, selection, form, and context write, and resets recovery ownership when +`initPage` starts a new team/context generation. + +### Round 4 TDD and validation evidence + +- Added three deterministic Playwright regressions to + `tests/e2e/support-permissions.spec.ts`: tag 403 recovery, delayed + contact-panel 403 after conversation switch, and team switch during settings + recovery. +- Initial browser proof was environment-blocked while the dev server warmed. + After restarting with the correct origin, secrets, and isolated database, + the failing ownership test was reproduced: route interception was installed + before the page's mounted initialization finished, so the initial request + received the switched-team fixture and recovery never started for the + original team. The test now waits for the initial policy control before + installing switched-team routes, asserts the switched inbox through the + form value, and proves ownership reset with a second switched-team recovery + list request. +- Focused settings ownership test, serial: 1/1 passed in 5.6s. +- Full `support-permissions.spec.ts`, serial `--workers=1`: 16/16 passed in + 40.8s. +- `support-conversation-flow.spec.ts`, serial `--workers=1`: 1/1 passed in + 3.6s. +- `yarn typecheck`: passed. +- `yarn test`: passed, 42 files / 457 tests. +- `yarn lint`: passed, 0 errors / 188 warnings (existing repository warning + debt). +- Broad forced Playwright stress and the guarded harness remain separate from + this round and are not claimed as broad green evidence. + +### Round 3 commit + +## Reviewer hardening round 5 (final fidelity fix) + +The delayed contact-panel 403 regression now observes both sides of the +race. The test counts inbox-list calls separately from the initial load and +fails if the stale conversation-A response starts any recovery request. It +holds conversation B's contact response until conversation A's delayed 403 +has completed, then releases B and waits for that request to complete before +reasserting B's selected `bg-accent` class. It also asserts that neither the +generic inbox-access alert nor the no-assignment recovery state appears. + +### Round 5 evidence + +- Focused delayed contact-panel regression, serial: 1/1 passed against the + fixed source at `http://localhost:4913`, using isolated database + `veerify_task4_20260825` and matching `BETTER_AUTH_URL`/trusted origin. +- Full permission workflow, serial: 16/16 passed (45.3s). +- Conversation flow, serial: 1/1 passed (4.0s). +- `yarn typecheck`: passed. +- `yarn test`: 42 files / 457 tests passed. +- `yarn lint`: 0 errors / 188 existing warnings. +- `yarn harness:verify`: all gates passed with isolated `DATABASE_URL`; + Redis 1 file / 6 tests and PostgreSQL 6 files / 42 tests passed. Guarded + E2E was intentionally skipped because `PLAYWRIGHT_FORCE=0`; the focused + and serial browser runs above supplied browser evidence. +- A targeted source mutation removing the conversation-current guard was + applied and restored without being staged. The mutation run was blocked by + Nuxt dev-server `/support` navigation aborts during HMR, and a clean + production-build mutation attempt was interrupted while Nitro generated + its server bundle; therefore no mutation RED result is claimed. The final + worktree has the guard unchanged and only the test/report files modified. + +### Round 5 changed files + +- `tests/e2e/support-permissions.spec.ts` +- `.superpowers/sdd/stage-01-04-hardening-implementation/task-4-report.md` + +`8fa5d7c` — `fix(support): guard final stale recovery writes` diff --git a/.superpowers/sdd/stage-01-04-hardening-implementation/task-5-report.md b/.superpowers/sdd/stage-01-04-hardening-implementation/task-5-report.md new file mode 100644 index 00000000..66b0b72d --- /dev/null +++ b/.superpowers/sdd/stage-01-04-hardening-implementation/task-5-report.md @@ -0,0 +1,55 @@ +# Task 5 report: authenticated feedback auto-linking + +## Delivered + +- Added `createAutomaticFeedbackLink` with the approved privacy contract: exact same-team `contact.userId`, active contacts only, two-row ambiguity detection, no email/name/anonymous/cross-team/blocked/merged matching, and conflict-safe insertion. +- Both feedback write routes now keep feedback, auto-vote, and optional link creation in one transaction. Anonymous routes skip the helper; direct anonymous calls return before policy access. +- Contact lifecycle operations use a stable, team-scoped row lock before touching contact-owned state. Create, update, block, delete, merge, explicit link/unlink, inbound resolution, and auto-link paths follow team-before-contact ordering; merge locks multiple team IDs in sorted order. This removes the prior global table lock and its row-lock inversion while preserving cross-team concurrency. +- Auto-link takes the team lock before reading policy. Settings writes take the same lock, so once a disable request commits, an older waiting submission cannot create a later automatic link. Setting changes remain future-write-only and never backfill or delete existing links. +- Concurrent duplicate callers use resulting-state semantics: both resolve to the existing linked contact. +- Contact timelines label automatic links and refetch after unlink so the feedback immediately returns to Possible matches. +- Browser fixtures track ownership, clean up independently in foreign-key-safe order, dispose request contexts, and assert restored policy, role, project, and owned rows. + +## Verification + +- RED: without lifecycle serialization, block/merge/second-contact mutations could leave a stale candidate decision and delete could hit a foreign-key violation. A later policy test also proved an enabled read could link after a disable committed. +- GREEN: the focused real-Postgres suite passes 22/22. It uses backend PIDs, `pg_blocking_pids()`, and `pg_locks` rather than timing guesses; covers both merge acquisition orders, unlink behind auto-link, unlink after merge repoints a link, unrelated-team progress, block/delete/new-ambiguity, policy-disable linearization, exact-one/zero/blocked/merged/ambiguous/cross-team/email-only/anonymous, duplicate callers, preservation on disable, and unlink authorization. +- Focused serial browser previously passed 2/2 against `http://localhost:4913`, isolated `veerify_task5_20260826`, and test-only auth/upload secrets. It proved authenticated auto-linking, email-only/anonymous no-link, the automatic-link label, unlink, and immediate Possible matches reappearance. +- On the final backend-only lock round, a repeat browser run passed the first case but the Nuxt dev server stopped responding to all protected-page navigation before the second case reached its assertions; Postgres was idle and `/login` remained healthy. A fresh dev server reproduced the protected-route render timeout. This runtime limitation is recorded rather than represented as a product assertion failure. +- Unit: 42 files / 458 passed. +- Redis integration: 6/6 passed. +- Postgres integration: 7 files / 64 passed. +- `yarn typecheck`: passed. +- `yarn lint`: 0 errors / 188 warnings (pre-existing repository lint debt; no changed-file warnings). +- `yarn harness:verify`: all validation gates passed. Guarded E2E explicitly skipped because `PLAYWRIGHT_FORCE=1` was not set; the focused serial browser evidence and final runtime limitation are reported separately. +- `yarn build`: client and SSR bundles completed; Nitro final packaging remained CPU-active after 15 minutes and was stopped. Production packaging remains unverified rather than reported as passing. + +## Files changed + +- `server/utils/support-auto-link.ts` +- `server/utils/contact-lock.ts` +- `server/utils/contact-link-transaction.ts` +- `server/utils/contact-merge-transaction.ts` +- `server/utils/inbound-contacts.ts` +- `server/api/feedback/index.post.ts` +- `server/api/public/t/[teamSlug]/[projectSlug]/feedback.post.ts` +- `server/api/support/contacts/index.post.ts` +- `server/api/support/contacts/[id].put.ts` +- `server/api/support/contacts/[id].delete.ts` +- `server/api/support/contacts/[id]/merge.post.ts` +- `server/api/support/contacts/[id]/links.post.ts` +- `server/api/support/teams/[teamId]/settings.put.ts` +- `pages/support/contacts/[id].vue` +- `tests/integration/support-auto-link.test.ts` +- `tests/e2e/support-contact-timeline.spec.ts` +- This report + +## Product gap + +There is still no safe operator workflow for binding a contact to a signed-in Veerify user. Fixtures seed `contact.userId` directly; this task intentionally does not add email-based or UI binding because that would weaken the privacy contract. + +## Commit series + +- `4a97882` (`feat(support): auto-link authenticated feedback`) +- `4ac6380` (`fix(support): close auto-link race windows`) +- The final team-scoped lifecycle fix is included in the next Task 5 commit. diff --git a/.superpowers/sdd/stage-01-04-hardening-implementation/task-6-report.md b/.superpowers/sdd/stage-01-04-hardening-implementation/task-6-report.md new file mode 100644 index 00000000..8d124d45 --- /dev/null +++ b/.superpowers/sdd/stage-01-04-hardening-implementation/task-6-report.md @@ -0,0 +1,33 @@ +# Task 6 report: bounded contact feedback timelines + +## Delivered + +- Added canonical version-1 opaque `(createdAt,id)` list cursors while preserving the existing decoded `{ createdAt: Date; id: string }` shape. Malformed, non-canonical, and unsupported-version cursors return the standard 400 validation error. Legacy cursors are deliberately rejected; pagination is transient, so older clients restart from the first page. +- Added independent `limit` (default 25, maximum 100), `linkedCursor`, and `probableCursor` handling. Both feedback sections query `limit + 1` in descending deterministic order with timestamp/id tie-breaks and independent `hasMore`/`nextCursor` metadata. +- Kept the timeline feedback-only. Linked rows require the requested contact and `entityType = feedback`; probable feedback is team-scoped, matches the existing email or exact user identity policy, and excludes feedback linked to any contact. +- Updated the contact detail page and inbox contact panel with independent rows, first-page/load-more loading and error state, retry affordances, and controls. Per-section request generations reject overlapping stale work without cancelling the other section; a full link/unlink refresh invalidates both. Load-more failures preserve loaded rows. Automatic links retain the `Automatically linked` label. +- Added an optional `section=linked|probable` query so each UI control performs only its section's database work. Updated OpenAPI with its validation errors and concrete link response schema. + +## RED evidence + +- Before implementation, `tests/contact-cursor.test.ts` failed because encoded cursors had no `v: 1` field. +- Before implementation, `tests/support-timeline.test.ts` failed because the timeline helper returned no independent page metadata. +- The first real-Postgres pagination attempt failed on a nondeterministic test fixture tie-breaker; the fixture was corrected to use deterministic link IDs, then the same test passed. + +## GREEN evidence + +- Focused cursor/timeline/route validation after review fixes: 3 files, 23 tests passed. The route tests cover omitted/max/invalid limits, invalid sections, malformed/non-v1 cursors, and one-query linked/probable section requests. +- Focused real Postgres: `tests/integration/support-timeline-pagination.test.ts`, 1 test passed against local Postgres. +- `yarn typecheck`: passed. +- Changed-file ESLint: 0 errors, 0 warnings. +- `yarn harness:verify`: passed against the isolated migrated database; 42 unit files / 471 tests, Redis 6/6, Postgres 8 files / 65 tests, typecheck and lint. The guarded E2E command skipped because `PLAYWRIGHT_FORCE=1` was not set; the focused serial browser run below was forced separately. +- Focused serial Playwright against the warmed isolated runtime at `http://localhost:4913`: 3/3 passed. This proves tenant scoping, authenticated auto-link/unlink, both independent Load more controls with 51 rows per section, preservation and manual retry after both automatic GET attempts return 503, and rejection of a delayed stale page after unlink/reset. + +## Runtime notes + +- Direct SSR navigation to protected routes can lock this Windows Nuxt development runtime after warm-up. The browser spec therefore loads the public login shell and uses the hydrated Nuxt client router, which exercises the same authenticated route middleware and page behavior without the dev-only SSR lock. +- `yarn build` completed the client and SSR application bundles, then failed during Nitro packaging in third-party Scalar/VueUse code: Rollup reported invalid transformed syntax in `node_modules/@vueuse/core/dist/index.js`. The source dependency file is valid on disk. This packaging/toolchain issue is separate from the Task 6 feature and remains to be isolated before release readiness. + +## Files + +See the Task 6 brief's exact scoped file list. No unrelated files were changed. diff --git a/.superpowers/sdd/stage-05a-agent-speed/progress.md b/.superpowers/sdd/stage-05a-agent-speed/progress.md new file mode 100644 index 00000000..3e71e17e --- /dev/null +++ b/.superpowers/sdd/stage-05a-agent-speed/progress.md @@ -0,0 +1,52 @@ +# SDD ledger — plan: docs/plans/2026-08-11-support-platform/stage-05a-agent-speed.md + +Base: `56484fe65f87838497ae97fa50f5b6763e9dc777` + +## Global constraints + +- Integration target is `support-platform`, never `main`. +- Options API only; no Composition API or hand edits under `components/ui/`. +- Use TDD and run `yarn harness:verify` after each sequential integration. +- Schema changes use `yarn db:generate`; migrations are never handwritten. +- UI behavior changes require Playwright coverage. +- Keep Stage 05's deferred scope out: no round-robin, availability, macros, saved views, bulk actions, merge/split, snooze, undo-send, presence, message-body search, or feedback bridge. +- Search must never query `conversationMessage`. +- Keyboard shortcuts are implemented last. + +## Pre-flight dependency and conflict scan + +| Tasks | Producer / consumer or shared surface | Ruling | +| ------------------ | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| 1 and 2 | Assignment and outgoing replies determine handled/unread state | Task 1 establishes ownership behavior; Task 2 consumes it without changing claim semantics. | +| 1 and 3 | Assignment affects fixed-view membership; both touch conversation list/page | Integrate Task 1 first; Task 3 rebases on it and owns navigation/filter presentation. | +| 1 and 8 | E2E must prove auto-claim and note exclusion | Task 1 adds focused coverage; Task 8 supplies the broad acceptance flow. | +| 2 and 3 | Read-state counts attach only to Unassigned and Assigned-to-me views | Task 2 owns unread computation/API; Task 3 owns view navigation and renders returned counts. | +| 2 and 8 | E2E must prove handled-ness across agents | Task 8 consumes Task 2's final API/UI contract. | +| 3 and 6 | View filtering and search share list queries, but search is global | Task 6 explicitly bypasses the selected view while preserving it for when search clears. | +| 3 and 7 | Keyboard navigation operates on the visible fixed-view list | Task 7 consumes final list/view behavior and remains last. | +| 4 and 5 | Draft text/mode and canned insertion share composer state | Task 4 owns persistence keys/lifecycle; Task 5 inserts through the same input state and cursor without altering draft semantics. | +| 4 and 8 | E2E must prove reply/note drafts coexist | Task 8 consumes the storage-key and composer-mode contract from Task 4. | +| 5 and schema | Canned responses add the only planned canned-response table/migration | Schedule after read-state schema work to avoid migration collisions; rebase before generating. | +| 6 and 8 | E2E must find resolved conversations globally | Task 8 consumes Task 6's final search contract. | +| 7 and all UI tasks | Shortcuts touch the settled page/list/composer surfaces | Build last as required, after Tasks 1–6 are integrated. | + +## Progress + +- Task 1 dispatched to `/root/sup_05a_1` from base `56484fe` on branch `agent/SUP-05A-1-claim-assignment`. +- Task 1 review round 1: atomic explicit claim and UI workflow coverage required fixes. +- Task 1: complete — commits `20df161`, `b94c2e7`; task review approved; merged as `c02e2d2`. +- Task 1 integrated verification: `yarn harness:verify` passed with 583 unit, 6 Redis, and 105 Postgres tests; one nested realtime test skipped because `REDIS_URL` was unset. Focused forced Chromium workflows passed 2/2. Broad E2E guard skipped because the environment is not cloud/CI and `PLAYWRIGHT_FORCE` was not set for the harness run. +- Task 2: complete — commits `b411689`, `c9b5f18`, `53e372f`, `d198a00`; task review approved after two fix rounds; merged as `a570a1d`. +- Task 2 integrated verification: `yarn harness:verify` passed with 585 unit, 6 Redis, and 115 Postgres tests. Focused forced read-state Chromium passed 2/2. Broad E2E guard skipped because the environment is local and `PLAYWRIGHT_FORCE` was not set for the harness run. +- Task 3: complete — commits `f8ea7c1`, `5da3d57`; task review approved after one fix round; merged as `b7ecf4b`. +- Task 3 integrated verification: `yarn harness:verify` passed with 585 unit, 6 Redis, and 115 Postgres tests. Focused fixed-view/API-doc/permissions Chromium passed 4/4. Broad E2E guard skipped because the environment is local and `PLAYWRIGHT_FORCE` was not set for the harness run. +- Task 4: complete — commits `8ae0c2f`, `716928c`; task review approved after one fix round; merged as `96faf33`. +- Task 4 integrated verification: `yarn harness:verify` passed with 585 unit, 6 Redis, and 115 Postgres tests. Focused related support Chromium passed 8/8. Broad E2E guard skipped because the environment is local and `PLAYWRIGHT_FORCE` was not set for the harness run. +- Task 5: complete — implementation commit `e85e0ee`; review approved with no Critical or Important findings; merged into `support-platform`. +- Task 5 integrated verification: `yarn harness:verify` passed with 592 unit, 6 Redis, and 115 Postgres tests; lint reported the repository's existing warnings with zero errors. Focused forced Chromium canned-response coverage passed 2/2 with explicit test secrets and seeded Postgres. Broad E2E guard skipped by default because this is a local run without `PLAYWRIGHT_FORCE=1` and explicit PG variables. +- Task 6: complete — implementation commit `ceb8405`; review scope checked for tenant/inbox filtering, exact numeric matching, view bypass, URL/debounce behavior, and no message-table access; merged into `support-platform`. +- Task 6 integrated verification: `REDIS_URL=redis://localhost:6379 yarn harness:verify` passed with 593 unit, 6 Redis, and 115 Postgres tests; lint reported the repository's existing 206 warnings with zero errors. Focused forced Chromium search coverage passed 1/1 against isolated Postgres. Broad E2E guard skipped because this local harness run had no `PLAYWRIGHT_FORCE=1`/explicit PG variables. +- Task 7: complete — implementation `bcf0255`, merged as `c461222`; review fixes `f929fc8` and `372ad4e`, merged as `5161226`. Added support-scoped keyboard shortcuts, composer mode switching, claim/resolve actions, search focus, and a dismissible help overlay with editable-control guards and mutation deduplication. Focused forced Chromium coverage passed 1/1 after the readiness/focus fix and review round. +- Task 7 integrated verification: `REDIS_URL=redis://localhost:6379 yarn harness:verify` passed with 596 unit, 6 Redis, and 115 Postgres tests; lint reported the repository's existing 206 warnings with zero errors. Broad E2E guard skipped because this local harness run had no `PLAYWRIGHT_FORCE=1`/explicit PG variables. +- Task 8: complete — implementation `074e635`, review-strengthening `ca892cb`, merged as `22f85bf`. Added three deterministic acceptance workflows for reply auto-claim versus note exclusion, independent reply/note draft restoration, and resolved-conversation global search from another fixed view with exact URL/input/list hydration after reload. Focused forced Chromium coverage passed 3/3. +- Task 8 integrated verification: `REDIS_URL=redis://localhost:6379 yarn harness:verify` passed with 596 unit, 6 Redis, and 115 Postgres tests; lint reported the repository's existing 206 warnings with zero errors. Broad E2E guard skipped because this local harness run had no `PLAYWRIGHT_FORCE=1`/explicit PG variables. diff --git a/.superpowers/sdd/stage-05a-agent-speed/task-1-brief.md b/.superpowers/sdd/stage-05a-agent-speed/task-1-brief.md new file mode 100644 index 00000000..5545b395 --- /dev/null +++ b/.superpowers/sdd/stage-05a-agent-speed/task-1-brief.md @@ -0,0 +1,22 @@ +# SUP-05A-1 — Claim and assignment + +Implement claim, auto-claim on first `outgoing` reply (notes excluded), unassign, and +assign-to-another-agent, each writing an `activity` message; reopen preserves assignee. + +## Binding behavior + +- Add a Claim button in the conversation header. +- Auto-claim an unassigned conversation to the replying agent on the first outgoing reply. +- Internal notes never claim. +- A reply must not steal a conversation already assigned to another agent. +- Support release back to the unassigned pool and handoff to another agent through a plain dropdown. +- Explicit claim, release, and handoff each write exactly one activity message through the existing + conversation activity path. +- A customer reply that reopens a resolved conversation preserves its assignee. +- Do not add round-robin, availability, presence, snooze, macros, or other deferred Stage 05 scope. + +## Verification + +- Follow TDD with focused unit/integration coverage. +- Update affected Playwright workflow coverage for the user-facing controls. +- Run `yarn harness:verify` and report every guarded skip reason. diff --git a/.superpowers/sdd/stage-05a-agent-speed/task-1-report.md b/.superpowers/sdd/stage-05a-agent-speed/task-1-report.md new file mode 100644 index 00000000..9c2b476c --- /dev/null +++ b/.superpowers/sdd/stage-05a-agent-speed/task-1-report.md @@ -0,0 +1,182 @@ +# SUP-05A-1 implementation report + +## TODO text + +`SUP-05A-1 Implement claim, auto-claim on first outgoing reply (notes excluded), unassign, and assign-to-another-agent, each writing an activity message; reopen preserves assignee` + +## Status and branch + +- Status: DONE +- Branch: `agent/SUP-05A-1-claim-assignment` +- Base: `origin/support-platform` at `56484fe65f87838497ae97fa50f5b6763e9dc777` +- Commit: `20df161292b5bbc12da23ebcd5a7e68f5ae5c837` — `feat(support): add conversation claim assignment` +- Remote: `origin/agent/SUP-05A-1-claim-assignment` points to the same commit. + +## Implementation + +- Added an explicit Claim action for unassigned conversations. It assigns the authenticated user through the existing conversation PATCH route. +- Kept the existing assignee selector as the handoff and unassign control, including a `You` fallback for authenticated team administrators who are not listed as inbox members. +- Auto-claims an unassigned conversation in the same database transaction as the first outgoing reply and its outbox record. The database-level `assignee_user_id IS NULL` condition makes concurrent claims single-winner; only the winner writes one assignment activity. +- Internal notes do not auto-claim, and an outgoing reply never steals a conversation already assigned to another agent. +- Refreshes conversation detail, messages, and list after a post so an auto-claim is immediately visible. +- Reopens a resolved conversation on a strongly threaded inbound reply, records the single status activity, and deliberately omits `assigneeUserId` from the update so ownership is preserved. +- Reused the settled PATCH/activity implementation for direct claim, unassign, and handoff; no schema change or deferred Stage 05A feature was added. + +## Changed files + +- `components/support/SupportConversationThread.vue` +- `pages/support/index.vue` +- `server/api/support/conversations/[id]/messages/index.post.ts` +- `server/api/support/inbound/[provider].post.ts` +- `server/utils/conversation-activity.ts` +- `server/utils/inbound-threading.ts` +- `server/utils/support-attachment-finalization.ts` +- `tests/conversation-activity.test.ts` +- `tests/e2e/support-conversation-flow.spec.ts` +- `tests/e2e/support-permissions.spec.ts` +- `tests/inbound-threading.test.ts` +- `tests/integration/conversation-assignment.test.ts` + +`TODO.md` was not modified. No database schema or generated migration changed. + +## Red/green TDD evidence + +### Assignment transaction + +- RED: `DATABASE_URL=postgresql://veerify:veerifypassword@localhost:5432/veerify_stage05a_baseline yarn test:integration tests/integration/conversation-assignment.test.ts` + - Result: 2 failed / 2 passed. The outgoing reply left `assigneeUserId` null, including in the concurrent-reply case. +- GREEN: the same focused real-Postgres suite after implementing the conditional transactional claim. + - Result: 4/4 passed, covering outgoing auto-claim and exact activity, note exclusion, no stealing, and a concurrent single-claim race. +- Final combined Postgres focus: `DATABASE_URL=postgresql://veerify:veerifypassword@localhost:5432/veerify_sup05a1_e2e yarn test:integration tests/integration/conversation-assignment.test.ts tests/integration/support-attachment-finalization.test.ts` + - Result: 14/14 passed. + +### Reopen ownership + +- RED: `yarn vitest run tests/inbound-threading.test.ts` + - Result: 1 failed / 13 passed because `updatesForInboundReply` did not exist. +- GREEN: `yarn vitest run tests/inbound-threading.test.ts tests/conversation-activity.test.ts` + - Result: 33/33 passed; a resolved inbound thread reopens without an assignee update, and generic reopen retains the same invariant. + +### Claim UI + +- The first browser attempts exposed environment setup issues before reaching the assertion: a missing `UPLOAD_TOKEN_SECRET`, then Nuxt/Vite rejecting a symlinked external `node_modules`. The worktree received local hard-linked dependencies and the required test secrets; neither setup change is tracked. +- The initial role-based button selector matched unrelated row text. It was replaced with the exact `support-thread-claim` test ID before recording UI red/green evidence. +- RED: with the Claim block temporarily removed, the targeted Playwright test failed waiting for `getByTestId('support-thread-claim')`. +- GREEN: after restoring the implemented block, the same target passed 1/1. It verifies the visible Claim action, its PATCH to the authenticated user, the owner selection update, and the button disappearing. +- Final affected browser command against isolated migrated database `veerify_sup05a1_e2e`, serial workers: + - `PLAYWRIGHT_FORCE=1 DATABASE_URL=postgresql://veerify:veerifypassword@localhost:5432/veerify_sup05a1_e2e yarn playwright test tests/e2e/support-permissions.spec.ts --grep "claims an unassigned conversation" tests/e2e/support-conversation-flow.spec.ts --workers=1` + - Result: 2/2 passed in 10.0s. +- The live conversation-flow test also proves: note leaves the ticket unassigned; outgoing reply auto-claims; handoff, unassign, and reclaim each emit the expected one activity; resolving and reopening preserve the owner; a no-op does not append activity. + +## Commands and results + +### Required context and isolated setup + +- Read `AGENTS.md`, `CLAUDE.md`, `.agents/CLAUDE.md`, `docs/plans/2026-08-11-support-platform/stage-05-decisions.md`, `stage-05a-agent-speed.md`, relevant support sections in `design.md`, `deltas.md`, and the SUP-05A-1 entry in `TODO.md`. +- Read the worktree, TDD, and completion-verification skill instructions. +- `yarn harness:context` in the integration checkout: completed; `support-platform` was clean at `56484fe`. +- `git fetch origin`: completed. +- `git worktree add -b agent/SUP-05A-1-claim-assignment /home/dev/code/veerify-support-sup-05a-1 origin/support-platform`: completed from the required remote base. +- `yarn install`: reported current but did not materialize a usable local dependency tree in the worktree. A temporary symlink was rejected by Vite, so it was removed and dependencies were hard-linked locally with `cp -al`; `yarn nuxt prepare` then completed. These are ignored environment files only. +- Baseline `yarn test`: 51 files / 582 tests passed. +- Created isolated database `veerify_sup05a1_e2e`, then ran project migrations and seed successfully for browser and final Postgres verification. The default local database was not used because its support tables were stale/unmigrated; this matches the controller's baseline note. + +### Formatting and focused checks + +- Ran Prettier on every changed file, and reran it on the final three touched files: passed. +- `git diff --check`: passed. +- `yarn typecheck`: passed. +- Focused ESLint initially reported one unused test import; the import was removed. Final lint through the harness passed. +- Final `yarn test`: 51 files / 583 tests passed. +- Final focused unit tests: 33/33 passed. +- Final focused assignment and attachment-finalization Postgres tests: 14/14 passed. +- Final focused Playwright workflows: 2/2 passed in 10.0s. + +### Full validation gate + +- `DATABASE_URL=postgresql://veerify:veerifypassword@localhost:5432/veerify_sup05a1_e2e yarn harness:verify` + - Exit 0; all harness gates passed. + - Context/docs map: passed. + - Typecheck: passed. + - Unit: passed (51 files / 583 tests, confirmed by the standalone final run). + - Lint: passed. + - Guarded E2E: skipped with the exact guard output: `[playwright] Skipping e2e run: not running in cloud/CI and PLAYWRIGHT_FORCE is not set to 1.` and `[playwright] Runs require cloud/CI or PLAYWRIGHT_FORCE=1 and a reachable configured database.` Focused forced Playwright coverage passed separately as recorded above. + - Redis integration: 1 file / 6 tests passed. + - Postgres integration: 11 files / 104 tests passed; 1 file / 1 test skipped. Exact nested skip reason: `[realtime-two-process] Skipping: REDIS_URL is not set.` The separately guarded Redis suite passed. + +### Git completion + +- `git add` was restricted to the 12 implementation/test paths listed above. +- `git diff --cached --check`: passed. +- `git commit -m "feat(support): add conversation claim assignment"`: created `20df161292b5bbc12da23ebcd5a7e68f5ae5c837`. +- `git push -u origin agent/SUP-05A-1-claim-assignment`: passed and configured the upstream. +- Final `git status --short --branch`: clean, tracking the pushed branch. +- Local and remote branch SHAs both resolve to `20df161292b5bbc12da23ebcd5a7e68f5ae5c837`. + +## Assumptions and design choices + +- "First outgoing reply" means the first outgoing reply while the conversation is unassigned. If it already has an owner, replies preserve that owner; if it becomes unassigned later, the next outgoing reply claims it. +- The existing authenticated PATCH route remains the canonical path for explicit claim, handoff, and unassign, including its single exact activity behavior. +- Strong inbound threading is the only route allowed to reopen a resolved conversation. Weak subject fallback still excludes resolved tickets as already settled. +- The inbound reopen activity has no human actor because the state transition is triggered by customer mail; assignment is unchanged. + +## Blockers and concerns + +- Blockers: none. +- Concerns: none. The only skips are the documented local Playwright guard in the full harness and the nested two-process realtime integration test's missing `REDIS_URL`; focused browser coverage ran and the dedicated Redis guard passed. + +## Fix round 1 — explicit-claim atomicity and browser workflow coverage + +### Status and commits + +- Status: DONE. +- Original implementation: `20df161292b5bbc12da23ebcd5a7e68f5ae5c837` — `feat(support): add conversation claim assignment`. +- Fix commit: `b94c2e71e988c8abaa09a8f307f5ba6d8567135b` — `fix(support): make explicit claims atomic`. +- Branch: `agent/SUP-05A-1-claim-assignment`. +- Final local HEAD, upstream, and `origin/agent/SUP-05A-1-claim-assignment` all resolve to `b94c2e71e988c8abaa09a8f307f5ba6d8567135b`. + +### RED evidence from review + +- Explicit Claim was not atomic at `20df161`: `git show 20df161:components/support/SupportConversationThread.vue` showed the Claim button calling the generic `PATCH` path with `@click="onUpdate('assigneeUserId', currentUserId)"`. Inspection of `server/api/support/conversations/[id].patch.ts` showed that route deriving `changes` from a pre-transaction snapshot and then updating by conversation ID alone. Two agents could therefore both observe `null`, both update, overwrite the owner, and each write an assignment activity. The real-Postgres suite had concurrent outgoing-reply coverage but no concurrent explicit-claim case. +- The Playwright workflow was API-only at `20df161`: `git show 20df161:tests/e2e/support-conversation-flow.spec.ts` contained the explicit comment `Deliberately API-level` and performed note, outgoing reply, handoff, unassign, and reclaim with `request.post`/`request.patch`. `support-permissions.spec.ts` mocked only a successful generic PATCH. It did not prove that the composer refreshes the displayed owner or that the actual assignee dropdown performs handoff and release. + +### GREEN implementation and evidence + +- Explicit Claim now uses `POST /api/support/conversations/[id]/claim`. `claimConversationForAgent` performs `UPDATE ... WHERE id = ? AND assignee_user_id IS NULL RETURNING` and writes the assignment activity inside the same Postgres transaction. A losing caller cannot overwrite the winner and returns the committed current owner with `claimed: false`; only the winning conditional update records activity. +- The new real-Postgres test starts two `claimConversationForAgent` calls with `Promise.all` and asserts one winner, one loser, the same final owner in both results, and one matching activity. +- The browser workflow now logs in and uses the visible note/reply composer controls, observes the refreshed assignee after the outgoing reply, uses the real assignee `` in the affected area; I added `` around that existing file input so formatting does not introduce a local lint warning. +- Browser storage access is best-effort and guarded with `import.meta.client` plus `try/catch`; if storage is unavailable, drafts do not crash the support UI and row indicators simply do not persist. diff --git a/.superpowers/sdd/stage-05a-agent-speed/task-5-brief.md b/.superpowers/sdd/stage-05a-agent-speed/task-5-brief.md new file mode 100644 index 00000000..55dc2a1a --- /dev/null +++ b/.superpowers/sdd/stage-05a-agent-speed/task-5-brief.md @@ -0,0 +1,13 @@ +# SUP-05A-5 implementation brief + +Implement the Stage 05A canned-response MVP on `support-platform`. + +Scope: + +- Add the team-scoped `cannedResponse` table with `id`, `teamId`, `shortcode`, `title`, `body`, `createdByUserId`, timestamps, and a unique `(teamId, shortcode)` constraint. Do not add `inboxId`. +- Add authenticated CRUD routes and settings UI. Any agent on the team can create and edit; enforce tenant scoping and input validation. +- Add `/shortcode` insertion in the reply/note composer at the cursor, preserving text on both sides of the cursor. +- Substitute only `{{contact.name}}` and `{{agent.name}}` from the active conversation/current agent. Do not implement macros, deferred variables, or server-side expansion beyond this requirement. +- Preserve existing drafts, read state, fixed views, and Options API conventions. + +Required evidence: focused unit/API/UI tests, generated migration via the repository DB tooling, and a report at `task-5-report.md`. Do not modify `TODO.md` or the progress ledger from the worker branch. diff --git a/.superpowers/sdd/stage-05a-agent-speed/task-5-report.md b/.superpowers/sdd/stage-05a-agent-speed/task-5-report.md new file mode 100644 index 00000000..224c0450 --- /dev/null +++ b/.superpowers/sdd/stage-05a-agent-speed/task-5-report.md @@ -0,0 +1,22 @@ +# SUP-05A-5 implementation report + +## Scope delivered + +- Added the generated team-scoped `canned_response` table and migration `0032_brave_wolf_cub`. +- Added authenticated team-scoped list/create/update/delete routes with unique `(teamId, shortcode)` handling and validation. +- Added settings CRUD UI for team canned responses; ordinary team membership is sufficient, with no support-role gate. +- Added composer insertion in both reply and note modes. Insertion preserves surrounding draft text, replaces an active `/shortcode` token when present, and substitutes only `{{contact.name}}` and `{{agent.name}}`. +- Preserved local draft persistence and the existing read-state/fixed-view flows. + +## Validation + +- `yarn test --run`: 54 files, 591 tests passed. +- `yarn test tests/support-canned-response-helpers.test.ts tests/support-canned-responses.test.ts --run`: 7 tests passed. +- `yarn typecheck`: passed. +- `yarn lint`: 0 errors; repository's existing warnings remain. +- `yarn db:generate`: no schema changes after the generated migration was present. +- Forced browser coverage was attempted while the isolated runner lacked `UPLOAD_TOKEN_SECRET`; the app returned its expected startup configuration error. The guarded browser run is to be executed on the integrated branch with the standard test secrets/database setup. + +## Commit + +- `e85e0ee feat(support): add team canned responses` diff --git a/.superpowers/sdd/stage-05a-agent-speed/task-6-brief.md b/.superpowers/sdd/stage-05a-agent-speed/task-6-brief.md new file mode 100644 index 00000000..14dfdc48 --- /dev/null +++ b/.superpowers/sdd/stage-05a-agent-speed/task-6-brief.md @@ -0,0 +1,14 @@ +# SUP-05A-6 implementation brief + +Implement global scoped search for the Stage 05A support inbox. + +Requirements: + +- Search conversations by `displayId`, subject, and contact name/email. +- Subject/contact fields use substring matching; a bare numeric query additionally matches `displayId` exactly (do not make numeric substring matching ambiguous). +- Search must be global across the current inbox's fixed views: it must bypass the selected Unassigned/Assigned-to-me/Resolved/All view filter while retaining inbox access/tenant scoping. +- Never query `conversationMessage`, use no message-body search, and do not introduce full-text/macro/deferred scope. +- Add the search control to the support UI with debounced/robust loading, preserve existing view/deep-link behavior when search clears, and use Options API conventions. +- Add focused API/unit and forced Playwright coverage, including finding a resolved conversation from Unassigned by contact email and bare ticket number. + +Use generated schema tooling only if schema changes are truly needed (none should be needed), preserve drafts/read state/fixed views/canned responses, do not edit `TODO.md` or the progress ledger from the worker branch, and write the canonical report to `task-6-report.md`. diff --git a/.superpowers/sdd/stage-05a-agent-speed/task-6-report.md b/.superpowers/sdd/stage-05a-agent-speed/task-6-report.md new file mode 100644 index 00000000..bea0e707 --- /dev/null +++ b/.superpowers/sdd/stage-05a-agent-speed/task-6-report.md @@ -0,0 +1,38 @@ +# SUP-05A-6 Report — Scoped Search + +Branch: `agent/SUP-05A-6-search` + +Base: `origin/support-platform` at `3943c07` + +## Summary + +- Added `search` to `GET /api/support/conversations`, scoped by `inboxId` and authorized via the existing `requireInboxAccess` boundary. +- Search matches conversation `subject`, contact `name`, contact `email`, and bare numeric `displayId` exactly. +- Non-empty search bypasses the fixed `view` filter so resolved or assigned tickets can be found from `Unassigned`; explicit legacy filters such as `status`, `assigneeUserId`, `contactId`, `tagId`, and `projectId` still apply. +- Search joins `contact` only and never queries `conversationMessage`. +- Added a compact conversation-list search box, debounced loading, `search` URL query hydration, clear-search behavior, and preservation of selected conversation deep links. +- Added focused unit and Playwright coverage for global search from `Unassigned`, contact search, exact ticket-number search, clear behavior, URL hydration, inbox scoping, and no message-table query access. + +## Files Changed + +- `server/api/support/conversations/index.get.ts` +- `components/support/SupportConversationList.vue` +- `pages/support/index.vue` +- `tests/conversation-read-state.test.ts` +- `tests/e2e/support-search.spec.ts` + +## Validation + +- `yarn test tests/conversation-read-state.test.ts` — passed, 3/3 tests. +- `yarn typecheck` — passed. +- `yarn lint` — passed with the existing repo warning set, 0 errors / 206 warnings. +- `PGHOST=localhost PGPORT=5432 PGUSER=veerify PGPASSWORD=veerifypassword PGDATABASE=veerify_sup_05a_6_search BETTER_AUTH_SECRET=... UPLOAD_TOKEN_SECRET=... PLAYWRIGHT_FORCE=1 PLAYWRIGHT_PORT=4998 yarn test:e2e tests/e2e/support-search.spec.ts` — passed, 1/1 Chromium test. +- `PGHOST=localhost PGPORT=5432 PGUSER=veerify PGPASSWORD=veerifypassword PGDATABASE=veerify_sup_05a_6_search BETTER_AUTH_SECRET=... UPLOAD_TOKEN_SECRET=... yarn test` — passed, 593/593 tests. +- `PGHOST=localhost PGPORT=5432 PGUSER=veerify PGPASSWORD=veerifypassword PGDATABASE=veerify_sup_05a_6_search BETTER_AUTH_SECRET=... UPLOAD_TOKEN_SECRET=... yarn test:e2e:if-available` — guard-skipped: `not running in cloud/CI and PLAYWRIGHT_FORCE is not set to 1`. + +## Notes + +- Created and seeded isolated local database `veerify_sup_05a_6_search` for focused Playwright validation. +- The first focused Playwright attempt without explicit secrets exited unsuccessfully while booting the dev server; the rerun with explicit test env passed. +- No schema changes were needed. +- `TODO.md` and the Stage 05A progress ledger were not edited by this worker. diff --git a/.superpowers/sdd/stage-05a-agent-speed/task-7-brief.md b/.superpowers/sdd/stage-05a-agent-speed/task-7-brief.md new file mode 100644 index 00000000..a7424f1f --- /dev/null +++ b/.superpowers/sdd/stage-05a-agent-speed/task-7-brief.md @@ -0,0 +1,17 @@ +# SUP-05A-7 implementation brief + +Implement the Stage 05A keyboard shortcuts on `/support`: + +- `j` / `k`: move selection through the currently visible conversation list. +- `r`: switch the composer to reply mode; `n`: switch to internal-note mode. +- `c`: claim the selected conversation to the current agent. +- `e`: resolve the selected conversation. +- `/`: focus the conversation search field. +- `?`: toggle a concise help overlay listing the shortcuts. + +Scope keyboard handling strictly to the support page. Never swallow shortcuts while an input, textarea, +select, contenteditable, or other form control is focused. Preserve search/deep links, drafts, read state, +fixed views, and canned-response behavior. Use Options API conventions; add focused tests and forced +Playwright coverage for navigation, composer mode, claim/resolve, search focus, help overlay, and input +focus guard. Do not implement unrelated shortcuts or deferred Stage 05 features. Do not edit TODO.md or +progress.md from the worker branch; write the canonical report to `task-7-report.md`. diff --git a/.superpowers/sdd/stage-05a-agent-speed/task-8-brief.md b/.superpowers/sdd/stage-05a-agent-speed/task-8-brief.md new file mode 100644 index 00000000..5bedc20d --- /dev/null +++ b/.superpowers/sdd/stage-05a-agent-speed/task-8-brief.md @@ -0,0 +1,12 @@ +# SUP-05A-8 implementation brief + +Add focused Playwright acceptance coverage for the final Stage 05A cross-feature workflows: + +- An outgoing reply auto-claims an unassigned conversation, while an internal note does not claim it. +- Reply and internal-note drafts restore their own text and mode independently for the same conversation. +- A resolved conversation can be found from another fixed view through global search and remains deep-linkable. + +Use the existing seeded auth/database helpers and current support UI/API contracts. Keep the spec deterministic, +clean up all created rows in `finally`, and do not change production behavior unless a test exposes a real Stage 05A +defect. Add only focused Playwright coverage (plus minimal test helpers if required); do not edit `TODO.md` or +`progress.md` on the worker branch. Write the canonical worker report to `task-8-report.md`. diff --git a/.superpowers/sdd/stage-05a-agent-speed/task-8-report.md b/.superpowers/sdd/stage-05a-agent-speed/task-8-report.md new file mode 100644 index 00000000..186ebb36 --- /dev/null +++ b/.superpowers/sdd/stage-05a-agent-speed/task-8-report.md @@ -0,0 +1,38 @@ +# SUP-05A-8 worker report + +## Scope + +Added `tests/e2e/support-stage-05a-acceptance.spec.ts` with three deterministic +Playwright workflows covering the final Stage 05A cross-feature contract: + +1. An internal note leaves an unassigned conversation unassigned, while a + customer-visible reply claims it for the replying agent. +2. Reply and internal-note drafts are restored independently after leaving and + returning to the same conversation. +3. Global search from the Assigned-to-me fixed view finds a resolved + conversation excluded from that view, and the selected conversation remains + deep-linkable after a reload. + +The claim workflow also checks persisted ownership and the exact message-kind +sequence from an initially empty thread: `note`, then `outgoing`, `activity`. +The search workflow reasserts the exact query, fixed-view hydration, input +value, target row, and excluded non-target row after reload. + +Each test creates unique inbox/contact/conversation rows through the E2E DB +helper and removes them in `finally` blocks. No production code, `TODO.md`, or +`progress.md` was changed. + +## Validation + +- Focused Chromium E2E: **3 passed** + - `yarn test:e2e tests/e2e/support-stage-05a-acceptance.spec.ts --project=chromium` + - Local run supplied explicit PostgreSQL, auth/upload secret, and port env vars. +- `yarn typecheck`: **passed** +- `yarn lint`: **passed** — 0 errors, 206 pre-existing warnings. + +## Notes + +The reply acceptance path queues the normal outbound delivery worker; local +SMTP is unavailable, so the worker logs its expected connection failure after +the message/claim transaction succeeds. The focused assertions passed before +cleanup. diff --git a/.superpowers/sdd/stage-05b-feedback-bridge/task-report.md b/.superpowers/sdd/stage-05b-feedback-bridge/task-report.md new file mode 100644 index 00000000..2f72e4c7 --- /dev/null +++ b/.superpowers/sdd/stage-05b-feedback-bridge/task-report.md @@ -0,0 +1,37 @@ +# Stage 05B — Feedback bridge implementation report + +## Status + +- Status: DONE +- Branch: `support-platform` +- Scope: conversation-to-feedback conversion, existing-feedback linking, shipped notifications, team-only link visibility, and public privacy coverage. + +## Implementation + +- Added `POST /api/support/conversations/[id]/feedback`, which locks the conversation and atomically creates agent-authored feedback, the feedback `contactLink`, `linkedFeedbackId`, and a private activity message. +- Added secured feedback search (`GET`) and existing-link mutation (`PUT`) for support agents; both enforce the conversation's team boundary and transactionally record the contact link/activity. +- Added the support bridge dialog with subject/message prefills, product/category selection, and create/link modes. +- Added linked feedback status/vote display in the thread and a team-only linked-conversation count on feedback detail. +- Added completion-only contact notifications with contact-id deduplication, using the existing status email and in-app notification dispatchers. +- Marked support-derived feedback as internal-source and sanitized anonymous/public body and author fields to prevent ticket/contact leakage. + +## Validation + +- `yarn harness:verify` — passed: 603 unit tests, typecheck, format, lint (0 errors / 206 existing warnings), Redis integration 6/6, Postgres integration 114 passed / 1 skipped. +- Guarded E2E was skipped locally because cloud/CI mode, a configured database, and `PLAYWRIGHT_FORCE=1` were not present. +- Playwright test listing for `tests/e2e/support-feedback-bridge.spec.ts` parsed successfully; it covers conversion, persistence, and anonymous public sanitization when the guarded environment is available. + +## Changed files + +- `server/api/support/conversations/[id]/feedback.{get,post,put}.ts` +- `server/api/support/conversations/[id].get.ts` +- `server/utils/feedback-support-notifications.ts` +- `server/api/feedback/[id]/{index.get,status.patch}.ts` +- `server/api/public/t/[teamSlug]/[projectSlug]/feedback.get.ts` +- `components/support/SupportConversation{Thread,FeedbackDialog}.vue` +- `pages/support/index.vue` +- `pages/feedback/[id]/index.vue` +- `tests/feedback-support-notifications.test.ts` +- `tests/support-route-authorization.test.ts` +- `tests/e2e/support-feedback-bridge.spec.ts` +- `server/generated/openapi-routes.ts` diff --git a/.superpowers/sdd/stage-06-sla/task-report.md b/.superpowers/sdd/stage-06-sla/task-report.md new file mode 100644 index 00000000..f242d47e --- /dev/null +++ b/.superpowers/sdd/stage-06-sla/task-report.md @@ -0,0 +1,22 @@ +# Stage 06 — Business hours + SLA implementation report + +## Status + +- Status: DONE +- Branch: `support-platform` +- Migration: `0033_melodic_garia.sql` + +## Implementation + +- Added team-scoped business-hours, policy, target, and per-metric breach tables plus conversation SLA columns. +- Added pure timezone/DST/holiday/midnight-window arithmetic with policy matching, priority fallback, and pause/resume deadline helpers. +- Assigned policies on manual and inbound conversation creation; re-evaluated on priority/tag changes and populated next-response deadlines after the first agent reply. +- Added pending pause/resume handling, idempotent five-minute breach sweeping, private activity entries, assignee/supervisor notifications, and priority escalation. +- Added team-admin SLA settings endpoints and an editor for schedules, holidays, targets, and escalation controls. +- Added live countdown badges, the breaching-soon saved view, and Playwright coverage for the new UI surfaces. + +## Validation + +- `REDIS_URL=redis://localhost:6379 yarn harness:verify` — passed: 617 unit tests, typecheck, format, lint (0 errors / 207 existing warnings), Redis 6/6, Postgres 114 passed / 1 skipped. +- Guarded E2E skipped locally because cloud/CI mode, `PLAYWRIGHT_FORCE=1`, and a configured database were not present. +- `yarn db:migrate` applied `0033_melodic_garia` before the database-backed verification pass. diff --git a/.superpowers/sdd/stage-07-automation/task-report.md b/.superpowers/sdd/stage-07-automation/task-report.md new file mode 100644 index 00000000..afcdbb45 --- /dev/null +++ b/.superpowers/sdd/stage-07-automation/task-report.md @@ -0,0 +1,36 @@ +# Stage 07 — Automation rules implementation report + +## Status + +- Status: IN PROGRESS +- Branch: `support-platform` +- Migration: `0034_steady_the_watchers.sql` + +## This slice + +- Added team-scoped `automationRule` and `automationRuleRun` tables with trigger/status checks, + ordering indexes, and audit fields. +- Added a pure condition evaluator with an extensible field registry, scalar/text/numeric operators, + `all`/`any` groups, and one permitted nesting level. +- Added an ordered action executor with injectable handlers, per-action failure isolation, and helpers + for collecting applied and failed action records. +- Added a persistence-backed engine that loads enabled rules in `sortOrder`, evaluates conversation + and latest-message context, audits applied/skipped/failed runs, and triggers after committed manual, + inbound, and message writes. +- Added dry-run reports with no writes or handler calls, plus a default three-level cascade guard that + records truncation in the run audit. +- Added a five-minute time-based sweep with Nitro task, Vercel cron endpoint, and active-conversation + filtering. +- Added unit coverage for matching, grouping, depth limits, custom fields, and empty groups. +- Added authenticated rule CRUD, run-history, and dry-run endpoints with team ownership checks. +- Added an admin-only settings control room with enable/disable, ordering, grouped condition/action + builders, run history, and dry-run preview. + +## Remaining Stage 07 work + +- None for the Stage 07 scope. + +## Validation + +- Full harness verification is run after each committed slice; the latest pass is recorded in the + handoff update. diff --git a/.superpowers/sdd/stage-08-csat/task-report.md b/.superpowers/sdd/stage-08-csat/task-report.md new file mode 100644 index 00000000..01ca084f --- /dev/null +++ b/.superpowers/sdd/stage-08-csat/task-report.md @@ -0,0 +1,26 @@ +# Stage 08 — CSAT implementation report + +## Completed in this slice + +- Added team/inbox-scoped `csatSurvey` configuration and one-response-per-conversation `csatResponse` + persistence, including token, cooldown, rating, and response timestamps. +- Added the bounded five-minute scheduler pass with resolve/close triggers, delay handling, agent-reply + guard, duplicate guard, per-contact cooldown, and contact opt-out guard. +- Queued survey messages through the existing durable outbound-delivery outbox with tokenized rating + links for the configured scale. +- Added pure email/rating helper tests and a real-Postgres dispatch integration test. +- Added the unauthenticated token read/submit API with rate limiting, single-use ratings, a seven-day + follow-up window, and timeline activity messages. +- Added the mobile-first public `/csat/:token` response page and Playwright coverage for rating plus + follow-up submission. +- Added authenticated team-admin survey CRUD and a per-inbox configuration card in Support settings. + +## Remaining Stage 08 work + +Stage 08 implementation work is complete. Stage 09 owns the longer-lived reporting rollups and +dashboard surfaces; this stage now exposes the live CSAT summary needed to feed them. + +## Validation + +- Focused unit and Postgres integration tests pass. +- Full harness verification passes for the complete Stage 08 slice. diff --git a/.superpowers/sdd/sup-x-3-openapi-scanner/task-brief.md b/.superpowers/sdd/sup-x-3-openapi-scanner/task-brief.md new file mode 100644 index 00000000..16bed295 --- /dev/null +++ b/.superpowers/sdd/sup-x-3-openapi-scanner/task-brief.md @@ -0,0 +1,19 @@ +# SUP-X-3 implementation brief + +Replace the hand-maintained OpenAPI `paths` duplication with a build-time scanner for route files that carry +`@openapi` JSDoc blocks. Parse the existing standard Swagger/OpenAPI YAML comment blocks with a direct `js-yaml` +dependency, merge them into the served OpenAPI 3 document, and ensure the generated spec is available in the +production build output without scanning source files at request time. + +Requirements: + +- Preserve the existing top-level metadata, tags, security schemes, and shared schemas unless the scanner needs a + compatible merge. +- Cover all current `@openapi` route files, including auth, GitHub, orgs, system, teams, cron, and support routes. +- Resolve Nitro/Nuxt build output paths deterministically; production runtime must not depend on source `.ts` files. +- Remove or stop relying on the duplicated hand-written route paths, while retaining equivalent served output. +- Add focused unit/build/API-doc tests and update package metadata/lockfile through the package manager. +- Run typecheck, unit tests, lint, format checks as applicable, and the harness gate. + +Do not edit `TODO.md` or the progress ledger on the worker branch. Write the canonical report to +`.superpowers/sdd/sup-x-3-openapi-scanner/task-report.md`. diff --git a/.superpowers/sdd/sup-x-3-openapi-scanner/task-report.md b/.superpowers/sdd/sup-x-3-openapi-scanner/task-report.md new file mode 100644 index 00000000..894c91bd --- /dev/null +++ b/.superpowers/sdd/sup-x-3-openapi-scanner/task-report.md @@ -0,0 +1,39 @@ +# SUP-X-3 implementation report + +## Outcome + +Implemented a build-time OpenAPI route scanner and replaced the hand-maintained route-path block in +`server/api/openapi.json.get.ts` with a generated TypeScript artifact import. + +The scanner found all 68 current route files carrying `@openapi` JSDoc. Those files merge into 46 path +entries and 68 operations, including auth, GitHub, organization, system, cron, team, and support routes. +Numeric response status keys are preserved as JSON string keys as required by OpenAPI. + +## Implementation + +- `scripts/openapi-scanner.ts` recursively scans `server/api/**/*.ts`, extracts standard swagger-jsdoc YAML + blocks, validates path items, merges methods deterministically, rejects conflicting operations, and emits + `server/generated/openapi-routes.ts`. +- `yarn openapi:generate` is invoked explicitly before `nuxt build`, `nuxt generate`, and `vercel-build`. +- The served endpoint retains its existing metadata, tags, security scheme, and shared schemas, while using + the generated path map. No request-time filesystem or source-route scan remains. +- `js-yaml` is a direct runtime dependency and `@types/js-yaml` is a development dependency. +- Focused scanner tests cover YAML extraction, deterministic method merging, duplicate-operation rejection, + complete current route coverage, numeric response keys, generated-artifact parity, and the endpoint's lack + of source filesystem reads. + +## Validation + +- `yarn openapi:generate` — passed; 46 paths / 68 operations generated. +- `yarn test --run` — passed; 599 tests across 56 files. +- `yarn typecheck` — passed. +- Focused Prettier check for changed source/test files — passed. The generated TypeScript artifact is intentionally + ignored by Prettier because it is derived output; repository-wide format check still reports pre-existing violations. +- `yarn lint` — passed with 0 errors and 206 existing warnings. +- `yarn build` — passed; Nitro production bundle built successfully after running the generator. +- Built runtime smoke check — `/api/openapi.json` returned OpenAPI 3 with 46 paths / 68 operations and retained + shared schemas and support search metadata. + +## Blockers + +None. diff --git a/.superpowers/sdd/sup-x-6-format-gate/task-brief.md b/.superpowers/sdd/sup-x-6-format-gate/task-brief.md new file mode 100644 index 00000000..658b6063 --- /dev/null +++ b/.superpowers/sdd/sup-x-6-format-gate/task-brief.md @@ -0,0 +1,17 @@ +# SUP-X-6 implementation brief + +Make formatting a real cross-platform validation gate. The repository currently has `yarn format:check` in CI but +not in `yarn harness:verify`, and Prettier's default line-ending behavior creates false Windows failures when +`core.autocrlf=true`. + +Requirements: + +- Configure Prettier with an explicit cross-platform line-ending policy (`endOfLine: auto` or an equivalent policy + justified by tests). +- Add `format:check` to `scripts/harness-verify.mjs` with clear output and failure handling. +- Add/update focused tests or harness assertions for the new gate where practical; preserve the existing CI command. +- Verify repo-wide format check, typecheck, unit tests, lint, guarded integrations, and the harness on the integration + branch. Do not mass-reformat unrelated files unless the chosen policy requires it and the diff is reviewed. + +Do not edit `TODO.md` or the progress ledger on the worker branch. Write the canonical report to +`.superpowers/sdd/sup-x-6-format-gate/task-report.md`. diff --git a/.superpowers/sdd/sup-x-6-format-gate/task-report.md b/.superpowers/sdd/sup-x-6-format-gate/task-report.md new file mode 100644 index 00000000..47a1f580 --- /dev/null +++ b/.superpowers/sdd/sup-x-6-format-gate/task-report.md @@ -0,0 +1,57 @@ +# SUP-X-6 report: format gate + CRLF-safe Prettier policy + +## Changes + +- Added `endOfLine: auto` to `.prettierrc.json`, so Prettier follows the existing file line ending and does not report CRLF-only differences on Windows checkouts. +- Added a named `Format check` step running `yarn format:check` to `scripts/harness-verify.mjs`. +- Added `format:check` to the validation script inventory in `scripts/harness-context.mjs`. +- Preserved the existing CI `yarn format:check` command unchanged. +- Did not edit `TODO.md` or the progress ledger. + +## Validation + +The focused gate changes passed these checks before the unrelated format-only churn was reverted: + +- `yarn format:check`: passed after normalizing the 26 existing files reported by the repo-wide check. +- `yarn typecheck`: passed. +- `yarn test`: 596/596 tests passed across 55 files. +- `yarn lint`: passed, 0 errors and 206 existing warnings. +- `REDIS_URL=redis://localhost:6379 yarn test:integration:if-available`: 6/6 passed. +- `PGHOST=localhost PGPORT=5432 PGUSER=veerify PGPASSWORD=veerifypassword PGDATABASE=veerifydb yarn test:integration:postgres:if-available`: 114 passed, 1 guarded realtime two-process test skipped because `DATABASE_URL` was unset. +- `yarn test:e2e:if-available`: skipped by the local guard because `PLAYWRIGHT_FORCE=1` and a configured database were not set. +- The full harness with explicit Redis/Postgres variables passed all gates, including the new format step, while the reviewed formatting normalization was present. + +## Final normalization + +The repository baseline had 26 pre-existing Prettier differences unrelated to this gate. In the follow-up commit, Prettier was run only on the exact 26 paths emitted by the failing `yarn format:check` command: + +```text +docs/plans/2026-08-11-support-platform/reviews/task-5-initial-review.md +docs/plans/2026-08-11-support-platform/stage-05-decisions.md +scripts/profile-build.mjs +server/api/support/attachments/[id].get.ts +server/api/support/attachments/[uploadId]/complete.post.ts +server/services/scheduler/tasks/attachment-cleanup.ts +server/services/scheduler/tasks/outbound-delivery.ts +server/utils/contact-merge-transaction.ts +server/utils/delivery-events.ts +server/utils/storage/provider-local.ts +server/utils/storage/provider-s3.ts +server/utils/support-attachments.ts +tests/attachment-cleanup-scheduler.test.ts +tests/build-lifecycle.test.ts +tests/delivery-route-control.test.ts +tests/e2e/helpers/support-permissions.ts +tests/e2e/support-inbound-email.spec.ts +tests/integration/support-attachment-cleanup.test.ts +tests/integration/support-attachment-finalization.test.ts +tests/integration/support-timeline-pagination.test.ts +tests/storage-provider-contract.test.ts +tests/support-attachment-read-routes.test.ts +tests/support-attachment-routes.test.ts +tests/support-attachments.test.ts +tests/support-keyboard-shortcuts.test.ts +tests/support-timeline.test.ts +``` + +The resulting diff is formatting-only (whitespace, wrapping, quote normalization, or Markdown table alignment), and the final repo-wide `yarn format:check` passes. diff --git a/AGENTS.md b/AGENTS.md index eb3e9004..ae6bb29c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,8 @@ Run these after every change: - `yarn test` - `yarn lint` - `yarn test:e2e:if-available` +- `yarn test:integration:if-available` +- `yarn test:integration:postgres:if-available` Or run the harness command: @@ -46,6 +48,26 @@ 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 when a local Redis/Valkey + endpoint is reachable at `REDIS_URL` (defaults to `redis://localhost:6379`), or when a remote endpoint is + explicitly marked dedicated with `REDIS_INTEGRATION_DEDICATED=1`. Start local coverage 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 the local/dedicated Redis endpoint is up. Shared or production endpoints are rejected because + the reconnect test uses `CLIENT KILL TYPE pubsub`. +- 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/Caddyfile b/Caddyfile new file mode 100644 index 00000000..5351c8d7 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,49 @@ +{ + on_demand_tls { + ask http://app:3000/api/system/tls-ask + interval 2m + burst 5 + } +} + +# First-class hosts get normal automatic HTTPS (ACME HTTP-01). These are +# explicit, operator-controlled hostnames, so they are never routed through +# the on-demand `ask` gate below. +{$APP_DASHBOARD_DOMAIN} { + reverse_proxy app:3000 +} + +{$APP_DOMAIN} { + reverse_proxy app:3000 +} + +# Public MinIO/S3 endpoint used for direct browser uploads (see +# STORAGE_ENDPOINT / STORAGE_PUBLIC_BASE_URL). Presigned upload URLs embed +# this host, so it must be reachable from the browser, not just from `app`. +{$STORAGE_DOMAIN} { + reverse_proxy minio:9000 +} + +# Everything else: team public-board subdomains (.{$APP_DOMAIN}) and +# customer-owned custom domains (project.customDomain, see server/utils/ +# project-access.ts:findPublicProjectByDomain). Caddy requests a certificate +# for each distinct Host on first request via on-demand TLS. +# +# IMPORTANT — this must stay gated by `ask` above. Without it, this block is +# an open certificate-issuance relay: anyone can point a domain's A record at +# this server and force us to request a cert for it, which is both an abuse +# vector and a fast way to hit Let's Encrypt's rate limits. The ask endpoint +# is expected to return 200 only when the host matches a `*.{$APP_DOMAIN}` +# team subdomain or a project's verified `customDomain` — see D-07 and +# SUP-00-7 in docs/plans/2026-08-11-support-platform/. +# +# `/api/system/tls-ask` does not exist yet as of SUP-00-7 (Dockerfile/compose +# only). Whoever implements the endpoint should reuse +# `findPublicProjectByDomain()` from server/utils/project-access.ts and +# additionally allow `*.{$APP_DOMAIN}` hosts for team public boards. +https:// { + tls { + on_demand + } + reverse_proxy app:3000 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..0586353c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 + +# ---- deps ---------------------------------------------------------------- +# Installs the full dependency tree (incl. devDependencies) once, shared by +# the build stage and copied into the runtime stage. devDependencies are kept +# at runtime because the dedicated migration service runs `drizzle-kit migrate` +# before the app service starts. See the entrypoint below. +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile + +# ---- build ----------------------------------------------------------------- +FROM node:22-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# Compile only. Database migration remains an explicit runtime operation in +# docker-entrypoint.sh; no image-build or install hook mutates a database. +RUN yarn build + +# ---- runtime ----------------------------------------------------------------- +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3000 + +# Non-root user +RUN addgroup -S nodejs && adduser -S nuxt -G nodejs + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/.output ./.output +COPY --from=build /app/drizzle.config.ts ./drizzle.config.ts +COPY --from=build /app/server/database/migrations ./server/database/migrations +COPY --from=build /app/server/database/schema ./server/database/schema +COPY --from=build /app/scripts/backfill-project-domains.ts ./scripts/backfill-project-domains.ts +COPY --from=build /app/package.json ./package.json +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh && chown -R nuxt:nodejs /app + +USER nuxt + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- --spider http://127.0.0.1:3000/ || exit 1 + +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["node", ".output/server/index.mjs"] diff --git a/README.md b/README.md index bf4335df..6e14c968 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ A modern feedback management platform built with Nuxt 3, TypeScript, and shadcn- ### Prerequisites -- Node.js 18+ +- Node.js 22.12+ - Yarn package manager ### Installation @@ -131,7 +131,21 @@ yarn db:studio yarn build ``` -The build automatically runs migrations and seeds test data via the `postbuild` script. Seed is skipped on production (`VERCEL_ENV=production`). +`yarn build` compiles only; it never connects to or mutates a database. Run deployment migrations explicitly before starting a new release: + +```bash +yarn db:migrate:deploy +``` + +Migration history is append-only: never edit a migration that may already have +been applied. If a constraint or index needs phased validation, add a new +forward migration and schedule the validation separately. This keeps existing +Drizzle journals valid and avoids making a deploy replay or skip an unrelated +range of migrations. For large installations, run the migration command as a +single controlled deployment job and monitor long-running backfills before +starting application replicas. + +Preview/test data is always an explicit operation (`yarn db:seed` or `yarn db:seed:e2e`) and must never be part of a build or package-install hook. Vercel's `vercel-build` command runs deployment migration first and compilation second, without seeding. #### Configure the PostgreSQL database @@ -229,6 +243,59 @@ For local development, start the database with Docker Compose: docker compose up -d ``` +### Self-hosting on a VM + +`docker-compose.yml` runs the full stack — the app, Postgres, [Valkey](https://valkey.io/) (Redis-protocol +broker for realtime + rate limiting), MinIO (S3-compatible object storage), and [Caddy](https://caddyserver.com/) +(reverse proxy + automatic HTTPS) — on a single machine. No other setup is required beyond Docker and DNS. + +#### Prerequisites + +- A VM (or bare-metal host) with Docker Engine and the Compose plugin installed. +- DNS `A`/`AAAA` records pointed at the VM's public IP: + - `APP_DASHBOARD_DOMAIN` (e.g. `app.veerify.io`) — the dashboard/login/API host. + - `APP_DOMAIN` (e.g. `veerify.io`) — the base host for team public boards. + - `*.APP_DOMAIN` (e.g. `*.veerify.io`) — a wildcard record required for + team public boards at `.APP_DOMAIN`; the base `APP_DOMAIN` record + is still needed for the root host. +- A third record for `STORAGE_DOMAIN` (e.g. `assets.veerify.io`) pointed at the same IP. Uploads (logos, + banners) are presigned directly against MinIO, so this host must be reachable from customers' browsers — + it is proxied by Caddy, not exposed on its own port. +- Ports `80` and `443` open and free on the host (Caddy binds both; port 80 is required for ACME's HTTP-01 + challenge as well as HTTP→HTTPS redirects). +- Do not publish PostgreSQL, Valkey, or MinIO ports to the public host. The production Compose file keeps them + on its private network; use a temporary SSH tunnel or an authenticated admin network when direct access is needed. + +#### Environment + +Copy `.env.example` to `.env` and fill in every value used by `docker-compose.yml` — at minimum: +`POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `APP_DOMAIN`, +`APP_DASHBOARD_DOMAIN`, SMTP settings pointed at a real relay (Mailpit is dev-only and is not part of the +production stack), `STORAGE_BUCKET`, `STORAGE_ACCESS_KEY_ID`, `STORAGE_SECRET_ACCESS_KEY`, `STORAGE_DOMAIN`, +and `UPLOAD_TOKEN_SECRET`. `STORAGE_ACCESS_KEY_ID`/`STORAGE_SECRET_ACCESS_KEY` double as the MinIO root +credentials — there is no separate MinIO admin password to set. + +#### Bring the stack up + +```bash +docker compose up -d --build +``` + +This builds the app image, starts Postgres/Valkey/MinIO, creates and publishes the MinIO bucket, runs the +single migration/backfill service before the app begins serving, and brings Caddy up in front of everything. +`docker compose logs -f migrate` shows migration output; `docker compose logs -f app` shows server startup. + +#### Custom domains (`project.customDomain`) + +When a team points a customer-owned domain at a project's public board, Caddy issues that domain's TLS +certificate automatically on first request (on-demand TLS) — no manual cert management, no restart. The +customer only needs a `CNAME`/`A` record pointing their domain at this VM; verification and DNS-target +guidance is the same as on the CNAME/Vercel-based flow (see `CNAME_TARGET` above). + +Certificate issuance for arbitrary hosts is gated by an `ask` check in `Caddyfile` so the proxy can't be +abused as an open certificate-issuance relay — see the comments in `Caddyfile` and D-07 in +`docs/plans/2026-08-11-support-platform/deltas.md` for what that endpoint needs to validate. + #### Preview the production build locally: ```bash diff --git a/TODO.md b/TODO.md index 20f6c2eb..8377e1fc 100644 --- a/TODO.md +++ b/TODO.md @@ -175,3 +175,252 @@ 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 have no dashboard pages, so their sidebar entries cannot be un-disabled** + Partially addressed by SUP-02-12: both entries are now gated on their per-team module toggle and default to off, so they no longer appear as permanently-dead items for every user. **They remain `disabled` even when the module is switched on**, because `pages/roadmap` and `pages/changelog` genuinely do not exist — un-disabling them would link to a 404. The public-board roadmap (`/[project]/roadmap`) and the changelog work on `sleekplan-export` are separate things and do not provide these routes. Closing this properly means either building the two dashboard pages or removing the entries entirely; the toggle is already in place to drive them once they exist. + +- [ ] **#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 +directory first. Infrastructure only — no support tables, endpoints, or UI in this stage. + +- [x] **SUP-00-1** Split `server/database/schema/feedback.ts` into `feedback.ts`, `notifications.ts`, `imports.ts`, `changelog.ts`; add empty `support.ts`; re-export from `index.ts`; verify `yarn db:generate` emits no migration + - `bd31097`: `notification` extracted to `notifications.ts`, empty `support.ts` added, `yarn db:generate` confirmed to emit no migration. + - `imports.ts` and `changelog.ts` are **not part of this branch**. The `importRun`/`importRunIssue`/`changelogPost` tables and the feature built on them live on `sleekplan-export`, where that split is already done. Nothing further is owed here. +- [x] **SUP-00-2** Add `server/services/realtime/` with `types.ts`, `redis.ts` (ioredis, separate pub/sub connections), `memory.ts`, and driver selection; versioned thin envelopes; unit tests for envelope routing + - `26d1847`. Driver written against the Redis wire protocol via `ioredis`, not a vendor SDK, so Upstash and Valkey are the same code behind one `REDIS_URL`. `sanitizeEnvelope()` strips unknown keys, enforcing "identifiers only, never record contents" in code rather than by convention. 18 unit tests. +- [x] **SUP-00-3** Rewrite `server/utils/ws-connections.ts` for channel subscriptions (`team:`, `inbox:`, `conversation:`, `user:`) with subscribe-time authorization; keep existing notification delivery working + - `cdd56dd`. Authorization in `server/utils/realtime-channels.ts`, checked at subscribe time and failing closed. `inbox:`/`conversation:` deny until Stage 02 supplies the tables. Peers capped at 50 channels. Legacy `sendToUser` retained unchanged — see SUP-00-9. +- [x] **SUP-00-4** Add client realtime helper: reconnect with backoff, resubscribe, refetch-on-reconnect, idle-disconnect after 5 min with refetch-on-focus + - `lib/realtime-client.ts` (framework-agnostic, injectable socket/timer seams) + `plugins/realtime.client.ts` exposing `this.$realtime`. Reference-counted subscriptions, backoff with jitter, idle disconnect at 5 min, no retry on close code 4001. 13 tests using fake timers. `NotificationBell.vue` deliberately untouched — that is SUP-00-9. +- [x] **SUP-00-5** Add a store adapter to `server/utils/rate-limit.ts` (`memory` | `redis`) reusing the Redis connection; no call-site changes + - Also delivered delta D-06: `server/services/redis/client.ts` is now the single ioredis factory, shared by the realtime publisher and the limiter, so enabling both does not double connection count. Redis sliding window is a Lua script over a sorted set (atomic, one round trip). **Fails open** — a Redis outage allows requests rather than taking down the public API. All 15 call sites unchanged. +- [x] **SUP-00-6** Add `server/services/scheduler/` with Vercel Cron and Nitro scheduled-task backends behind one registration API; no tasks registered yet + - Merged from `agent/SUP-00-6-scheduler-r2` (branch name collision with a dead worktree — see delta D-11). Cron endpoints verify `CRON_SECRET` with a length-checked `timingSafeEqual` and fail closed before touching the registry; task name is baked in per route so callers cannot probe for arbitrary tasks. 18 tests. +- [x] **SUP-00-7** Add `Dockerfile` + `.dockerignore`; extend production `docker-compose.yml` with `app`, `valkey`, `minio`, and Caddy on-demand TLS; add `valkey` to dev compose; document the VM path in `README.md` + - Merged from `agent/SUP-00-7-docker-r2`. Dockerfile deliberately runs `yarn nuxt build`, not `yarn build` — the latter's `postbuild` hook runs `scripts/seed.ts`, which creates fixed-password test accounts that must never reach a production image. Caddy `on_demand_tls` is gated behind an `ask` endpoint. `.gitattributes` forces LF on `*.sh` and `Caddyfile` so a CRLF shebang cannot break `/bin/sh` in the container. + - `docker build` **independently verified** by the orchestrator: exit 0, image 1.76 GB, `docker inspect` confirms `User=nuxt` (uid 100, non-root), `Entrypoint=[docker-entrypoint.sh]`, `Cmd=[node .output/server/index.mjs]`. Ran the image and confirmed `.output/server/index.mjs`, `server/database/migrations`, and `node_modules/.bin/drizzle-kit` are all present so the entrypoint can actually migrate. Test image removed afterwards. +- [x] **SUP-00-10** Add `GET /api/system/tls-ask` — the Caddy on-demand TLS validation endpoint + - `032c5b5`. Board entry was stale — the file exists and survived the `sleekplan-export` split untouched. + - Implement with `findPublicProjectByDomain()` from `server/utils/project-access.ts`: return 200 only for hostnames configured as a project custom domain or a team subdomain, 404 otherwise. Must be unauthenticated (Caddy has no session) and cheap — it is called per unknown-host TLS handshake, so it needs its own rate limit. +- [x] **SUP-00-8** Update `.env.example` (`REDIS_URL`, `REALTIME_DRIVER`, `RATE_LIMIT_STORE`) and `docs/agent/context-map.md` + - `c1b603a`. Also added `CRON_SECRET`, which SUP-00-6 introduced. Note it is effectively required on cloud: the cron endpoints fail closed, so an unset secret means every scheduled task returns 401 and silently never runs. Docker-specific vars land with SUP-00-7. +- [x] **SUP-00-10** Add `GET /api/system/tls-ask` — the Caddy on-demand TLS validation endpoint + - `032c5b5`. Allows exactly three cases: the dashboard host, a team subdomain whose team exists, and a public project custom domain. Hostname parsing extracted to `server/utils/tls-ask.ts` with 12 tests covering non-DNS characters, empty labels, suffix matches that are not subdomain boundaries, and nested labels. +- [x] **SUP-00-9** Migrate `NotificationBell.vue` off direct WS payloads onto the channel system: publish a thin envelope on `user:` and have the client refetch the list and unread count instead of unshifting `msg.data` + - `b6404a9` + `3b0ab10`. Envelopes are now scoped by channel rather than requiring a `teamId`, so `user:` events are expressible; `publishRealtime` also refuses cross-channel publishes, which is stronger than Stage 00 originally specified. Peers auto-subscribe to their own user channel using the id from the validated session, so the client never sends its own id. + - The legacy per-user path (`sendToUser`, `addConnection`, `removeConnection`, the `userConnections` map) is **deleted**, not deprecated — notifications now cross instances. The 30s polling remains as a connect-failure fallback but is no longer load-bearing. + - Surfaced during SUP-00-3. `sendToUser()` pushes full notification objects, which the envelope design disallows, so it was left in place unchanged. Its in-memory map is process-local, so notifications still do not cross instances — the component's 30s polling fallback is load-bearing until this lands and must not be removed before then. + - Needs a decision on scope: the `notification` table has no `teamId`, so either the envelope's `teamId` becomes optional for user-scoped events, or notifications gain a team scope. + +## Support Platform — Stage 01: Contact identity + +Plan: `docs/plans/2026-08-11-support-platform/stage-01-contacts.md`. Read `design.md` and `deltas.md` +first. Integration branch is **`support-platform`**, not `main` (delta D-17). + +**Hard constraint:** `server/database/schema/feedback.ts` gets no `contactId`, no backfill, and no data +migration. The single permitted change is an index on `authorEmail`. See "Why contacts and feedback stay +separate" in `design.md`. + +- [x] **SUP-01-1** Add `contact`, `contactIdentity`, `supportCompany`, `contactLink` to `server/database/schema/support.ts` with all indexes; generate migration; add index on `feedback.authorEmail` +- [x] **SUP-01-2** Add `requireContactAccess` to `server/utils/support-access.ts`; unit tests for the 404/403 split +- [x] **SUP-01-3** Add contact CRUD endpoints (list, create, get, update, delete) with team scoping and cursor pagination +- [x] **SUP-01-4** Add `POST /api/support/contacts/[id]/merge` with transactional repointing and tombstone; unit tests for collision and self-merge cases +- [x] **SUP-01-10** Correct Stage 01 integrity and concurrency: validate same-team `companyId` on create/update; use a validated `(createdAt, id)` cursor; lock and revalidate both merge contacts inside one transaction; add PostgreSQL-backed endpoint tests. + - `3360f98`, `b25525f`, `ab6fdb2`: same-team company checks, opaque stable cursor, locked/revalidated merge and tombstone update guards, plus focused and guarded PostgreSQL E2E coverage. Independent review approved after two fix rounds. +- [x] **SUP-01-5** Add `supportTeamSettings` (`teamId` primary key, `autoLinkFeedback` default `false`, timestamps) and `GET /api/support/contacts/[id]/timeline` returning `linked` and `probableFeedback` separately, plus link/unlink endpoints. Changing this team-scoped setting requires team membership. + - `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. +- [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. +- [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 + +- [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. +- [x] **SUP-X-3** (repo-wide, not support-specific) Build a build-time scanner that parses the `@openapi` JSDoc blocks already present on annotated endpoint files — auth, github, orgs, cron, system, teams, 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. + - `6d5a372`, merged in `03b7de3`. `scripts/openapi-scanner.ts` emits the checked-in `server/generated/openapi-routes.ts` before `build`, `generate`, and `vercel-build`; all 68 annotated route files produce 46 paths and 68 operations. Focused scanner/lifecycle tests and the full harness passed; the served production smoke check retained metadata, security, shared schemas, and support routes without request-time filesystem access. +- [x] **SUP-X-4** Restrict module enable/disable in the `/settings` Tools tab to team admins (delta D-28). The design initially deferred this because `teamMember.role` has `admin` and `member` with otherwise equivalent permissions, and `design.md` freezes those semantics. This is the first real differentiation of that column; the freeze remains about keeping _support_ permissions off `teamMember.role`, which is a separate question from workspace administration. + - `97f7575`. The existing PUT admin guard is now reflected in the GET capability response and Settings Tools UI: members see read-only switches and an explanation, while admins retain mutations. Added API authorization coverage and a Playwright workflow that asserts no PUT is attempted by a non-admin view. +- [x] **SUP-X-5** Fix E2E specs that import `db` failing to collect under Playwright (delta D-33) + - Root cause was **not** the browser/node export split first assumed — both consola builds export `createConsola`. Playwright resolves the **`require`** condition to `lib/index.cjs`, which assigns exports in a dynamic loop (`module.exports[key] = lib[key]`); `cjs-module-lexer` cannot see those, so the ESM named import fails and the whole spec file fails to collect, reported only as `No tests found`. Node and Nuxt resolve the `.mjs` build, so the app was never affected. + - Fixed by giving the suite its own client, `tests/e2e/helpers/db.ts`, built from `pg` + the schema (which depends only on `drizzle-orm/pg-core`). No app module, and therefore no logger, enters the test process. **`logger.ts` deliberately untouched** — it is used app-wide and the CJS/ESM interop shape differs between builds, so changing it to suit a test runner risked breaking production logging. + - **The three affected specs had never run.** Stage 01's cross-tenant isolation, concurrent-merge, and cursor-pagination criteria said "verified by E2E" and were enforcing nothing. All five tests now collect: 3 pass, 2 skip on absent fixture data. + - Still open as a separate harness follow-up: `harness:verify` **skips** the E2E gate rather than failing it, so a broken spec file looks identical to a deliberately skipped suite. That is what let this hide for two stages. +- [x] **SUP-X-6** (repo-wide) `format:check` is not part of `yarn harness:verify`, and is unusable on Windows. Two separate problems: (1) the gate every stage validates against never runs `prettier --check`, which is how ~40 files drifted far enough for CI to fail on them; (2) `.prettierrc` sets no `endOfLine`, so with `core.autocrlf=true` every text file in a Windows working tree fails on line endings alone — 110 files locally, all false positives, since git stores LF and CI checks out LF. Verified: `npx prettier --check --end-of-line=auto .` passes repo-wide today, so there is no real drift right now. Fix is likely `"endOfLine": "auto"` in `.prettierrc` plus adding the check to `scripts/harness-verify.mjs`. + - `0fbad79` and `7d3ed22`, merged in `70f1192`. Added the explicit `endOfLine: "auto"` policy, made `format:check` a named `harness:verify` gate and a required harness script, and normalized the 26 files reported by the gate. The integrated harness passed formatting, typecheck, 599 unit tests, lint (0 errors/206 existing warnings), Redis (6), and Postgres (114 with one guarded realtime skip); local broad E2E remained correctly guarded. + +## Support Platform — Stage 04: Outbound replies + +Plan: `docs/plans/2026-08-11-support-platform/stage-04-outbound-replies.md`. Read `design.md` and +`deltas.md` first, then `parallel-agents.md` for the two-agent split and the agreed module signatures. + +**The split is PROPOSED, not agreed.** Agent 1 drafted it because no Stage 04 contract and no `SUP-04-*` +ids existed when the stage opened; Agent 2 wrote the Stage 03 one. **Agent 2 should ratify or amend +`parallel-agents.md`** — but the three questions it originally left open are now **answered against the +code**, so the stage is not blocked on them. + +**One migration expected: `0025`**, belonging to SUP-04-3, creating **two** tables: +`supportOutboundDelivery` (specified in `design.md`, never created; the schema is at `0024`) and +`supportDeliveryEvent`. It does **not** alter `supportEmailEvent` — delivery webhooks must not share that +table. Its key is one row per _email_, deliberately collapsing retries, whereas one outbound message +produces many delivery events (Delivery, Open, Bounce). Sharing it would swallow every event after the +first, **including the hard bounce**, which is exactly the silent failure acceptance criterion 6 exists to +catch. Reasoning in full in `parallel-agents.md`. + +**Narrowed by Stage 02, before anyone starts:** `firstResponseAt` stamping and the immediate realtime +publish — acceptance criteria 7 and 8 — already ship in `messages/index.post.ts:83-96`. SUP-04-4 must +preserve them, not build them. + +- [x] **SUP-04-1** (agent 1) Extend `lib/email.ts` with an optional options bag (from, replyTo, cc, headers, attachments); confirm all existing call sites are unaffected. All 10 are inside `lib/email.ts` itself and pass exactly `{ to, subject, html, text }`, so the blast radius is contained. Also adds the outbound surface to `ChannelDriver`, which has none today — SUP-04-6 has nothing to call without it +- [x] **SUP-04-2** (agent 2) Add `lib/support-email.ts`: Message-ID generation, References chain assembly with trimming, quoted-history block, signature appending; unit tests for chain assembly +- [x] **SUP-04-3** (agent 1) Add `supportOutboundDelivery` (message id, payload/credential references, attempt count, status, lease, idempotency key, timestamps) and a bounded retry/claim worker. Reuse it for agent replies, auto-replies, and later CSAT/social sends (delta D-21) +- [x] **SUP-04-4** (agent 1) Wire `POST /api/support/conversations/[id]/messages` for `kind: 'outgoing'`: transactional optimistic insert plus outbox enqueue, immediate realtime publish, worker delivery-status update, and `firstResponseAt` stamping +- [x] **SUP-04-5** (agent 1) Enforce server-side that `kind: 'note'` never dispatches mail +- [x] **SUP-04-6** (agent 2) Implement per-inbox From/Reply-To/signature with a settings warning when the address is not provider-authorized +- [x] **SUP-04-7** (agent 2) Implement agent attachment upload via the existing presign flow with size cap and type allowlist +- [x] **SUP-04-8** (agent 1) Implement auto-reply with once-per-conversation, auto-response, `Auto-Submitted`, and per-contact rate-limit guards. All four ship together or auto-reply does not ship — it is the mail-loop vector +- [x] **SUP-04-9** (agent 1) Add `POST /api/support/delivery/[provider]` for delivery and bounce webhooks, keyed per event on the new `supportDeliveryEvent` table rather than `supportEmailEvent`; map to `deliveryStatus` and write an `activity` message on hard bounce +- [x] **SUP-04-10** (agent 2) Surface delivery status in the thread UI (pending, sent, failed, bounced) with a retry action on failure +- [x] **SUP-04-11** (agent 2) Add E2E coverage for the full round trip: inbound mail → agent reply → customer reply threads back + - **Acceptance criterion 1 is not reachable from this suite.** "Same thread in Gmail _and_ Outlook" needs real mailboxes at both providers, and the stage doc says outright that Mailpit will not catch client-specific quirks. Verify by hand and record it as manual, or descope it deliberately — do not let a Mailpit assertion quietly stand in for it. **Descoped deliberately** — not asserted by `tests/e2e/support-outbound-reply.spec.ts`. + - `tests/e2e/support-outbound-reply.spec.ts` written and typechecks/lints clean; **not executed this session** — no Docker, no reachable Postgres, no Postmark credentials on this box. Same "written but never executed" state SUP-03-14 was in; say so, don't let it read as verified. + +## Support Platform — Stage 03: Inbound email + +Plan: `docs/plans/2026-08-11-support-platform/stage-03-inbound-email.md`. Read `design.md` and +`deltas.md` first, then `parallel-agents.md` for the two-agent split and the agreed module signatures. + +**Webhook only** — the IMAP driver was dropped (delta D-29). Inbound is Postmark/Mailgun webhooks. + +- [x] **SUP-03-1** Add `server/services/support-channels/` with `types.ts` and normalized `InboundMessage`; driver selection from `SUPPORT_CHANNEL_PROVIDER` + - `3f65831` (agent 1), merged in `b5e8e80`. `InboundMessage` matches the contract pinned in `parallel-agents.md` exactly. `rawHeaders` keys are lowercased by the drivers, which `isAutoResponse` also does defensively — harmless overlap. + - A **compile-time assertion** now lives in `tests/inbound-threading.test.ts` proving `InboundMessage` satisfies the structural `ThreadableMessage` that `resolveThread` takes. Nothing calls `resolveThread` with one until SUP-03-4, so without it the seam between the two agents would go unchecked until integration — the exact way Stage 02's deep-link bug got in. +- [x] **SUP-03-2** Implement the Postmark webhook driver with signature verification and payload normalization; unit tests against captured fixtures + - `3f65831` (agent 1). +- [x] **SUP-03-3** Implement the Mailgun webhook driver with signature verification and payload normalization + - `3f65831` (agent 1). +- [x] **SUP-03-4** Add `supportEmailEvent` claim/replay state and `POST /api/support/inbound/[provider]` (verify → atomic claim → archive raw → parse → resolve inbox → resolve contact → thread → persist → publish → mark processed); per-inbox rate limiting + - `fc24223` (agent 1). Non-error outcomes all return 200 with a `reason` (`duplicate-delivery`, `no-matching-inbox`, `support-disabled`, `auto-response`, …) so a provider never retries forever. Carries **migration 0024** dropping `NOT NULL` from `supportEmailEvent.inboxId` — correct and necessary (delta D-35): the event is claimed as soon as the signature verifies, before parsing reveals the inbox, and mail to an unrecognised address never resolves to one. That `NOT NULL` was a defect in my SUP-02-1 schema. +- [x] **SUP-03-5** Implement threading resolution (Message-ID/References, then thread key, then bounded subject+contact fallback); unit tests including the never-merge-across-contacts case + - `server/utils/inbound-threading.ts`. Header matches are inbox-scoped but deliberately **not** contact-scoped — a CC'd participant replying is a different contact on the same thread. The subject heuristic is fenced four ways (same inbox, same contact, open/pending, 7-day window); the contact scope is what stops two customers mailing "Invoice question" landing in one conversation. 13 unit tests on the exported `normalizeSubject` (stacked prefixes, `Re[2]:`, localised AW/WG/SV/RES, and "Refund request" surviving a naive prefix strip) plus 10 against real Postgres. + - Takes a structural `ThreadableMessage` rather than importing `InboundMessage`, so `server/utils` takes no dependency on `server/services/support-channels`. An `InboundMessage` satisfies it, so the pinned call site compiles unchanged — **flagged for Agent 1** rather than changed silently. +- [x] **SUP-03-6** Implement reply-quote and signature stripping for Gmail/Outlook/Apple Mail; retain the raw body in metadata; unit tests against fixtures + - `server/utils/inbound-content.ts`. Cuts at the **earliest** quote marker, handles the wrapped Gmail attribution clients emit, Outlook's divider and its no-divider `From:/Sent:` block, localised dividers, and forwarded blocks; then drops trailing `>` lines and an RFC 3676 signature. Falls back to flattened HTML when there is no text part. `rawBody` always returns the untouched input, so a bad strip is recoverable from the record rather than data loss — and if a strip would empty the message, the heuristics are assumed to have misfired and the full source is kept. +- [x] **SUP-03-7** Implement inbound HTML sanitization with a strict allowlist and sandboxed-iframe rendering in the thread pane + - Both layers `design.md` requires. `server/utils/inbound-sanitize.ts` uses **`sanitize-html`** rather than a hand-written allowlist — the risk is not parsing HTML, it is the bypass tail (`javascript:` behind entity encoding, svg/math foreign content, mXSS, CSS `expression()`), which a regex sanitizer loses to. `SupportMessageHtml.vue` renders `bodyHtml` in an iframe with `sandbox=""` (no `allow-scripts`, no `allow-same-origin`), never `v-html`. + - **Adds `sanitize-html` as a direct dependency** — the first dependency change this stage. `package.json`/`yarn.lock` are shared with Agent 1; noted in case of a lockfile conflict at merge. + - Two real policy gaps the tests caught: `transformTags` added `rel`/`target` but `allowedAttributes` filtered them back out; and `allowedSchemes` only governs URLs that _have_ a scheme, so a relative `/settings` href survived — which in the agent UI resolves against our own origin, turning a hostile email into a link into the authenticated app. Relative hrefs are now dropped and the text kept. + - `img` is blocked: a remote `src` is a tracking pixel firing when an agent opens a ticket, leaking their IP. Inline images arrive by `Content-ID` and need `cid:` rewriting to our storage — a deliberate decision for **SUP-03-8**, not something to allow blindly here. 18 unit tests. +- [x] **SUP-03-8** Implement attachment ingest to storage with inline `Content-ID` mapping and a per-message size cap + - `7672969` (agent 1). Crosses into `server/utils/inbound-sanitize.ts` (my file) to allow `` — **reviewed carefully, and correct**: the transform drops any `src` that is not `/api/support/attachments/`, anchored at both ends so `https://evil/api/support/attachments/x` and path traversal both fail. The route it points at is genuinely access-checked via `requireConversationAccess`, plus `nosniff`, `Content-Disposition: attachment` for non-inline, and a strict CSP — so a guessed id returns 403, not another tenant's file. My tracking-pixel test still passes, confirming remote `src` is still dropped. +- [x] **SUP-03-9** Implement auto-response detection (`Auto-Submitted`, `X-Autoreply`, null return-path) so bounces do not reopen or loop + - `server/utils/inbound-autoresponse.ts`. Biased toward false negatives on purpose: a missed auto-reply is one junk message an agent deletes, a false positive silently discards a real customer email. Matches `Auto-Submitted != no`, the `X-Autoreply` family, `X-Auto-Response-Suppress`, a null return-path, and `Precedence: auto_reply` — but **not** `bulk` or `list`, which customers forward routinely. +- [x] **SUP-03-10** Honour the team's Support module switch: if `teamModuleSettings.supportEnabled` is false for the resolved inbox's team, record the event and return 200 without creating a conversation (delta D-32, moved from SUP-02-13) + - `fc24223` (agent 1). Returns 200 with `reason: 'support-disabled'`. +- [x] **SUP-03-11** Implement contact and CC-participant resolution from `From` and `Cc` + - `fc24223` (agent 1), in `server/utils/inbound-contacts.ts`. +- [x] **SUP-03-12** Implement product attribution on conversation creation from the matched `supportInboxAddress.projectId`, never overwriting an existing conversation's product + - `fc24223` (agent 1). +- [x] **SUP-03-13** 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 + - **Narrowed deliberately — see delta D-34.** Provider selection and the webhook signing secret are _deployment env vars_ (`SUPPORT_CHANNEL_PROVIDER`, `SUPPORT_POSTMARK_WEBHOOK_USER/PASSWORD`, `SUPPORT_MAILGUN_SIGNING_KEY`), not per-inbox settings. A provider dropdown would imply a choice that does not exist; a secret field would either do nothing or push a webhook credential into `supportInbox.channelConfig`, which **any team member can read and edit** — a security regression traded for a form that looks finished. + - Built `GET /api/support/channel-status` + a read-only Channel card: selected provider, whether its driver resolves, whether credentials are present, the **names** of missing env vars (never values), the webhook URL to register, and the address to point MX/forwarding at. The receiving-address list with product mapping already shipped in SUP-02-14. + - More useful than the specified connection test: the realistic failure is a deployment with the provider set but credentials unset, where inbound mail is rejected silently. The card reads "Not receiving mail" and names what to set. Verified live — 401 unauthenticated, and `credentialsConfigured: false` with both Postmark vars named on this dev box. + - **Known duplication:** `REQUIRED_ENV` hard-codes each provider's variables; that belongs on `ChannelDriver` as `isConfigured()`. `server/services/support-channels/**` is Agent 1's territory this stage, so it was flagged rather than edited — adding a provider currently means updating the map too. +- [x] **SUP-03-14** Add E2E coverage: inbound mail creates a ticket, a reply threads onto it, a duplicate delivery does not double it + - Spec at `tests/e2e/support-inbound-email.spec.ts` (agent 2), **fixed and executed by agent 1** in `a6e58f5`. + - The spec as first written was wrong, and the bug was mine: `supportEnabled` defaults to **false** for every team (delta D-31, my decision), and SUP-03-10 correctly honours it by recording the event, returning 200, and creating nothing. So the spec asserted a conversation was created while exercising the path designed to create none. It could not have been caught without running it — which is exactly why it shipped marked NOT YET EXECUTED rather than checked off. Agent 1 diagnosed it by replaying the spec's own API calls by hand, since its `finally` deletes the fixtures. + - The fix switches the module on first and **restores the previous value in `finally`** — the seed team is shared with every other spec, so leaving Support enabled would silently change what they exercise. + - **Verification is agent 1's, not independently re-run here**: they report this spec passing and the full Playwright suite at 37 passed / 1 skipped / 0 failed, the skip being the pre-existing local-storage-only upload test. Agent 2 could not re-run it — the dev server would not finish a cold start, single files taking 280s+ under disk contention. + +## 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. +- [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. +- [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`. +- [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). +- [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. +- [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. +- [x] **SUP-02-8** Add message, participant, and tag endpoints; publish thin realtime envelopes on `conversation:` and `inbox:` for every write + - `c8bc29d` (agent 1), merged in `7ffa2fb`. Messages GET/POST, participants POST/DELETE, conversation tags GET/POST/DELETE, and team tag CRUD. Reuses the existing `publishConversationEvent()` rather than adding a second publisher. Unblocks the thread pane in SUP-02-9, which had been surfacing its error state against a 404 until this landed. +- [x] **SUP-02-9** Build the `/support` three-pane UI: inbox switcher, filtered conversation list, thread pane rendering all four message kinds, contact drawer + - `pages/support/index.vue` plus five components under `components/support/`. Note rendering uses five independent signals (not a bubble at all, amber dashed card, uppercase "Internal note" label, lock icon, "only visible to your team" caption), reusing the "unconfirmed" visual language from Stage 01's probable-matches panel — acceptance criterion 4 is a functional requirement, not styling. Realtime refetches on `inbox:`/`conversation:` envelopes rather than trusting their contents. **The messages endpoint (SUP-02-8, Agent 1) does not exist yet**, so the thread pane currently shows its error-with-retry state; it starts working when that lands, with no change here. Composer deliberately omitted — SUP-02-10. Fixed a `no-dynamic-delete` lint error in the contact cache; the replacement `'error'` sentinel also stops a legitimately-null contact being refetched on every render. +- [x] **SUP-02-10** Build the composer with an unmistakable reply/note toggle; messages stored only, not sent, in this stage + - `components/support/SupportComposer.vue`, filling the slot the thread pane already reserved. Mode is signalled five ways — container tint, tab styling, placeholder, caption ("Only your team will see this" vs "Visible to the customer"), and the submit button ("Add internal note" vs "Send reply") — matching the note styling in `SupportMessageItem`. Redundant on purpose: acceptance criterion 4 calls posting a note as a public reply the worst failure in a support tool. + - Draft **and** mode reset when the selected conversation changes, so a note draft cannot follow the agent into another ticket. Mode persists after a successful post (agents add notes in runs) — safe because the strip stays visibly amber. + - Server-side, `isPrivate` is derived from `kind` and never read from the body (SUP-02-8), so a client cannot post a note that renders as public. + - Verified the contracts my UI had been written against before Agent 1's endpoints existed — messages GET/POST and tags GET all match on params, response envelope, and ordering. Cleared two now-stale "this endpoint may not exist yet" comments. +- [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. +- [x] **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) + - `teamModuleSettings` table (migration `0023`) in its own `server/database/schema/teams.ts`, not in `supportTeamSettings` — see delta D-31. `GET/PUT /api/teams/[teamId]/modules`, team membership only. The PUT upserts with defaults filled in, so a partial write against a team with no row cannot silently disable the fields it omitted. `SettingsTools.vue` is the tab; the sidebar reads the flags and refetches on `veerify:active-team-changed` and a new `veerify:team-modules-changed` event. + - **Only partially delivers "replacing the hardcoded `disabled: true` placeholders", and deliberately so.** `pages/roadmap` and `pages/changelog` **do not exist** — the entries are placeholders for unbuilt dashboard pages, not feature-gated links, so un-disabling them would produce links to a 404. The toggle now controls whether each placeholder is _advertised at all_ (both default off, so they vanish for everyone by default), but they stay `disabled` when shown. Fully closing Technical Debt #11 requires building those pages or deleting the entries; see the updated note there. + - `feedbackEnabled` defaults **true** so existing teams see no change on deploy; `supportEnabled` defaults **false**, so the Support group SUP-02-11 added is now opt-in. Updated the E2E spec that asserted Roadmap/Changelog render as visible disabled buttons, and added one covering the Support group's default-off state. +- [x] **SUP-02-13** Implement module disable semantics: hide nav and stop inbound processing while preserving conversations and contacts + - **Split — everything buildable in Stage 02 is done; the rest moved to Stage 03 (delta D-32).** Hiding the nav shipped with SUP-02-12: the sidebar reads `teamModuleSettings` and the Support group disappears when `supportEnabled` is false. Preserving data needed no work — the disable path is a boolean on a settings row and touches no conversation tables. + - **"Stop inbound processing" was not buildable here:** there is no inbound processing until Stage 03. A guard written now would sit against a code path nothing exercises and could not be tested until that stage — the same way `isUniqueViolation()` was silently wrong for weeks (delta D-24). It is now an explicit item in `stage-03-inbound-email.md` with its own acceptance criterion: record the event, return 200, create no conversation, and never 404 (a mail provider would retry forever). +- [x] **SUP-02-14** Build `/support/settings` with inbox name, signature, agent membership, and the receiving-address list with product mapping + - `bbc2b7d` (agent 1), merged in `7ffa2fb`. +- [x] **SUP-02-15** Add `conversation_assigned` and `conversation_mention` notification types and preference toggles + - `24cc1a6` (agent 1), merged in `7ffa2fb`. Extends the existing notification infrastructure — no parallel system, no migration (preferences already live in `user.settings` jsonb). Trigger point wired into `conversations/[id].patch.ts`, skipping self-assignment. + - **Integration bug found and fixed on merge** (`bc4a635`): the notification links to `/support?conversationId=…` and the key was correct — it matches what `/support` writes on selection — but the page only read `inboxId` back on load, never `conversationId`. Following the notification landed on `/support` with the first inbox open and the assigned conversation _not_ selected. Each side was right in isolation; it only broke where they met. Neither agent could have caught it alone. +- [x] **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) + - `728311f` (agent 1), merged in `7ffa2fb`. Still the hand-maintained duplicate that delta D-23 describes; SUP-X-3 remains the real fix. +- [x] **SUP-02-17** Add E2E coverage: create conversation, reply, add note, change status, verify activity message and live update + - `tests/e2e/support-conversation-flow.spec.ts`. Covers the full agent flow: create a conversation (asserting a real `displayId` from `supportCounter`), post a reply, post an internal note, change status, and confirm the change rendered into the thread as an `activity` message from the same ordered query. Also asserts `isPrivate` is **false** on the reply and **true** on the note — the server derives it from `kind`, so this tests the guard rather than what the client asked for — and that re-sending an unchanged status appends no phantom activity message. + - **The spec is written but could not be executed**, because of a pre-existing blocker (delta D-33, queued as SUP-X-5): any Playwright spec importing `db` dies at collection on a `consola`/`createConsola` export-condition mismatch. Stage 01's `support-contact-timeline.spec.ts` fails identically, so this is not new. **Every assertion in the spec was instead verified by hand against a live dev server and database** — create, reply, note, status change, activity body text, sender kind, and the no-op guard all confirmed, then the test data removed. So the behaviour is verified; the automated spec is not yet proven runnable. + - **Not covered:** acceptance criterion 1's realtime half — two agents in two browsers on two app instances, one replying and the other seeing it without a refresh. That needs two app instances and a shared broker, which this suite cannot stand up. Left explicitly open rather than pretended. + +## Support Platform — Stage 05A: Agent speed (MVP) + +Plan: `docs/plans/2026-08-11-support-platform/stage-05a-agent-speed.md`. Read +`stage-05-decisions.md`, `design.md`, and `deltas.md` in the same directory first. Integration branch is +**`support-platform`**, not `main`. Scope is fixed for a 1–3 agent team; do not restore deferred Stage 05 +features. + +- [x] **SUP-05A-1** Implement claim, auto-claim on first `outgoing` reply (notes excluded), unassign, and assign-to-another-agent, each writing an `activity` message; reopen preserves assignee + - `20df161`, `b94c2e7`, merged in `c02e2d2`. Explicit Claim uses a conditional transaction so concurrent claimers cannot overwrite the winner and only one assignment activity is written. Outgoing replies auto-claim unassigned conversations in their message transaction; notes never claim, existing owners are never stolen, and inbound reopen preserves the assignee. The header supports Claim, handoff, and release, with real-Postgres concurrency coverage and forced Playwright coverage for composer auto-claim plus dropdown handoff/release. +- [x] **SUP-05A-2** Add per-user conversation read state with the handled-ness supersede rule, manual mark-unread, and unread badges on `Unassigned` and `Assigned to me` + - `b411689`, `c9b5f18`, `53e372f`, `d198a00`, merged in `a570a1d`. Added per-user `conversationReadState` cursors and generated migrations `0030`/`0031`, handled-ness-aware unread derivation, assignee-only incoming invalidation, manual mark-unread, visible unread rows, and the two required queue badges. Read/inbound operations serialize on the conversation row; cursors are monotonic and future-timestamp safe; missing rows use structured 404 errors. Focused real-Postgres coverage is 9/9 and forced Chromium coverage is 2/2. Full integrated harness passes with 585 unit, 6 Redis, and 115 Postgres tests; broad E2E is guard-skipped locally when not forced. +- [x] **SUP-05A-3** Implement the four fixed views with `Unassigned` as the landing view + - `f8ea7c1`, `5da3d57`, merged in `b7ecf4b`. Replaced support navigation filters with exactly Unassigned, Assigned to me, Resolved, and All; Unassigned is the default and inbox changes reset the view. Added `view` API filtering while preserving existing explicit filters, static OpenAPI docs, second-inbox reset coverage, and corrected affected tag-permission E2E coverage. Integrated harness passes 585 unit, 6 Redis, and 115 Postgres tests; focused fixed-view/API-doc/permissions Chromium passes 4/4. Broad E2E is guard-skipped locally when not forced. +- [x] **SUP-05A-4** Implement local draft persistence keyed by `(conversationId, mode)` that restores composer mode, clears on send, and shows an unsaved-draft indicator in the list + - `8ae0c2f`, `716928c`, merged in `96faf33`. Reply and note drafts coexist in client storage under separate `(conversationId, mode)` keys; mode restores with its draft; successful sends clear only the submitted draft (including delayed in-flight sends), failed sends retain it, and rows show an unsaved Draft marker. Focused draft/support Chromium passes 8/8; integrated harness passes 585 unit, 6 Redis, and 115 Postgres tests, with broad E2E guard-skipped locally when not forced. +- [x] **SUP-05A-5** Add the team-scoped `cannedResponse` table (no `inboxId`) + CRUD + `/shortcode` insert-at-cursor with `{{contact.name}}` and `{{agent.name}}` + - `e85e0ee`, merged in the Stage 05A integration merge. Added generated migration `0032_brave_wolf_cub`, team-scoped CRUD with strict validation and unique-shortcode conflict handling, settings management for team members, and reply/note composer insertion that preserves surrounding text, replaces an active slash token, and substitutes only the two approved variables. Full harness passes with 592 unit, 6 Redis, and 115 Postgres tests; focused canned-response Chromium passes 2/2. Broad E2E is guard-skipped by default locally when not forced; forced focused coverage passed with the required test secrets and seeded Postgres. +- [x] **SUP-05A-6** Implement global scoped search over `displayId`, subject, and contact name/email; no `conversationMessage` access + - `ceb8405`, merged in the Stage 05A integration merge. Added global current-inbox search that bypasses the selected fixed view, matches subject/contact fields by substring and bare numeric display IDs exactly, preserves URL/deep-link state, and never touches `conversationMessage`. Full harness passes with 593 unit, 6 Redis, and 115 Postgres tests; focused search Chromium passes 1/1. Broad E2E is guard-skipped by default locally when not forced. +- [x] **SUP-05A-7** Add keyboard shortcuts scoped to `/support` with a `?` help overlay — build last + - `bcf0255`, merged in `c461222`; review fixes `f929fc8`, `372ad4e`, merged in `5161226`. Added support-scoped `j/k/r/n/c/e//?` handling over the visible list, composer mode focus, claim/resolve actions, search focus, and a dismissible help overlay. Editable fields and controls are protected, in-flight claim/resolve mutations are deduplicated, and focused Chromium coverage passes 1/1. Full integrated harness passes with 596 unit, 6 Redis, and 115 Postgres tests; broad E2E is guard-skipped locally without `PLAYWRIGHT_FORCE=1`/explicit PG variables. +- [x] **SUP-05A-8** Add E2E coverage: reply auto-claims, note does not, draft restores its own mode, search finds a resolved conversation from another view + - `074e635`, review-strengthening `ca892cb`, merged in `22f85bf`. Added three deterministic Chromium acceptance workflows covering persisted note/reply assignment and message kinds, independent reply/note drafts, and resolved-conversation global search from another fixed view with exact deep-link/view/input hydration after reload. Focused Chromium passes 3/3; final harness passes with 596 unit, 6 Redis, and 115 Postgres tests. Broad E2E is guard-skipped locally without `PLAYWRIGHT_FORCE=1`/explicit PG variables. diff --git a/components/NotificationBell.vue b/components/NotificationBell.vue index 975acd06..ac6d541a 100644 --- a/components/NotificationBell.vue +++ b/components/NotificationBell.vue @@ -1,12 +1,7 @@