Skip to content

feat(support): ship support MVP and Coolify-ready deployment path - #47

Open
Andreas-Froyland wants to merge 391 commits into
mainfrom
support-platform
Open

Andreas-Froyland wants to merge 391 commits into
mainfrom
support-platform

Conversation

@Andreas-Froyland

@Andreas-Froyland Andreas-Froyland commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Delivers the support-platform MVP across inbox and conversation workflows, inbound and outbound email, agent-speed tools, feedback bridge, SLA, automations, and CSAT.
  • Retains tested Stage 09 reporting foundations while deferring Wave 2 analytics; the reports page states what is unavailable instead of showing fabricated metrics.
  • Adds production database TLS policy and migration/backfill connection handling, plus a Coolify staging and cutover plan.
  • Removes completed stage plans, per-agent briefs/reports, obsolete unrelated feature plans, and speculative post-MVP drafts. Keeps the durable architecture/deviation notes, active reporting and Coolify plans, provider checklist, and TODO status record.
  • PR diff after cleanup: 475 files, 216,169 additions, 4,097 deletions. Most additions are required database migration SQL/snapshots and product source/tests; generated migration history was retained.

Verification

  • yarn harness:verify on 1e78601: format, typecheck, 716 unit tests, lint (0 errors; existing warnings), Redis integration (6 passed), and Postgres integration (133 passed, 1 guarded realtime skip because DATABASE_URL was unset).
  • Guarded Playwright skipped locally: not cloud/CI, PLAYWRIGHT_FORCE unset, and no database connection configured.
  • Hosted Neon Playwright on source commit 8fbf4ef: 119 passed, 4 skipped, 0 failed. The delayed team-switch regression also passed three consecutive local runs against an isolated disposable database.
  • GitHub build, lint, typecheck, unit tests, and Cubic automated review passed on the prior source commit; checks are rerunning for the doc-only cleanup.

Readiness and deployment boundary

  • The PR remains UNSTABLE while Vercel deployment fails: this project requires subdaily support-worker schedules, while the connected Vercel Hobby plan only accepts daily cron jobs. The schedule was not weakened because that would break processing cadence.
  • No live Coolify deployment is claimed. Real mail-provider, S3, and Coolify staging/cutover validation remain pending in the deployment/provider checklists. Keep Vercel configured until Coolify staging is proven, or use a Vercel plan that supports these schedules.

Andreas-Froyland and others added 30 commits August 23, 2026 00:05
…al contract

Written before SUP-04-7 landed, against a guessed shape. The real composer
POST body (components/support/SupportComposer.vue) and presign response use
{ storageKey, fileName, contentType, sizeBytes } - no cid, since this
composer has no inline-image insertion. Fixed the field name mismatch
(filename -> fileName) that would have made every attachment silently drop
on send (zod strips unknown keys rather than rejecting them).

Also added the per-message attachment total-size enforcement that
parallel-agents.md flags as explicitly this endpoint's job:
MAX_MESSAGE_ATTACHMENT_BYTES (25 MB) is checked here because SUP-04-7's
presign step validates one file at a time and cannot see the others in the
same reply. Extracted as totalAttachmentBytes() in outbound-reply.ts so it
has a real test rather than living untested inside the zod schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…4-11

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fires from the inbound endpoint when an inbound message opens a NEW
conversation on an inbox with autoReplyEnabled. All four required guards:

1. Never on a detected auto-response - enforced by control flow, the
   isAutoResponse check already returns before this code is reached.
