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..19de103e 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,17 @@ # Database Configuration # Option 1: Use DATABASE_URL (recommended for Vercel + Neon) DATABASE_URL=postgresql://user:password@host:5432/database +# Database TLS policy: only `disable` or `verify-full` are accepted. With a +# production DATABASE_URL and no explicit mode, the default is verify-full. +# Leave empty for the local non-TLS default. Provider URL sslmode parameters and +# PGSSLMODE do not override this resolved policy. +DATABASE_SSL_MODE= +# Optional PEM CA certificate for verify-full. Do not set this with disable. +DATABASE_SSL_CA= # Option 2: Use individual variables (local development with Docker) +# PG* connections remain non-TLS by default, including in production. For a +# remote PG* database, explicitly set DATABASE_SSL_MODE=verify-full. POSTGRES_USER=veerify POSTGRES_PASSWORD=veerifypassword POSTGRES_DB=veerifydb @@ -28,8 +37,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 +60,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 +136,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/coolify-deploy.yml b/.github/workflows/coolify-deploy.yml new file mode 100644 index 00000000..f8bf70f7 --- /dev/null +++ b/.github/workflows/coolify-deploy.yml @@ -0,0 +1,117 @@ +name: Deploy to Coolify + +on: + workflow_dispatch: + inputs: + target: + description: Coolify deployment target + type: choice + required: true + options: + - preview + - production + pull_request_id: + description: Open PR number (required for preview) + type: string + required: false + production_migration_completed: + description: Confirm the reviewed backup and release migration completed (production only) + type: boolean + required: true + default: false + +permissions: + contents: read + pull-requests: read + id-token: write + +concurrency: + group: coolify-${{ inputs.target }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: ${{ inputs.target == 'production' && 'Production' || 'Coolify Preview' }} + steps: + - name: Require trusted default branch workflow + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Dispatch this workflow from the protected main branch." + exit 1 + fi + + - name: Require reviewed production migration + if: inputs.target == 'production' + env: + MIGRATION_COMPLETED: ${{ inputs.production_migration_completed }} + run: | + if [[ "$MIGRATION_COMPLETED" != "true" ]]; then + echo "Production release stopped: take and verify the database backup, then run the reviewed release migration before deploying." + exit 1 + fi + + - name: Validate preview pull request + if: inputs.target == 'preview' + env: + GH_TOKEN: ${{ github.token }} + PULL_REQUEST_ID: ${{ inputs.pull_request_id }} + run: | + if [[ ! "$PULL_REQUEST_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "A positive pull request number is required for preview deployments." + exit 1 + fi + + pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PULL_REQUEST_ID}") + state=$(jq -r '.state' <<<"$pr") + base=$(jq -r '.base.ref' <<<"$pr") + head_repo=$(jq -r '.head.repo.full_name // empty' <<<"$pr") + author_association=$(jq -r '.author_association' <<<"$pr") + + if [[ "$state" != "open" || "$base" != "main" || "$head_repo" != "$GITHUB_REPOSITORY" ]]; then + echo "Preview deployments require an open, same-repository PR targeting main." + exit 1 + fi + + case "$author_association" in + OWNER|MEMBER|COLLABORATOR) ;; + *) echo "Preview deployments are limited to repository collaborators."; exit 1 ;; + esac + + - name: Join the tailnet + uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + audience: ${{ secrets.TS_AUDIENCE }} + tags: tag:ci-coolify-deploy + ping: ${{ vars.COOLIFY_TAILSCALE_HOST }} + + - name: Request Coolify deployment + env: + COOLIFY_API_URL: ${{ vars.COOLIFY_API_URL }} + COOLIFY_APPLICATION_UUID: ${{ vars.COOLIFY_APPLICATION_UUID }} + COOLIFY_API_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }} + TARGET: ${{ inputs.target }} + PULL_REQUEST_ID: ${{ inputs.pull_request_id }} + run: | + if [[ -z "$COOLIFY_API_URL" || -z "$COOLIFY_APPLICATION_UUID" || -z "$COOLIFY_API_TOKEN" ]]; then + echo "Configure the Coolify API URL, application UUID, and API token in this GitHub environment." + exit 1 + fi + + if [[ ! "$COOLIFY_APPLICATION_UUID" =~ ^[A-Za-z0-9-]+$ ]]; then + echo "The Coolify application UUID contains unsupported characters." + exit 1 + fi + + deploy_url="${COOLIFY_API_URL%/}/deploy?uuid=${COOLIFY_APPLICATION_UUID}" + if [[ "$TARGET" == "preview" ]]; then + deploy_url+="&pr=${PULL_REQUEST_ID}" + fi + + curl --fail-with-body --silent --show-error \ + --connect-timeout 10 --max-time 60 \ + --request POST \ + --header "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ + "$deploy_url" diff --git a/.github/workflows/neon.yml b/.github/workflows/neon.yml index afb029f4..4189d1ba 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,40 @@ 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 + DATABASE_SSL_MODE: verify-full + 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 +129,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 +137,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/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..55e696b0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# 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/server/database/connection-config.ts ./server/database/connection-config.ts +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..0ff767fe 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,61 @@ 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 the MinIO bucket, clears bucket-wide anonymous +access, and allows anonymous downloads only under `projects/` for public branding assets. Support mail and +attachment objects remain private even though the MinIO endpoint is reachable through Caddy. It then 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..08b2cf5a 100644 --- a/TODO.md +++ b/TODO.md @@ -175,3 +175,280 @@ 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 + +Completed stage; see `docs/plans/2026-08-11-support-platform/README.md` and `design.md` for retained +architecture notes. 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 + +Completed stage; see `docs/plans/2026-08-11-support-platform/README.md`, `design.md`, and `deltas.md`. +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 + +Completed stage; see `docs/plans/2026-08-11-support-platform/README.md`, `design.md`, and `deltas.md`. + +**Historical note:** the two-agent split was initially proposed, not agreed. Its open questions were +resolved during implementation; see the retained design notes for the resulting decisions. + +**Migration `0025`** belongs to SUP-04-3 and created **two** tables: +`supportOutboundDelivery` 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. See `design.md` and `deltas.md` for retained design rationale. + +**Narrowed by Stage 02:** `firstResponseAt` stamping and the immediate realtime +publish — acceptance criteria 7 and 8 — already shipped in `messages/index.post.ts`. SUP-04-4 +preserved 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 + +Completed stage; see `docs/plans/2026-08-11-support-platform/README.md`, `design.md`, and `deltas.md`. + +**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 agreed normalized-message contract. `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 + +Completed stage; see `docs/plans/2026-08-11-support-platform/README.md`, `design.md`, and +`deltas.md`. 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 was carried into inbound implementation 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 — MVP deployment readiness + +Current priority: verify and deploy the implemented support workflow; advanced reporting is deferred. +Plan: `docs/plans/2026-09-21-coolify-deployment.md`. No production cutover is implied by local work. + +- [x] **MVP-DEP-1** Implement and test a shared verified database TLS policy for runtime, migrations, and domain backfill while retaining explicit local non-TLS setup + - Shared verified TLS policy implemented in `dc104fb` / `ab39787`; independent Luna review passed, including the Drizzle Kit host-credentials fix. The runtime client accepted a temporary local CA, rejected the certificate without it, and used plaintext only with explicit `disable`. The built migrator also rejected the untrusted certificate, then applied all migrations and ran the backfill over verified TLS against a disposable Postgres 17.5 database. Hosted-provider trust remains a staging gate. +- [x] **MVP-DEP-2** Audit container/runtime readiness and record a minimal actionable launch checklist + - Local image `veerify:mvp-local-smoke-c73effb` built successfully without build-time database access. Inspected runtime image: 436 MB, non-root `nuxt`, port 3000; verified TLS helper, Drizzle config, backfill script, and all 45 SQL migration files are present. The image's explicit migration/backfill command succeeded against a disposable TLS database; after that operation, the container started and `/login` returned HTTP 200 using the same verified TLS database connection. Coolify runtime and real storage/mail settings remain to be verified in staging. Set paired Nuxt runtime overrides and direct `MAIL_FROM` used by support fallbacks. +- [ ] **MVP-DEP-3** Verify real inbound/reply/delivery-webhook flow and private attachments in isolated staging + - Needs: isolated staging services and test provider access; never substitute mocked tests for real-provider evidence. +- [x] **MVP-DEP-4** Remove or clearly disable sample analytics on `/reports` before launch, without building new reporting features; verify with Playwright + - Replaced fabricated data and inert controls with an unavailable notice, preserving login protection. Commit `3154f6e`; independent review passed, focused production-preview Playwright 2/2, integrated harness passed. + - Combined TLS/UI production-preview rerun passed 2/2; reports notice visually inspected. General E2E harness guard skipped (not cloud/CI, force unset, configured DB absent); existing two-process realtime integration skipped without `DATABASE_URL`. + +## Support Platform — Stage 09: Reporting execution + +Plan: `docs/plans/2026-08-11-support-platform/stage-09-delivery-plan.md`. +Existing schema/calendar/status-event/CSAT foundations are implemented. The full reporting stage +remains incomplete. The first wave is integrated and verified. Remaining reporting waves are +deferred out of MVP by user direction, not launch blockers; do not dispatch their workers. + +- [x] **SUP-09-1** Add strict local-calendar reporting range validation, with DST, skipped dates, defaults, and bounded ranges +- [x] **SUP-09-2** Add tenant-scoped daily created/resolved/reopened volume reads and transactional recomputation, with real-Postgres concurrency coverage + +Wave 1 evidence (2026-09-22): commits `7da2e12` and `8612896`, independent spec/quality reviews, +and sequential integration harness passes. Combined result: 701 unit, 6 Redis, 133 Postgres tests +passed; Playwright skipped by local-environment guard and the two-process realtime test skipped +without `DATABASE_URL`. No reports API, UI, scheduler, or historical-coverage claims are added yet. + +Coolify staging/cutover planning: `docs/plans/2026-09-21-coolify-deployment.md`. Deployment execution +is separate from this reporting wave; live migration and production cutover have not occurred. + +## Support Platform — Stage 05A: Agent speed (MVP) + +Completed stage; see `docs/plans/2026-08-11-support-platform/README.md`, `design.md`, and `deltas.md`. +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 @@