2/3. Never on an existing conversation, never more than once per
   conversation - both collapse into isNewConversation (server/utils/
   auto-reply.ts's shouldSendAutoReply): the only way this branch runs
   is "new conversation", which happens at most once by definition.
4. Auto-Submitted: auto-replied set on the outgoing message, so the
   other end's loop detection works.

Plus the fifth requirement from the stage doc, per-contact rate limiting
(1/hour via the existing rate-limit store, keyed by contactId rather than
IP - this isn't a per-request limit).

Reuses buildOutgoingReply (SUP-04-4) for composition rather than a
separate send path, then layers Auto-Submitted on top
(server/utils/auto-reply.ts). Enqueues to the same durable outbox inside
the same transaction as the incoming message and conversation insert.

Two deliberate judgment calls, flagged in parallel-agents.md for Stage 06:
- Does not touch firstResponseAt/lastAgentReplyAt - a system
  acknowledgment is not a substantive agent response, but design.md
  never addresses this interaction directly.
- No sending address or no inbound Message-ID to thread onto: logged
  and skipped rather than failing the webhook, since there is no agent
  to show an error to and the customer's message must still be ticketed.

8 new unit tests (auto-reply.ts's pure composition and guard logic).
The endpoint wiring itself has no dedicated test - same gap as SUP-04-4,
no precedent in this codebase for unit-testing endpoint handlers
directly. harness:verify green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o support-platform

Agent 1's outgoing-reply wiring, note-never-dispatches enforcement, and
auto-reply (all four guards) merged clean; harness:verify green
(E2E/Redis/Postgres skip, no Docker).
…4-10)

Fixes a real gap found while building this: completeOutboundDelivery and
failOutboundDelivery only ever updated supportOutboundDelivery.status, never
conversationMessage.deliveryStatus -- a message would show 'pending' in the
UI forever regardless of whether the send actually succeeded or exhausted
its retries. Both functions (plus resetOutboundDeliveryForRetry) now take
(id, messageId) and update both rows in one transaction, and publish a
realtime event on the conversation/inbox channels so the existing
subscribeConversation reload picks up the change without any client-side
event-type handling.

Adds POST /api/support/conversations/[id]/messages/[messageId]/retry (409s
unless kind:'outgoing' and deliveryStatus:'failed'), and renders all five
deliveryStatus values plus a retry button in SupportMessageItem.vue.

This crosses into server/utils/outbound-delivery.ts (Agent 1's file this
stage) -- flagged and documented in parallel-agents.md, same as Agent 1's
own fix of my SUP-04-7 contract guess in SUP-04-4.
…ply -> customer reply threads back (SUP-04-11)

tests/e2e/support-outbound-reply.spec.ts, guarded like
support-inbound-email.spec.ts (skips without Postmark webhook creds):
reply-before-sending-address is 409, a note with attachments is 400, an
outgoing reply's channelMessageId is set synchronously, deliveryStatus
leaves 'pending' within 5s (regression coverage for the SUP-04-10 bug this
stage found), and a simulated customer reply referencing that
channelMessageId lands on the same conversation rather than opening a new
one -- the stage's headline threading risk, exercised end to end for the
first time rather than only unit-tested on the composition side.

Written and typechecked/linted clean; not executed this session -- no
Docker, no reachable Postgres, no Postmark credentials on this box, same as
every other guarded suite this stage (and the same state SUP-03-14 was
explicitly flagged in). Descopes acceptance criterion 1 (Gmail/Outlook
threading) and "contact has no email" (unreachable via the email channel)
deliberately, per parallel-agents.md.
Landed in 68d104b. Written, not executed this session -- no Docker.
… (SUP-04-9)

POST /api/support/delivery/[provider] maps Postmark/Mailgun delivery,
bounce, and engagement events onto conversationMessage.deliveryStatus.
A hard bounce marks the message bounced and writes a visible activity
line (both in one transaction); a soft bounce is recorded but leaves
deliveryStatus untouched, since the provider's own SMTP retry is still
in flight and the message already left our outbox successfully.

ChannelDriver grows parseDeliveryEvent/extractDeliveryEventId on both
drivers. verifySignature/isConfigured are reused unchanged: both
providers protect every webhook URL on an account identically
(Postmark: Basic Auth in the URL; Mailgun: the HMAC envelope), so
there is nothing delivery-specific to verify differently.

Parsing here is pure and in-memory (no raw-body archival, unlike
inbound), so it happens before the claim rather than after -
recordType/recipient are real NOT NULL columns and are already known
by claim time, which avoids a second migration.

THE BIGGEST UNCONFIRMED ASSUMPTION THIS STAGE: DeliveryEvent.messageId
is read from whatever field each provider's webhook uses to identify
the original message, on the assumption it equals the RFC Message-ID
this app set when sending. That is documented as true for a provider's
HTTP send API. It has NOT been verified for SMTP relay, which is what
this app actually uses (lib/email.ts -> nodemailer -> SMTP_HOST) - a
provider receiving mail over SMTP may track by an internal id that
never appears in the RFC header at all. If so, delivery-status tracking
silently correlates nothing, regardless of how correct the rest of this
code is. Handled as data, not an error, matching D-35's precedent
(messageId: null, recorded and returned 200) - but this needs an
empirical check against a real send before anyone trusts it. Full
reasoning in parallel-agents.md.

Two more judgment calls, both flagged in code and doc:
- Bounce types this driver does not specifically recognize default to
  hard, not soft - design.md's stated priority is that silent delivery
  failure is worse than a visible error.
- Engagement events (opened/clicked/spam_complaint) are recorded for
  audit and future stages but do not change deliveryStatus - out of
  this stage's acceptance criteria.

Found and fixed one real bug in review before it shipped: a test
fixture used a Postmark bounce ID (4323372036854775807) large enough
to lose precision as a float64, silently producing the wrong
idempotency key. Not a real Postmark ID - my own fixture mistake.
Replaced with a realistic magnitude; flagged the theoretical risk in
a comment since JSON.parse has already parsed the number by the time
this code sees it.

14 new unit tests, fixture-based against documented Postmark/Mailgun
payload shapes (support-channels-delivery.test.ts). 5 new guarded
integration tests for the claim/lease atomicity (delivery-events.test.ts),
NOT executed against real Postgres this session - no Docker on this
box, same gap as every other guarded suite this stage. Verified
structural correctness via typecheck and a real connection attempt
(ECONNREFUSED, not an import error). harness:verify green (402 total
unit tests via the default runner).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every checkbox in parallel-agents.md's work split is now checked. The
SMTP-relay message-id correlation assumption is flagged as the
highest-priority thing to verify against a real send before trusting
delivery-status tracking in production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found running the suite against real Postgres (Docker up for the first
time this session): "enqueues a pending row..." and "rejects a second
enqueue..." each left their supportOutboundDelivery row 'pending' with no
cleanup. claimNextOutboundDelivery takes the oldest pending row across the
whole table, unscoped to a test, so those leftovers got claimed ahead of
the next test's own row -- "claims the oldest pending row" and "does not
reclaim a row whose lease is still live" failed with a messageId mismatch
that had nothing to do with the code under test. Both tests now complete
their row, matching the cleanup pattern already used elsewhere in this
file. All 8 tests pass against real Postgres after the fix.

README.md's status table is auto-updated by the harness docs-map check.
Agent 1's delivery/bounce webhook (2f0503c, fd01de5) merged clean; verified
against real Postgres/Redis this session (Docker up) -- 34/34 Postgres
integration tests pass including delivery-events.test.ts's real-concurrency
race test, 6/6 Redis integration tests pass, harness:verify green
end-to-end.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/e2e/support-outbound-reply.spec.ts">

<violation number="1" location="tests/e2e/support-outbound-reply.spec.ts:668">
P3: `await firstProxyUploadStarted` has no timeout. If the first proxy upload never reaches the route handler (e.g., a client regression flips the attachment to `failed`, or the real presign starts returning a direct URL in the test environment), this promise never resolves and the test — and everything after it in the `test.describe.serial` block — hangs until the test timeout (60s CI / 90s local). The assertion it replaced (`toBeVisible()` with the default 10-15s expect timeout) would have failed fast with a diagnostic. Race the promise against a timeout so the failure is reported as a clear assertion error instead of a hang.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

await expect(composer).toBeVisible({ timeout: 30_000 })
const input = page.locator('[data-testid="support-composer-file-input"]')
await input.setInputFiles({ name: 'contract.txt', mimeType: 'text/plain', buffer: Buffer.from('proxy bytes') })
await firstProxyUploadStarted

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: await firstProxyUploadStarted has no timeout. If the first proxy upload never reaches the route handler (e.g., a client regression flips the attachment to failed, or the real presign starts returning a direct URL in the test environment), this promise never resolves and the test — and everything after it in the test.describe.serial block — hangs until the test timeout (60s CI / 90s local). The assertion it replaced (toBeVisible() with the default 10-15s expect timeout) would have failed fast with a diagnostic. Race the promise against a timeout so the failure is reported as a clear assertion error instead of a hang.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/e2e/support-outbound-reply.spec.ts, line 668:

<comment>`await firstProxyUploadStarted` has no timeout. If the first proxy upload never reaches the route handler (e.g., a client regression flips the attachment to `failed`, or the real presign starts returning a direct URL in the test environment), this promise never resolves and the test — and everything after it in the `test.describe.serial` block — hangs until the test timeout (60s CI / 90s local). The assertion it replaced (`toBeVisible()` with the default 10-15s expect timeout) would have failed fast with a diagnostic. Race the promise against a timeout so the failure is reported as a clear assertion error instead of a hang.</comment>

<file context>
@@ -648,9 +665,14 @@ test.describe.serial('outbound attachment contract', () => {
-      await expect(
-        page.locator('[data-testid^="support-composer-attachment-"][data-phase="uploading"]').first()
-      ).toBeVisible()
+      await firstProxyUploadStarted
+      try {
+        await expect(
</file context>
Suggested change
await firstProxyUploadStarted
await Promise.race([
firstProxyUploadStarted,
new Promise((_, reject) => setTimeout(() => reject(new Error('proxy upload never started')), 10_000)),
])

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7e7959e. The upload route counter is now awaited with Playwright expect.poll({ timeout: 10_000 }), so missing proxy interception fails with a bounded diagnostic instead of hanging the serial suite. The targeted attachment workflow and full harness pass.

@Andreas-Froyland Andreas-Froyland changed the title feat(reporting): build Stage 09 reporting foundations feat(support): ship support MVP and prepare Coolify deployment Sep 23, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 23 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/plans/2026-09-21-coolify-deployment.md">

<violation number="1" location="docs/plans/2026-09-21-coolify-deployment.md:86">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

The `NUXT_NODEMAILER` JSON override cannot add missing SMTP keys or `auth` at runtime. This app defines Nodemailer from `MAIL_FROM`/`SMTP_*`, and the module supports individual `NUXT_NODEMAILER_*` overrides only for options already present; document the supported variables or change the runtime configuration to define those options unconditionally.</violation>
</file>

<file name="server/database/connection-config.ts">

<violation number="1" location="server/database/connection-config.ts:85">
P2: Malformed query ports can silently fall back to a valid prefix, potentially connecting migrations and the app to the wrong PostgreSQL endpoint. Parse the entire value and require an integer in the valid TCP port range before constructing the config.</violation>

<violation number="2" location="server/database/connection-config.ts:90">
P2: Database names containing URL-encoded reserved characters are passed to PostgreSQL with the percent escapes still present. Decode this already-isolated path component with `decodeURIComponent` so valid `DATABASE_URL` database names resolve correctly.</violation>
</file>

<file name="docs/plans/2026-08-11-support-platform/stage-09-wave-1-implementation.md">

<violation number="1" location="docs/plans/2026-08-11-support-platform/stage-09-wave-1-implementation.md:62">
P2: This plan is committed with every task still unchecked and instructions to "Create ..." and "confirm the missing behavior", yet the deliverables it describes already exist and pass at HEAD (support-reporting-range.ts, support-reporting-volume.ts, and their tests). A future dispatcher following the README dispatch protocol would treat Wave 1 as pending and re-dispatch or re-verify it. Mark the plan as complete/superseded — mirror the status line used in stage-09-semantics.md — or note that Wave 1 shipped and only Wave 2 remains open.</violation>
</file>

<file name="tests/integration/support-reporting-volume.test.ts">

<violation number="1" location="tests/integration/support-reporting-volume.test.ts:389">
P3: The sticky `mockReturnValue` is only undone on the success path. If the injected recompute ever stops throwing (any behavior change to `randomUUID` usage or validation order), the `rejects.toThrow()` assertion fails, the restore line is skipped, and every later `recomputeSupportVolumeDay` call in this file generates the duplicate id `unrelated_${suffix}`, failing with an unrelated duplicate-key error that obscures the real failure. Restore in `try/finally`, or use `mockImplementationOnce` for a single poisoned UUID.</violation>
</file>

<file name="tests/database-connection-config.test.ts">

<violation number="1" location="tests/database-connection-config.test.ts:29">
P3: This guard isolates the `playwright_e2e` job with whitespace-exact string splits (`'  playwright_e2e:'` / `'\n  delete_neon_branch:'`) of a YAML file. Renaming or reordering `delete_neon_branch`, or reformatting job indentation, silently degrades the split (the assertion then scans the rest of the file, or the test fails spuriously), so the TLS regression guard becomes brittle. Since the goal is only to prevent relaxing `DATABASE_SSL_MODE` in this workflow, assert on the whole file instead of parsing job boundaries by exact indentation.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Use `.env.example` for local/build variables and direct process-environment consumers; this plan
documents the production Nuxt runtime mappings. `NUXT_STORAGE_DIRECT_UPLOAD_CONSTRAINTS` defaults to
`proxy-required`; retain that unless the chosen S3 provider's constraint enforcement is verified.
The parent `NUXT_NODEMAILER` JSON override supplies keys even when a secret-free build omitted

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

The NUXT_NODEMAILER JSON override cannot add missing SMTP keys or auth at runtime. This app defines Nodemailer from MAIL_FROM/SMTP_*, and the module supports individual NUXT_NODEMAILER_* overrides only for options already present; document the supported variables or change the runtime configuration to define those options unconditionally.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/plans/2026-09-21-coolify-deployment.md, line 86:

<comment>The `NUXT_NODEMAILER` JSON override cannot add missing SMTP keys or `auth` at runtime. This app defines Nodemailer from `MAIL_FROM`/`SMTP_*`, and the module supports individual `NUXT_NODEMAILER_*` overrides only for options already present; document the supported variables or change the runtime configuration to define those options unconditionally.</comment>

<file context>
@@ -0,0 +1,154 @@
+Use `.env.example` for local/build variables and direct process-environment consumers; this plan
+documents the production Nuxt runtime mappings. `NUXT_STORAGE_DIRECT_UPLOAD_CONSTRAINTS` defaults to
+`proxy-required`; retain that unless the chosen S3 provider's constraint enforcement is verified.
+The parent `NUXT_NODEMAILER` JSON override supplies keys even when a secret-free build omitted
+`auth` or undefined SMTP defaults. Nested overrides alone cannot introduce absent keys. Use a full
+mailbox string such as `Veerify <noreply@example.com>` for `from`; `MAIL_FROM_NAME` is not mapped by
</file context>

Comment thread server/database/connection-config.ts Outdated
const password = queryPassword || decodeUrlPart(url.password)
const host = (queryHost || url.hostname).replace(/^\[|\]$/g, '')
const portValue = queryPort || url.port || env.PGPORT || '5432'
const port = Number.parseInt(portValue, 10)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Malformed query ports can silently fall back to a valid prefix, potentially connecting migrations and the app to the wrong PostgreSQL endpoint. Parse the entire value and require an integer in the valid TCP port range before constructing the config.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/database/connection-config.ts, line 85:

<comment>Malformed query ports can silently fall back to a valid prefix, potentially connecting migrations and the app to the wrong PostgreSQL endpoint. Parse the entire value and require an integer in the valid TCP port range before constructing the config.</comment>

<file context>
@@ -0,0 +1,143 @@
+  const password = queryPassword || decodeUrlPart(url.password)
+  const host = (queryHost || url.hostname).replace(/^\[|\]$/g, '')
+  const portValue = queryPort || url.port || env.PGPORT || '5432'
+  const port = Number.parseInt(portValue, 10)
+  if (!Number.isFinite(port)) throw new Error('DATABASE_URL must be a valid PostgreSQL URL or Unix socket path')
+
</file context>

Comment thread server/database/connection-config.ts Outdated

const databasePath = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname
const database =
decodeUrlPart(databasePath, decodeURI) || env.PGDATABASE || user || env.PGUSER || env.USER || 'veerifydb'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Database names containing URL-encoded reserved characters are passed to PostgreSQL with the percent escapes still present. Decode this already-isolated path component with decodeURIComponent so valid DATABASE_URL database names resolve correctly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/database/connection-config.ts, line 90:

<comment>Database names containing URL-encoded reserved characters are passed to PostgreSQL with the percent escapes still present. Decode this already-isolated path component with `decodeURIComponent` so valid `DATABASE_URL` database names resolve correctly.</comment>

<file context>
@@ -0,0 +1,143 @@
+
+  const databasePath = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname
+  const database =
+    decodeUrlPart(databasePath, decodeURI) || env.PGDATABASE || user || env.PGUSER || env.USER || 'veerifydb'
+
+  return {
</file context>

expect(range.days.map((day) => day.date)).toEqual(['2026-03-08'])
```

- [ ] Run `yarn vitest run tests/support-reporting-range.test.ts` and confirm the missing behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This plan is committed with every task still unchecked and instructions to "Create ..." and "confirm the missing behavior", yet the deliverables it describes already exist and pass at HEAD (support-reporting-range.ts, support-reporting-volume.ts, and their tests). A future dispatcher following the README dispatch protocol would treat Wave 1 as pending and re-dispatch or re-verify it. Mark the plan as complete/superseded — mirror the status line used in stage-09-semantics.md — or note that Wave 1 shipped and only Wave 2 remains open.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/plans/2026-08-11-support-platform/stage-09-wave-1-implementation.md, line 62:

<comment>This plan is committed with every task still unchecked and instructions to "Create ..." and "confirm the missing behavior", yet the deliverables it describes already exist and pass at HEAD (support-reporting-range.ts, support-reporting-volume.ts, and their tests). A future dispatcher following the README dispatch protocol would treat Wave 1 as pending and re-dispatch or re-verify it. Mark the plan as complete/superseded — mirror the status line used in stage-09-semantics.md — or note that Wave 1 shipped and only Wave 2 remains open.</comment>

<file context>
@@ -0,0 +1,124 @@
+expect(range.days.map((day) => day.date)).toEqual(['2026-03-08'])
+```
+
+- [ ] Run `yarn vitest run tests/support-reporting-range.test.ts` and confirm the missing behavior.
+- [ ] Implement using `reportingDateAt`/`reportingDayBounds`. Validate date strings before using
+      UTC calendar arithmetic to enumerate labels (not instants). Count the 366 limit in civil date
</file context>

.from(supportMetricDaily)
.where(eq(supportMetricDaily.teamId, ids.team))
.orderBy(asc(supportMetricDaily.id))
randomUuidMock.fn.mockReturnValue(`unrelated_${suffix}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The sticky mockReturnValue is only undone on the success path. If the injected recompute ever stops throwing (any behavior change to randomUUID usage or validation order), the rejects.toThrow() assertion fails, the restore line is skipped, and every later recomputeSupportVolumeDay call in this file generates the duplicate id unrelated_${suffix}, failing with an unrelated duplicate-key error that obscures the real failure. Restore in try/finally, or use mockImplementationOnce for a single poisoned UUID.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/integration/support-reporting-volume.test.ts, line 389:

<comment>The sticky `mockReturnValue` is only undone on the success path. If the injected recompute ever stops throwing (any behavior change to `randomUUID` usage or validation order), the `rejects.toThrow()` assertion fails, the restore line is skipped, and every later `recomputeSupportVolumeDay` call in this file generates the duplicate id `unrelated_${suffix}`, failing with an unrelated duplicate-key error that obscures the real failure. Restore in `try/finally`, or use `mockImplementationOnce` for a single poisoned UUID.</comment>

<file context>
@@ -0,0 +1,427 @@
+      .from(supportMetricDaily)
+      .where(eq(supportMetricDaily.teamId, ids.team))
+      .orderBy(asc(supportMetricDaily.id))
+    randomUuidMock.fn.mockReturnValue(`unrelated_${suffix}`)
+    await expect(recomputeSupportVolumeDay({ teamId: ids.team, date, timezone })).rejects.toThrow()
+    randomUuidMock.fn.mockImplementation(randomUuidMock.generate)
</file context>

describe('database connection TLS configuration', () => {
it('requires verified TLS for the Neon pull-request test database', () => {
const workflow = readFileSync(new URL('../.github/workflows/neon.yml', import.meta.url), 'utf8')
const playwrightJob = workflow.split(' playwright_e2e:')[1]?.split('\n delete_neon_branch:')[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This guard isolates the playwright_e2e job with whitespace-exact string splits (' playwright_e2e:' / '\n delete_neon_branch:') of a YAML file. Renaming or reordering delete_neon_branch, or reformatting job indentation, silently degrades the split (the assertion then scans the rest of the file, or the test fails spuriously), so the TLS regression guard becomes brittle. Since the goal is only to prevent relaxing DATABASE_SSL_MODE in this workflow, assert on the whole file instead of parsing job boundaries by exact indentation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/database-connection-config.test.ts, line 29:

<comment>This guard isolates the `playwright_e2e` job with whitespace-exact string splits (`'  playwright_e2e:'` / `'\n  delete_neon_branch:'`) of a YAML file. Renaming or reordering `delete_neon_branch`, or reformatting job indentation, silently degrades the split (the assertion then scans the rest of the file, or the test fails spuriously), so the TLS regression guard becomes brittle. Since the goal is only to prevent relaxing `DATABASE_SSL_MODE` in this workflow, assert on the whole file instead of parsing job boundaries by exact indentation.</comment>

<file context>
@@ -0,0 +1,264 @@
+describe('database connection TLS configuration', () => {
+  it('requires verified TLS for the Neon pull-request test database', () => {
+    const workflow = readFileSync(new URL('../.github/workflows/neon.yml', import.meta.url), 'utf8')
+    const playwrightJob = workflow.split('  playwright_e2e:')[1]?.split('\n  delete_neon_branch:')[0]
+
+    expect(playwrightJob).toMatch(/^\s+DATABASE_SSL_MODE:\s*verify-full\s*$/m)
</file context>

@Andreas-Froyland Andreas-Froyland changed the title feat(support): ship support MVP and prepare Coolify deployment feat(support): ship support MVP and Coolify-ready deployment path Sep 23, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/e2e/support-permissions.spec.ts">

<violation number="1" location="tests/e2e/support-permissions.spec.ts:618">
P2: The listener-readiness guard keys on the wrong component. The init-script marker is set by any `window` listener for `veerify:active-team-changed`, and AppSidebar (layouts/dashboard.vue) registers exactly that event on mount — before `pages/support/index.vue` does, since its listener is added only after the async `initTeamContext()` (including the unmocked csat round trip) resolves. `waitForFunction` can therefore pass while the support index handler is still unattached, and the two `window.dispatchEvent` calls can be lost, leaving the serial suite hung on `await oldListReady` — the race the two stabilization commits were meant to close. Make readiness specific to the support index listener: have `mounted()` in pages/support/index.vue set its own data attribute after registering the handler, and have the test wait on that instead of patching `addEventListener`.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread tests/e2e/support-permissions.spec.ts Outdated
)

await page.reload({ waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => document.documentElement.dataset.supportActiveTeamListenerReady === 'true')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The listener-readiness guard keys on the wrong component. The init-script marker is set by any window listener for veerify:active-team-changed, and AppSidebar (layouts/dashboard.vue) registers exactly that event on mount — before pages/support/index.vue does, since its listener is added only after the async initTeamContext() (including the unmocked csat round trip) resolves. waitForFunction can therefore pass while the support index handler is still unattached, and the two window.dispatchEvent calls can be lost, leaving the serial suite hung on await oldListReady — the race the two stabilization commits were meant to close. Make readiness specific to the support index listener: have mounted() in pages/support/index.vue set its own data attribute after registering the handler, and have the test wait on that instead of patching addEventListener.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/e2e/support-permissions.spec.ts, line 618:

<comment>The listener-readiness guard keys on the wrong component. The init-script marker is set by any `window` listener for `veerify:active-team-changed`, and AppSidebar (layouts/dashboard.vue) registers exactly that event on mount — before `pages/support/index.vue` does, since its listener is added only after the async `initTeamContext()` (including the unmocked csat round trip) resolves. `waitForFunction` can therefore pass while the support index handler is still unattached, and the two `window.dispatchEvent` calls can be lost, leaving the serial suite hung on `await oldListReady` — the race the two stabilization commits were meant to close. Make readiness specific to the support index listener: have `mounted()` in pages/support/index.vue set its own data attribute after registering the handler, and have the test wait on that instead of patching `addEventListener`.</comment>

<file context>
@@ -606,6 +614,9 @@ test.describe.serial('support permission-aware navigation', () => {
     )
 
+    await page.reload({ waitUntil: 'domcontentloaded' })
+    await page.waitForFunction(() => document.documentElement.dataset.supportActiveTeamListenerReady === 'true')
+    await expect(page.getByTestId(`support-inbox-switch-${fixture.primaryInboxId}`)).toBeVisible()
     activeTeamTransition = 1
</file context>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and 6 new issues found across 78 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/utils/inbound-threading.ts">

<violation number="1" location="server/utils/inbound-threading.ts:9">
P3: Custom agent: **Flag AI Slop and Fabricated Changes**

This comment repeats the same asymmetric-failure explanation twice. Remove the first duplicate sentences or fold the rationale into one occurrence so the comment is cohesive.</violation>

<violation number="2" location="server/utils/inbound-threading.ts:26">
P3: The `ThreadableMessage` comment is now a broken, duplicated sentence: "An `InboundMessage` satisfies this shape, so the call site in The inbound adapter satisfies this shape — …". Removing the doc reference left the clause "so the call site in" dangling, and the shape explanation is stated twice. Drop the leftover clause so the paragraph starts with "The inbound adapter satisfies this shape …".</violation>
</file>

<file name="server/utils/outbound-delivery.ts">

<violation number="1" location="server/utils/outbound-delivery.ts:289">
P3: This comment now reads as a broken sentence: "See the initial implementation rationale is retained in the support platform design notes." The mid-sentence doc rewrite dropped the original "landing note in `parallel-agents.md` for the full story" framing and left a non-parseable clause. Rejoin it as a continuation of "See the".</violation>
</file>

<file name="server/database/schema/support.ts">

<violation number="1" location="server/database/schema/support.ts:840">
P3: The comment edit removed the `See parallel-agents.md, "` opening of the quoted phrase but left its closing quote on the next line, so the comment now reads `the delivery webhook gets its own table", and delta D-35...` with a dangling `"` and a broken quote pair. Drop the stray quote (and the now-unneeded comma) so the comment reads as plain prose.</violation>
</file>

<file name="TODO.md">

<violation number="1" location="TODO.md:224">
P2: This block now contradicts the new 'Completed stage' label directly above it: it still says migration `0025` is 'One migration expected' and that `supportOutboundDelivery` / `supportDeliveryEvent` are 'never created; the schema is at `0024`'. `server/database/migrations/0025_amazing_gabe_jones.sql` already creates both tables (and both exist in `server/database/schema/support.ts`), so this text is stale plan prose that will mislead anyone following the stage record. Rewrite the block to state that 0025 shipped both tables.</violation>

<violation number="2" location="TODO.md:349">
P3: Dangling 'first' left over from the old 'Read `design.md` and `deltas.md` first' phrasing. The line now reads 'Completed stage; see ... `deltas.md` first.', and the Stage 05A rewrite already dropped the word. Remove it.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread TODO.md

## Support Platform — Stage 01: Contact identity

Completed stage; see `docs/plans/2026-08-11-support-platform/README.md`, `design.md`, and `deltas.md`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This block now contradicts the new 'Completed stage' label directly above it: it still says migration 0025 is 'One migration expected' and that supportOutboundDelivery / supportDeliveryEvent are 'never created; the schema is at 0024'. server/database/migrations/0025_amazing_gabe_jones.sql already creates both tables (and both exist in server/database/schema/support.ts), so this text is stale plan prose that will mislead anyone following the stage record. Rewrite the block to state that 0025 shipped both tables.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At TODO.md, line 224:

<comment>This block now contradicts the new 'Completed stage' label directly above it: it still says migration `0025` is 'One migration expected' and that `supportOutboundDelivery` / `supportDeliveryEvent` are 'never created; the schema is at `0024`'. `server/database/migrations/0025_amazing_gabe_jones.sql` already creates both tables (and both exist in `server/database/schema/support.ts`), so this text is stale plan prose that will mislead anyone following the stage record. Rewrite the block to state that 0025 shipped both tables.</comment>

<file context>
@@ -221,8 +221,8 @@ directory first. Infrastructure only — no support tables, endpoints, or UI in
 
-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).
+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).
 
</file context>

* Threading resolution for inbound mail — deciding whether a message continues
* an existing conversation or starts a new one.
*
* A missed match splits one conversation into two, which an agent can merge,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Flag AI Slop and Fabricated Changes

This comment repeats the same asymmetric-failure explanation twice. Remove the first duplicate sentences or fold the rationale into one occurrence so the comment is cohesive.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/utils/inbound-threading.ts, line 9:

<comment>This comment repeats the same asymmetric-failure explanation twice. Remove the first duplicate sentences or fold the rationale into one occurrence so the comment is cohesive.</comment>

<file context>
@@ -6,8 +6,9 @@ import { conversation, conversationMessage } from '~/server/database/schema/supp
  *
- * `stage-03-inbound-email.md` calls this "the classic source of duplicate
- * tickets", and the failure is asymmetric: a missed match splits one
+ * A missed match splits one conversation into two, which an agent can merge,
+ * while a wrong match shows one customer another customer's correspondence.
+ * The failure is asymmetric: a missed match splits one
</file context>

* deliveryStatus` past its initial insert-time value - `completeOutboundDelivery`
* and `failOutboundDelivery` only touched the outbox row. A message would sit
* at `'pending'` forever regardless of whether it actually sent. See the
* initial implementation rationale is retained in the support platform design notes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This comment now reads as a broken sentence: "See the initial implementation rationale is retained in the support platform design notes." The mid-sentence doc rewrite dropped the original "landing note in parallel-agents.md for the full story" framing and left a non-parseable clause. Rejoin it as a continuation of "See the".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/utils/outbound-delivery.ts, line 289:

<comment>This comment now reads as a broken sentence: "See the initial implementation rationale is retained in the support platform design notes." The mid-sentence doc rewrite dropped the original "landing note in `parallel-agents.md` for the full story" framing and left a non-parseable clause. Rejoin it as a continuation of "See the".</comment>

<file context>
@@ -286,7 +286,7 @@ export async function claimNextOutboundDelivery(options: { now?: Date } = {}): P
  * and `failOutboundDelivery` only touched the outbox row. A message would sit
  * at `'pending'` forever regardless of whether it actually sent. See the
- * landing note in `parallel-agents.md` for the full story.
+ * initial implementation rationale is retained in the support platform design notes.
  */
 async function applyDeliveryOutcome(input: {
</file context>
Suggested change
* initial implementation rationale is retained in the support platform design notes.
* initial implementation rationale in the support platform design notes.

Comment on lines +840 to +841
// to catch. The delivery webhook gets its own
// table", and delta D-35 for why `messageId` below is nullable for the same

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The comment edit removed the See parallel-agents.md, " opening of the quoted phrase but left its closing quote on the next line, so the comment now reads the delivery webhook gets its own table", and delta D-35... with a dangling " and a broken quote pair. Drop the stray quote (and the now-unneeded comma) so the comment reads as plain prose.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/database/schema/support.ts, line 840:

<comment>The comment edit removed the `See parallel-agents.md, "` opening of the quoted phrase but left its closing quote on the next line, so the comment now reads `the delivery webhook gets its own table", and delta D-35...` with a dangling `"` and a broken quote pair. Drop the stray quote (and the now-unneeded comma) so the comment reads as plain prose.</comment>

<file context>
@@ -837,7 +837,7 @@ export const supportOutboundDelivery = pgTable(
 // SpamComplaint). Sharing the key would silently swallow every event after
 // the first - including the hard bounce that acceptance criterion 6 exists
-// to catch. See `parallel-agents.md`, "the delivery webhook gets its own
+// to catch. The delivery webhook gets its own
 // table", and delta D-35 for why `messageId` below is nullable for the same
 // reason `supportEmailEvent.inboxId` is.
</file context>
Suggested change
// to catch. The delivery webhook gets its own
// table", and delta D-35 for why `messageId` below is nullable for the same
// to catch. The delivery webhook gets its own
// table, and delta D-35 for why `messageId` below is nullable for the same

* **Deliberately structural rather than importing `InboundMessage`** from
* `server/services/support-channels/types.ts` (Agent 1's file). An
* `InboundMessage` satisfies this shape, so the call site in
* The inbound adapter satisfies this shape — but `server/utils/` does not take a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The ThreadableMessage comment is now a broken, duplicated sentence: "An InboundMessage satisfies this shape, so the call site in The inbound adapter satisfies this shape — …". Removing the doc reference left the clause "so the call site in" dangling, and the shape explanation is stated twice. Drop the leftover clause so the paragraph starts with "The inbound adapter satisfies this shape …".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/utils/inbound-threading.ts, line 26:

<comment>The `ThreadableMessage` comment is now a broken, duplicated sentence: "An `InboundMessage` satisfies this shape, so the call site in The inbound adapter satisfies this shape — …". Removing the doc reference left the clause "so the call site in" dangling, and the shape explanation is stated twice. Drop the leftover clause so the paragraph starts with "The inbound adapter satisfies this shape …".</comment>

<file context>
@@ -22,7 +23,7 @@ type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
  * `server/services/support-channels/types.ts` (Agent 1's file). An
  * `InboundMessage` satisfies this shape, so the call site in
- * `parallel-agents.md` compiles unchanged — but `server/utils/` does not take a
+ * The inbound adapter satisfies this shape — but `server/utils/` does not take a
  * dependency on `server/services/support-channels/`, and these tests need only
  * four fields rather than a whole normalized message. Flagged to Agent 1 rather
</file context>

Comment thread TODO.md

## Support Platform — Stage 02: Inbox + conversation core

Completed stage; see `docs/plans/2026-08-11-support-platform/README.md`, `design.md`, and

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Dangling 'first' left over from the old 'Read design.md and deltas.md first' phrasing. The line now reads 'Completed stage; see ... deltas.md first.', and the Stage 05A rewrite already dropped the word. Remove it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At TODO.md, line 349:

<comment>Dangling 'first' left over from the old 'Read `design.md` and `deltas.md` first' phrasing. The line now reads 'Completed stage; see ... `deltas.md` first.', and the Stage 05A rewrite already dropped the word. Remove it.</comment>

<file context>
@@ -350,7 +346,7 @@ Plan: `docs/plans/2026-08-11-support-platform/stage-03-inbound-email.md`. Read `
 ## Support Platform — Stage 02: Inbox + conversation core
 
-Plan: `docs/plans/2026-08-11-support-platform/stage-02-conversation-core.md`. Read `design.md` and
+Completed stage; see `docs/plans/2026-08-11-support-platform/README.md`, `design.md`, and
 `deltas.md` first. Integration branch is **`support-platform`**.
 
</file context>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant