release: dev -> prod 2026-09-01 - #1310
Conversation
Two scopes, one channel-agnostic engine (lib/support/): - Per-appointment threads: 10 stage-gated flowcharts (no-show attendee/ provider variants, payment status, recording access, quality, technical, org-party dispute), machine-readable escalation reasons, reason->priority policy, org-attributed ticket hand-off into the existing ops queue. - Platform intake: stateless flowchart (server-validated cursor) ending in self-serve answers or a SupportTicket via the shared createSupportTicket factory; session-scoped issue types rejected (422) on the platform route. Surfaces: SupportHub (Sessions/Platform subtabs, status buckets, last-activity sort) in consultee/consultant/org-workspace trees; inline thread status card + Get help on appointment detail; back-office Conversations inbox (threads.manage) with AGENT reply mirroring and CAS status sync both ways; org triage page (metadata-only per ADR 20) with CSAT aggregate card and the org-party dispute entry. ReportIssueDialog retired; legacy no-show path unified into the flowchart thread. Schema (additive-only under #705 freeze): SupportTicket.organizationId + index, lastMessageAt on SupportTicket and AppointmentSupportThread. Notifications carry NotificationScope (ADR 23). Security suite pins the org metadata allowlist; ADR-20 addendum documents the boundary.
…dialog, org-session picker - Both support sheets: proper padding (header/body/composer were edge-to-edge — the ui/sheet variant ships no p-*). - SupportThreadSheet gains appointmentHref → 'Go to appointment' header button; wired from the hub (personal trees) and the consultee adapter. Deliberately withheld on org-scoped rows and org surfaces (ADR 20: no per-session detail page for org roles). - Platform tab: explicit 'New request' dialog (CreateTicketDialog) with the platform-only issue taxonomy — the old form died with the requests page the hub replaced; server 422 remains the backstop. - Role gap found in audit: personal scope pins organizationId:null, so an org LEARNER/EXPERT had NO per-session support entry point. The hub now merges orgMember-scoped sessions for ACTIVE memberships (new GET /api/user/org-memberships), tags them with the org name, and sorts the merged picker by slot date.
…d reply mirror Three bugs from live testing of the thread sheet: 1. Inputs looked ignored / prompts repeated forever: threads created before the flow rewrite carry cursors (and rendered option ids) that no longer exist in the registry — every choice mismatched and re-presented. The service now restarts at the entry when the persisted cursor isn't in the current flow (self-healing), and clicking an intent chip always restarts its flow instead of resuming a stale cursor. 2. Double bubble on one press: a second POST could land in the refetch window after isPending cleared but before options swapped. Server side, a re-present (cursor didn't move) is no longer persisted when the tail already is that prompt — one press can never append two identical bubbles. Client side, controls stay disabled until the refetch round-trips (turnPending = isPending || isSuccess&&isFetching). 3. 'Other side's replies' invisible: staff replying from the TICKET queue never reached the thread (only the Conversations-inbox path mirrored). Public ticket responses now mirror into the linked thread as AGENT messages + lastMessageAt bump; internal notes stay ticket-only. The two reply paths (ticket→thread, thread→ticket) now keep one history.
…onal
- platform intake route: add the missing supportError import, hoist flowId
above the try so the catch can tag Sentry context (was TS2304 x8), drop
the now-unused Sentry import
- lib/api/support-http.ts: one envelope for the support surface — user-safe
copy + machine code + developer detail, with the Sentry policy (capture
the original exception with stack for 5xx; warnings for contract-drift
4xx; 401/429 are expected noise and stay uncaptured)
- SupportHub: remove {profileId ? null : null} (Sonar S3923) and the dead
profileId plumbing through SessionsTab; prop stays in the public hub
signature for caller parity
Sonar's duplication gate failed on the twin authorize copies in the support + feedback routes. They collapse into lib/api/appointment-access.ts: - authorizeAppointment(appointmentId) — participation check + privileged + org-party branch, opt-in via a typed flag so routes with no org-party surface (detail, feedback) cannot silently accept the operator grant (ADR 20). Success carries organizationId (CSAT attribution) and the already-loaded detail, so the detail route no longer pays a second read. - supportError gains parseRouteParams: schema-parse route params once, answer INVALID_ID envelope on failure — replaces the copy-pasted blocks. Param schemas move from inline z.object duplicates to schemas/support.ts. Ids are length-bounded opaque strings (uuid in prod, readable slugs in seeded demo DBs like demo0813-appt-ba): the .uuid() format check 400'd valid sessions before the DB lookup could speak — the lookup is the real validator. Feedback/detail adopt the coded envelope; console.error in supportError keeps causes visible to devs running without a Sentry DSN.
The rest of the support surface joins the coded envelope, so every client
sees {error, code, detail?} with user-safe copy and dashboards can group
by code:
- staff inbox list: query enums were raw 'as' casts — an unknown value
crashed Prisma into a 500; now zod-validated → VALIDATION_FAILED 400
- staff thread GET/POST/PATCH: threadId validated via SupportThreadIdParams
(was never validated); 404/400/409 carry codes; closed-thread CAS refusal
is CONFLICT; the CAS WHERE-clause discipline itself is untouched
- org triage: orgId validated via OrgIdParams; the handler gains the
try/catch it never had (Prisma errors were escaping unhandled); the ADR-20
metadata allowlist and its pinned comments are byte-for-byte unchanged
- user tickets: 500s no longer leak error.message to clients and stop
mis-tagging Sentry as subsystem:auth; validation returns flattened zod
detail instead of raw issues; the 422 SESSION_SCOPED_ISSUE contract is
preserved verbatim; stray 'lib/prisma' import aliased to @/lib/prisma
The envelope was server-only; every consumer still hand-rolled b?.error reads and generic 'Failed to load' strings. Now the split the envelope promises actually happens at the UI boundary: - throwSupportError(res, context) replaces the ad-hoc reads across the thread sheet, platform intake, CSAT card, status card, ticket dialog, staff inbox, hub tabs, and the appointment detail page: raw payload + status go to console.error (devtools), the thrown message is code-mapped copy users can act on (toasts / EmptyState) - useFeedbackSupport read errorData.message, a field the API never sent — every failure toasted the fallback. It now maps via describeSupportError (code copy → server error → fallback) INVALID_ID now renders as 'This session's link looks broken — open the session from your dashboard and try again.' instead of raw server text.
The turnSchema's XOR refinement (!!option !== !!message) rejected the one
shape startFlow actually sends first: {flowId} alone. Every platform
intake conversation died on its first click with 'Some details are missing
or invalid' — invisible to CI because the route had zero tests. Only the
both-set case is invalid; entry turns carry neither.
- support-http: envelope shape, message overrides, the full Sentry policy (original exception with stack at error/warning, causeless 4xx as captureMessage warnings, 401/429 silent), and parseRouteParams' INVALID_ID answer — including the fix this suite forced: handlers pass the params PROMISE, so the helper must await it (safeParse on a thenable 400'd every real request; only plain-object tests could have missed it) - error-copy: code-mapped copy wins, server error is the fallback, dev detail never reaches users, non-JSON bodies never throw - appointment-access: coded failures; participants/staff skip membership lookups; the org-party grant is opt-in at the type level and demands an ACTIVE operations.read membership (ADR 20) - appointment-support-route: REGRESSION pins demo0813-appt-ba (the preview toast) and uuid ids at 200 through GET+POST; over-long ids 400 before Prisma; FORBIDDEN maps through the envelope
All 40 inline + 3 outside-diff review comments triaged against current code and fixed: Security & integrity - CSAT writes are participant-only: staff read access no longer becomes write access (a privileged non-participant's rating polluted the org aggregate); feedback POST verifies canAccessAppointment explicitly - org-party authz results drop entirely (typed) — an operator's grant can never reach recordings/payment/participants (ADR 20) - platform intake: a forged/spoofed explicit orgId is now a 403 instead of silently downgrading to a B2C ticket; org inference restricted to the ORG_OPERATOR_BILLING flow; ticket spam budget charged at escalation only, so navigating a flow never spends it - feedback-summary suppresses averages below MIN_COHORT=3 — an n=1 average is one member's exact rating (ADR 20) - support-http: the 401/429 uncaptured exemption applies on the cause path too; 5xx detail stays server-side (Sentry-only), client-fault detail still rides the envelope - recording 48h window verified server-side in runSupportTurn: claiming 'within' after endsAt+48h re-anchors onto the escalation terminal Correctness - platform sheet preserves message.metadata.options — PROMPT nodes rendered with NO option buttons before this (free text couldn't advance them) - staff inbox: ?page=abc NaN'd into a Prisma 500 (now falls back); status buckets apply the non-status filters so counts reconcile with pagination - thread PATCH mirrors atomically (), CLOSED guard unconditional on the ticket mirror, resolvedAt preserved when closing a RESOLVED thread - queue replies bump SupportTicket.lastMessageAt inside one transaction with the response + CAS + thread mirror; reply draft moved to React state (duplicate sends / cross-thread draft bleed fixed); bounded transcript (newest 50, ascending shape preserved) - notification deep-links follow resolve-href doctrine (org → org appointments surface, B2C bare /dashboard) - context.ts picks the current/next slot (endsAt > now), not a stale past SCHEDULED row; user free-text bumps lastMessageAt even on re-present turns - createSupportTicket isolates notifyStaff failures (a committed ticket must not 500 into a duplicate retry); replay dedup for terminal turns - SupportHub cache keyed by capped org ids (not membership count); controlled sheets render no trigger (focus return); intents hidden while gated GET is in flight; error states with retry across hub/triage/inbox/platform sheet; SelectGroup/SelectLabel for category headings; absolute feedback/help hrefs Tests: mock fidelity (real appointmentAuthzError via requireActual), clearAllMocks for Sentry history, conditional expects removed, dead mocks dropped, params-PROMISE regression pinned. Docs: docs/support/support-hub.md (architecture, envelope contract, invariants, test map). NOTE: schema adds @@index([status, lastMessageAt]) — requires > familiarise_web@0.2.0 db:push > npm run db:push:schema && npm run db:sidecars > familiarise_web@0.2.0 db:push:schema > prisma db push Datasource "db": PostgreSQL database "postgres", schema "public" at "aws-0-ap-south-1.pooler.supabase.com:5432" 🚀 Your database is now in sync with your Prisma schema. Done in 2.59s > familiarise_web@0.2.0 db:sidecars > npm run db:triggers && npm run db:constraints > familiarise_web@0.2.0 db:triggers > npx tsx -r dotenv/config scripts/db/apply-ledger-triggers.ts ✅ Applied ledger balance trigger (3 statements) from /Users/kaustavghosh/Desktop/familiarise_web/prisma/sql/ledger-triggers.sql > familiarise_web@0.2.0 db:constraints > npx tsx -r dotenv/config scripts/db/apply-check-constraints.ts ✅ Applied CHECK constraints (67 statements) from /Users/kaustavghosh/Desktop/familiarise_web/prisma/sql/check-constraints.sql per environment after merge.
…clock Three follow-ups from the re-review of ab842c0: - findRecentOpenEscalation passed organizationId through '?? undefined', which OMITS the Prisma filter for B2C replays — a personal retry could dedupe against an organization ticket for the same issue type. The value now passes directly, so null filters on organizationId: null. - Regression from this branch's own slot fix: filtering the context's slot read to endsAt > now left COMPLETED sessions with endsAt: null — exactly the stage where RECORDING_ACCESS lives, so the server-side 48h window verification could never fire. buildSupportContext now falls back to the most recently ENDED scheduled slot (the delivered session's end) when no active slot exists; stage/startsAt semantics unchanged. - docs: fenced code block language (markdownlint MD040). Pinned by __tests__/support/create-ticket-dedup.test.ts.
# Conflicts: # app/api/user/support-tickets/route.ts
…er told A scoping review of this PR found that the per-appointment half of the support system was disconnected from the queue it feeds. 1. THE SESSION TAXONOMY WAS WRITE-DEAD. `escalate()` wrote its ticket with a raw `tx.supportTicket.create` carrying no `issueType`, so every escalation from an appointment thread reached ops as an untyped row — the Issue type column rendered "-" and a no-show was indistinguishable from a billing question except by reading the prose. All twelve members of SESSION_SCOPED_ISSUE_TYPES were therefore unreachable in the entire product, which made the platform form's 422 refusing them a door guarding an empty room. `issueTypeForReason` now lives beside `priorityForReason` — the file that already owns exactly this kind of one-line-per-reason policy. The money reasons map to the same types the platform intake assigns, so "charged twice" means one thing in the queue whichever surface raised it. An unclassified reason returns null rather than a catch-all: `no_flow` is genuinely unclassified and should read that way rather than be filed as OTHER. 2. APPOINTMENT ESCALATIONS NEVER NOTIFIED STAFF. Bypassing `createSupportTicket` also bypassed `notifyStaff`, so the flow told the user "our team will review and follow up here" and the team was never told. `notifyStaff` is now exported as `notifySupportStaff` and called from `escalate` AFTER the transaction commits — the create has to stay in-tx to be atomic with the thread's state change, and notifying inside it would page ops about a ticket a rollback then erased. 3. "TECHNICAL ISSUES" WAS THE ONE REAL LEAK ONTO THE PLATFORM FORM. TECHNICAL_ISSUES is not in SESSION_SCOPED_ISSUE_TYPES, so the platform dropdown offered a bare "Technical issues" under Account & Access while a since-deleted constant in the same file classified it as a Session Issue. A user whose CALL dropped picked it and filed a company-wide ticket with no session attached. Relabelled "Site or app not working", which is what the platform flow's own copy already describes; in-session audio/video trouble is COMMUNICATION_ISSUE, raised from the appointment. Deleted alongside it: ISSUE_TYPE_CATEGORIES (the contradicting classification), CONTEXTUAL_ISSUE_TYPES, ALL_ISSUE_TYPES and getFilteredIssueTypes — all four had zero consumers outside their own file. 4. TWO ISSUE TYPES WERE BANNED WITH NOWHERE TO GO. DOCUMENT_ISSUE and TIMEZONE_CONFUSION are refused on the platform form with a message telling the user to open the appointment's Get help — where no matching option existed. Neither had a flow anywhere in either registry. Added a `DOCUMENTS` intent (materials missing / wrong file, ungated because pre-reads matter before and handouts after) and a timezone branch under TECHNICAL, where it belongs: it is a display problem and it only matters before the session starts. `SupportThreadCategory` gains DOCUMENTS — an additive enum value under the #705 freeze. 5. POST-CALL AUDIO/VIDEO PROBLEMS HAD NO HOME. TECHNICAL is gated `notCompleted`, and QUALITY_COMPLAINT offered no A/V option, so once a session ended "the call was broken" could only be filed as "Something else". Added that option, mapping to COMMUNICATION_ISSUE. Also dropped the "Your rating and feedback stay private" clause from the poor-quality terminal: this flow never collects a rating, and the CSAT card on the same page does. Promising privacy for a rating we do not take here is a claim about the wrong object. The two lists that decide where an issue can be raised — the server's SESSION_SCOPED_ISSUE_TYPES and the client's PLATFORM_ISSUE_TYPE_CATEGORIES — are still hand-written, because grouping and order are editorial. They are now pinned by a test asserting they are disjoint and together cover the enum, so adding a member must place it on exactly one surface. Not addressed here, deliberately: the CSAT/thread overlap (every QUALITY_COMPLAINT terminal still escalates, so leaving an opinion files a ticket) and `consultationId` on escalated tickets, which the SupportContext does not currently carry. Both are follow-ups. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exactly two `maxLength` attributes existed anywhere in the onboarding component tree (both on the 500-char verification notes). Every other free-text field was unbounded, which makes a pasted resume the one realistic way a user reaches the 64KB draft-payload cap — and crossing that cap does not warn or fail, it just stops autosaving for the rest of the run while the resume banner keeps promising saved progress. Caps go on the fields with no natural length: work-experience, education and achievement descriptions, the consultant expertise summary (2000 each), education activities and mentoring style (500). Sized against what was already capped — `bio` at 160 for a one-liner, verification notes at 500 for a paragraph — so 2000 reads as "several paragraphs". Each cap is paired: a Zod `.max()` so the server rejects it, and a `maxLength` on the input that writes it so the browser refuses the paste in the first place rather than letting the user finish a step and then lose it to validation. Where a counter already existed next to a capped field the same treatment is applied here. `mentoringStyle` is the odd one out: it is part of the draft payload via `consultantScalarFields` but has no onboarding input at all — the only place it is edited is the consultant settings tab, so that is where its `maxLength` lands. Part of the #onboarding-ux draft-layer guardrails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion marker
`OnboardingDraft.payload` stays a JSON blob — the audit is unambiguous
that normalizing it is the wrong trade — but the blob was accepted as
`z.record(z.string(), z.unknown())`, which is not a contract at all. Two
consequences, both real:
1. Anything could be stored, including `__proto__` and `constructor`.
`page.tsx` already carries a consumer-side `Object.hasOwn` guard
precisely because a stored `role: "__proto__"` white-screens the
wizard — including the "Start over" button that would recover it.
Defending at the consumer is right; defending ONLY at the consumer
means every future reader has to remember.
2. Keys accumulated forever. A field retired two wizard versions ago
kept riding along in a row nobody reads field-by-field, eating the
byte budget and confusing anyone who inspects one.
`OnboardingDraftPayloadSchema` fixes the shape without pretending a
draft is valid data — it deliberately is not, that is what a draft IS.
It asserts three things and nothing more: the key set is the wizard's
key set (unknown keys stripped), values stay optional and untyped, and
anything the wizard treats as an array is an array. That last one is the
promise consumers actually depend on: `weeklySlots.map(...)` and
`subDomains.filter(...)` run during render, so a scalar there is fatal.
The key set is DERIVED from `OnboardingFormDataSchema`, not hand-listed
(55 keys, 13 array-guarded), so a field added to a role branch becomes
draftable the moment it exists rather than being silently dropped until
someone remembers this file. A key is array-guarded only when every
branch declaring it agrees, so no branch can lose a legal value.
The sanitizer drops the poisoned keys at every depth as well, because
the schema only sees the top level — and `out["__proto__"] = {…}` is not
an ordinary property write, it re-parents the object being built.
`payload.__v` is the second half. Without it, a wizard change means old
payloads get spread into new form state and the user resumes onto a form
that LOOKS filled while old keys mean something else now — discovered at
submit-time validation, or not at all. Discarding costs one run of
re-typing; merging costs trust in every answer on the screen. So a
mismatch is quarantined and the caller is told, which is what lets the
UI say the form changed instead of showing an empty wizard under a "we
saved your progress" banner. A quarantine also rewinds `currentStep`, or
the user lands on a blank step 4 with no way to see what is missing.
The marker lives in the payload, not a new column: it describes the
blob, so it has to travel with the blob. It is stamped by the writer
after sanitization and stripped from caller input, so a client cannot
forge a stale payload into looking current.
Finally, `OVER_BUDGET` now reports which top-level key dominates the
payload. "Your progress is too large" is unactionable on its own — the
user cannot see the blob — so the rejection has to name a field.
Part of the #onboarding-ux draft-layer guardrails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… big to save Both draft failures were observable only to us. `OVER_BUDGET` emitted a Sentry breadcrumb and returned; the wizard carried on with the resume banner still saying "we saved your progress", which was by then false for every subsequent save. A breadcrumb tells the team. It does not tell the one person who can fix it by shortening a field. Two non-blocking banners: * Over budget — names the field that dominates the payload (from the reason-carrying prepare result) and says plainly that finishing and submitting still works. Only RESUMING later is at risk, and blocking submission over a convenience cache would be a much worse trade than the one it prevents. Clears itself as soon as a save succeeds. * Quarantined — the stored draft existed but came from a different wizard generation, so it was discarded rather than half-merged. This is the one case where "nothing restored" is not the same as "nothing was ever saved", and staying silent reads as the wizard losing data. `resolveRegistryRole` keeps its `Object.hasOwn` guard, with its comment corrected: the new payload schema strips poisoned KEYS at the storage boundary, but `role` is a legitimate key whose VALUE stays untyped by design, so `role: "__proto__"` is still expressible and this guard is still the thing that stops it. Part of the #onboarding-ux draft-layer guardrails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…why the blob stays The 64KB budget lived entirely in `utils/onboarding-draft.ts`. That is the right place for the product rule — it is what lets the wizard warn the user instead of throwing — but it is application code guarding a column any future writer can reach directly. `pg_column_size(payload) <= 65536` is the backstop. It measures the stored, post-TOAST width, so a compressible paste still gets in: this is a bound on the row, not a second copy of the rule. Rides the sidecar like every other CHECK; no migration. The schema doc block also gains the two arguments that actually decided "keep the blob" and were missing from it: * The real tables cannot be reused as draft storage. `Achievement` and `SlotOfAvailabilityWeekly` both require a `consultantProfileId`, and no consultant profile exists until `processOnboardingData` runs at the very end of the wizard. Drafting into them would mean creating a half-built profile hours before the user commits to being a consultant — or duplicating each table, which is a mirror, not reuse. * A mirror would not buy stronger typing. Every mirrored column has to be nullable, because a draft is incomplete by definition, and every enum column is a liability for the same reason (a half-picked ScheduleType has no legal value). Ten tables of that is not typed storage; it is weak typing spread across ten tables, ten sets of FKs, and a migration for every form tweak. And it records the cost honestly, because "keep the blob" is a trade and not a free win: the database enforces nothing about the contents, so every consumer has to defend itself — the structural schema, the `__v` marker, the byte gate and this CHECK, and prototype-chain-safe lookups at every read site. The flexibility is paid for at each of them. Part of the #onboarding-ux draft-layer guardrails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`npm run format:check` is advisory in CI (continue-on-error), which is how six of these files drifted out of shape on dev in the first place. The new `.max(...)` calls in schemas/user.ts pushed that file over too, so rather than leave a newly non-compliant file behind, every file this branch touches is normalized. No behaviour change — verified by tsc and the full suite before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hree dead UI states Triage of the 13 open review threads. Nine were real; two of those were correctness, and one was a data-ownership bug. **Ticket links could be forged onto someone else's subscription.** `POST /api/user/support-tickets` kept the caller's own `consultationId` / `subscriptionId` as fallbacks when an `appointmentId` was also supplied. The appointment only ever resolves ONE arm, so the other survived — and the validation block below skips its ownership checks whenever `appointmentId` is present, on the stated grounds that it was "already validated above". It was not. Sending your own consultation appointment together with a stranger's subscriptionId attached their subscription to your ticket, unchecked. The appointment is now the sole source of the link, or there is no link. **Ticket and thread status could disagree.** The staff PATCH updated the ticket, then the linked thread, as two separate writes. A failure on the second returned 500 with the ticket already moved, so the queue and the user's conversation disagreed and the caller retried against a change that had in fact landed. Both writes are now one transaction, with the notification sent after it commits. **A successful empty intent list was treated as an error.** `SupportThreadSheet` fell back to its static chip list whenever the server returned no intents. But an empty list is a correct answer — it means every intent was gated out for this stage and role — so the fallback re-offered exactly what the server had just withheld: a no-show chip on an upcoming session, a recording chip on one that never started. It now triggers only on a genuine error. `ORG_ADMIN_DISPUTE` in that list also gains `orgOnly`, which it always needed: a plain B2C consultee was being offered "Raise a concern" and got an instant human escalation off a chip the route would 403 anyway. **A turn arriving after the sheet was reset repopulated it.** The platform intake holds its cursor client-side for one sitting. Closing the sheet or switching flows cleared the transcript, but an in-flight turn's `onSuccess` wrote to it unconditionally — dropping the user back into a conversation they had abandoned, with a stale cursor. A monotonic sitting marker now rides along as a client-only mutation variable and stale results are discarded. **The conversations list rendered failures as emptiness.** It branched only on the loading flag, so a failed fetch looked identical to "no conversations match this filter" and offered no way back. Now it shows the coded message and a Retry, matching the detail pane beside it. **Two dead ends removed.** The "leave feedback" terminal promised "the Feedback tab on this page" — there is no `/feedback` route in the org-workspace tree, so for operators it pointed at nothing. Reworded to describe the action rather than a location. And the redundant single-column `@@index([organizationId])` on SupportTicket is dropped: Postgres reads the leftmost prefix of `@@index([organizationId, status])`, so it was pure write cost on every ticket insert and update. It came from #1245 r2; removing it here rather than leaving two indexes shadowing each other. Judged not to need changes: the `lastMessageAt` NULL-ordering concern (all three queries already order `nulls: "last"` with a `createdAt` tiebreak) and a stale line in bugs/support/00-overview.md. Left open deliberately: the two SonarCloud complexity extractions (the gate currently passes, and both touch working code for a metric alone), two walker-branch tests, and the 48-hour re-anchor scoping question, whose one-directional distrust reads as deliberate and wants a decision rather than a guess. Part of #1021 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review catch on the reason→issueType map added in 9c131e0. I had mapped `attendee_no_show` to CONSULTANT_NO_SHOW so that "a policy escalation still types correctly" — but that inverts the blame. `attendee_no_show` is the PROVIDER reporting that the consultee did not turn up, so a consultant who showed up and said so would appear in the ops queue as the no-show. The enum has no member for an absent attendee, and the flow resolves that branch in-flow anyway ("the fee stays with you"), so it only reaches this map via a policy escalation. Unclassified is the honest answer there — which is the rule the rest of the map already follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…drails fix(onboarding): guardrails for the draft JSON layer — shape contract, generation marker, text caps, budget CHECK
**A resolved playback turn was being escalated as a missing recording.**
The 48-hour re-anchor fired on any resolved RECORDING_ACCESS turn once the
session was more than 48h old. But that flow has two resolved terminals:
`within` ("less than 48 hours"), which is a client-side claim about elapsed
time and is exactly what the re-anchor exists to distrust — and `fixed` ("yes,
it plays now") on the playback branch, which asserts nothing about time.
Recordings only exist after processing, so most playback conversations happen
well past 48h. A user who reported the problem was GONE had their confirmation
discarded, was told "our team will chase the processing", and generated a false
`recording_missing` ticket. The guard now keys on the resolved node.
It also picked its escalation target as "the first escalating terminal in
object order", which selected `beyond` only because it is declared before
`broken` — reordering the nodes would have silently started filing
`recording_broken` for a missing recording. Now looked up by name.
**Org attribution extracted and tested.** `resolveOrgAttribution` moves the
rule out of the route: a requested `orgId` outside the caller's ACTIVE
memberships is refused rather than silently downgraded to a B2C ticket (a
silent downgrade hides the attempt), and sole-membership inference stays
confined to the operator-billing flow — a B2C flow must never inherit an org
just because the person filing happens to belong to one, or a private
complaint lands in their employer's queue. Seven cases pin it.
**Walker branches covered.** `walkFlow` had two untested paths: an option with
`next: null` (ends the conversation with no bubble) and one pointing at a
missing node (a flow-authoring typo, which must fail toward a human rather
than strand the user on an unadvanceable cursor). Both scopes execute this
walker, so a regression in either was silent.
**`OrgThreadRow` extracted** from the triage list, carrying the ADR 20 rule in
its doc comment: metadata only, because a member's support conversation is
content the org may see happened but never read.
On the two SonarCloud complexity threads — the gate is currently PASSING, so
the stated rationale ("to clear the SonarCloud failure") did not hold. Both
extractions were done anyway, on merit: the attribution rule is
security-relevant and now independently testable, and the row component is
where the ADR 20 constraint belongs. The remaining length in the platform POST
is a linear chain of guard clauses with early returns, which is the shape it
should have.
Part of #1021
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The deploy-preview context now trusts the preview origin, so authenticated flows can be exercised there. Sign-in previously returned a bare 500 on previews because the trusted-origins list only contained the production URL, which made every authed surface untestable before merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the preview: the support sheet shows only BOT messages. Every answer the user gives by pressing a chip is invisible, so the transcript reads as a monologue — a run of bot questions with no trace of the replies that produced them. The cause is the same line in four places: a USER message was written only `if (userMessage)`, i.e. only for free-typed text. Advancing by a chip sends `chosenOptionId` and no `userMessage`, so nothing was recorded on the user's side anywhere — not in the sheet, and not in the database. The database half is the more serious one. This PR's stated purpose includes giving staff the transcript of an escalated thread, because previously they had none. But the stored transcript contained only bot messages, so a staff member opening a no-show escalation saw the questions and could not tell which answers led there. The ticket's machine-readable `reason` gives the outcome; it does not give the path. - `walkFlow` now returns `chosenLabel` — it already resolved the option object to advance, it simply never reported which one. - The self-serve write and `escalate()` persist `userMessage ?? chosenLabel`, so a chip press becomes a real `SupportMessage` with sender USER. - The recording re-anchor carries the label through its synthesised turn: the server overrides the outcome there, but the user did press "Less than 48 hours" and the transcript should say so. - `PlatformSupportSheet` renders the pressed chip as a USER bubble. That scope is stateless — nothing is persisted until escalation — so it needs the label client-side; it rides along as a mutation variable and is stripped before the request body. `SupportThreadSheet` needed no change: it already renders USER messages right-aligned in the primary colour. It had simply never been given one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A guided tour of the deploy preview found three problems that no test caught,
because each is a wiring mismatch rather than a logic error.
**The recent-sessions picker could never show anything.** It read
`json.data ?? json.appointments`, but `/api/appointments` returns
`listAppointmentsScoped`'s `{ items, total, page, perPage }`. Neither key has
ever existed on that response, so the picker was unconditionally empty and the
Sessions tab told users with real appointments that they had none — while the
API beside it was returning them correctly.
**The Platform tab rendered a failed load as an empty list.** Unlike the
Sessions tab, which distinguishes "Couldn't load — Retry" from genuine
emptiness, it branched only on `length === 0` and said "No platform requests
yet", inviting the user to file a request they may already have open. Now it
shows the coded error and a Retry.
**The intent-chip fallback was stage-blind.** When the gated fetch failed the
sheet fell back to a hardcoded chip list, which by construction knew nothing
about stage or role — so it offered "Cancel & refund" on a session that had
already happened and "The other party didn't show" on one that had not started,
which is exactly the order-state violation this flow exists to prevent. It also
failed silently: nothing looked wrong until a chip was pressed and the turn
errored. The fallback is deleted. A wrong chip is worse than no chip — the
server clamps the intent anyway, so it bought nothing and misled. On failure the
sheet now says so and offers a retry, and the `INTENTS` constant goes with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Support 5xxs were reaching the client while Sentry stayed empty. The capture was firing; the event never left. On a serverless host the instance may freeze the moment the response is returned, and the SDK transport is asynchronous, so a queued event dies in memory unsent. `onRequestError` does not cover this path either — these errors are caught and turned into an ordinary NextResponse, so Next never sees a handler fail. Schedules the flush with `after()` so it runs post-response but before the platform is allowed to freeze. Never awaited inline; telemetry must not slow an error response. `Sentry.flush` resolves false on timeout rather than throwing, so that case is logged — an empty dashboard should be distinguishable from a healthy one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The thread query had no refetch of any kind and invalidated only after the user's own turn, so a reply written from the ops queue sat in the database until the user closed and reopened the sheet. The thread read as one-sided. Bounded on three sides so an idle drawer costs nothing: only while the sheet is open, never once the thread is RESOLVED or CLOSED, and never in a background tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(support): Swiggy-style support & feedback hub — per-appointment threads, platform intake, enterprise triage
Additive only, and none of it is backfilled — a pre-MVP reset is coming, so every new column is either nullable or carries a constant default. **SupportMessage.seq, allocated from AppointmentSupportThread.messageSeq.** `createdAt` alone cannot order this table. The user's turn and the bot's reply are written inside ONE transaction and Postgres CURRENT_TIMESTAMP is transaction START time, so the two rows can carry a byte-identical timestamp and the sort falls to whatever the planner returns — the answer can render above the question. Ordering by `[createdAt, id]` only makes that deterministic, not correct: the id is a random uuid, so it would put the reply first about half the time. `Int @default(autoincrement())` was rejected too — a volatile default forces ADD COLUMN to rewrite the table under ACCESS EXCLUSIVE and numbers legacy rows in physical heap order, silently scrambling every existing transcript. **SupportTicket.referenceNumber + SupportTicketCounter.** A uuid cannot be read back over a phone line, so the two staff surfaces had each invented their own truncation — one took the first eight characters, the other the last — and users were shown no identifier at all. Year-scoped so the series leaks this year's volume rather than the all-time count. **SupportTicket SLA stamps.** Deadlines are stored at intake rather than derived at read time, so a later policy change never retroactively re-dates an open ticket's breach. `awaitingUserSince` + `pausedMs` stop the resolution clock while the ball is in the user's court. No `slaState` column: the five timestamps plus now() are complete, and a stored flag needs a cron to stay honest and is wrong between runs. **ConsultantReview.appointmentId + ratingUnitId, and the unique moves to (appointmentId, consulteeProfileId).** `ratingUnitId` is what makes a group event one data point. A sessionType enum could not do that job: a WEBINAR shares one Appointment across every attendee but a CLASS mints one per enrolment, so grouping by type would collapse every class a consultant ever ran into a single point. Legacy rows keep a NULL appointmentId, and Postgres treats a NULL key as distinct, so the old one-review-per-pair rule would silently lapse for exactly the rows that were never gated per session. `consultant_review_legacy_pair_key` in the sidecar preserves it for that band; Prisma cannot express a partial unique and this repo declines the preview flag. **AppointmentFeedback.raterRole.** Nullable and never backfilled: rows written before the column cannot be attributed, and asserting CONSULTEE over them would re-admit the provider self-ratings this exists to exclude. Aggregates filter on CONSULTEE, so unknown provenance fails closed. **SupportFlowOutcome.** One row per terminal turn, in both scopes, carrying no message bodies. Deflection was unanswerable even retrospectively because the platform scope persisted nothing at all. Also fixes the sidecar drift guard, which split the file on the staged-for-reset banner. Active SQL sits below that banner too, so `appointment_doc_thread_version_unique` and `onboarding_draft_payload_size` were never asserted. What actually excludes a staged object is that it is commented out, so that is what the guard now keys on — and its index-name regex captured the literal "IF" from `CREATE UNIQUE INDEX IF NOT EXISTS`. Part of #705 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fb4vbRYxJH7wxc4tjhm6XJ
…HERE Six more findings, all of which held up. The important one is a correction to my own previous fix. **Moving the read inside the transaction did not make the SLA milestones atomic.** At READ COMMITTED two staff replying at the same instant still both see a null `firstAgentReplyAt`, and the later write moves a timestamp that is supposed to be the first one. A read-then-write is not a compare-and-swap wherever you put it; the guard has to be a condition the database evaluates. `applyStaffReply` now claims each milestone with `updateMany` guarded on the field still being null, and banks an open wait with a CAS on the `awaitingUserSince` it read, incrementing rather than reading-and-adding — so of two simultaneous replies exactly one banks the interval. Both staff routes share it, which also removes the duplicated patch-building. **A CLOSED ticket could not follow its thread, and the thread moved anyway.** The mirror's whole purpose is that the queue never disagrees with the conversation, but the `updateMany` result was discarded, so resolving a thread whose ticket was already closed split the two and still notified the user. It now fails the transaction into the existing 409. Moving to CLOSED is exempt — a closed ticket is already where the thread is going. **The review card treated a failed eligibility check as "not eligible".** Both rendered nothing, which is the same mistake the Platform tab made before it was fixed: a load failure shown as an empty state. It now offers a retry, and only stays silent when the request succeeded and the answer was genuinely null. **A successful review could be submitted twice.** `isPending` went false before the refetch landed, so `existing` was still null, the button still read "Post review", and a second press took a 409 off the one-review-per-session unique. The invalidation is awaited, which holds the button through it. **Clearing your written review did nothing.** `UpdateReviewSchema` is `.partial()`, so the `text.trim() || undefined` the card sent meant "leave it alone". It now sends an explicit null, and the schema accepts one — null rather than an empty string, so there is only one representation of "no text". **The dry run compared two of the four aggregates the real run writes.** A profile whose review count moved without crossing the publish threshold reported as unchanged. It now compares all of them, which is the only reason a dry run against a shared database is worth having. Part of #705 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fb4vbRYxJH7wxc4tjhm6XJ
…ew-sweep fix(support): the eight causes behind a chat turn that never appeared, and the review/feedback sweep around it
… members, leaked capture (#1271) * fix(stream): restore video joining — authorless call create, unsynced members, leaked capture The join gate has thrown on every request since #1136. `getOrCreate()` was called on a server-side client with no `created_by_id`, which Stream requires under server-side auth even when the call already exists, so the route 500'd after access had been granted and rendered "Could not join this meeting". Because the throw preceded `updateCallMembers`, nobody was ever granted membership: the whole Stream app holds zero `call_member` roles. A second, independent blocker sat behind it. Stream refuses a call naming a user it does not hold, and a token alone never creates one — only connectUser does. Every chat path upserts before naming members; the video path never did, so the 29% of consultants who had never signed in broke the mint outright. Minting also opened the camera. `getOrCreate` applies the call type's camera_default_on/mic_default_on, and the Call it ran on was function-local, so capture outlived every reference to it and the recording light stayed on for the life of the tab. Same shape on the meeting page, where a `cancelled` bail or a throw dropped an instance whose devices `get()` had already started. Teardown is now defensive: a handle that never reached get()/join() has no device managers, and reading through them threw from inside the cleanup, masking the original error. Also: Home gated Join on `isApprovedStatus`, a strict equality that SCHEDULED fails, so a scheduled subscription offered Join on the Appointments tab and hid it on Home; and addUserToEventChannel let a consent refusal bubble unhandled out of a server action, taking the whole appointment page down instead of chat. The join-gate suite asserted the ORDER of the Stream calls but never their arguments, so a bare getOrCreate() looked identical to a correct one. It now asserts the author and the member sync. Closes #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * refactor(stream): extract the consent guard out of addUserToEventChannel Sonar S3776 — the inline try/catch pushed the function to cognitive complexity 18 against a budget of 15. Behaviour is unchanged; the guard is now a named helper that returns false when consent is missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): propagate the consent refusal, release capture on a cancelled effect Review round 1 on #1271. Turning the consent gate into a skip was only half the job. The channel-open route discarded the result and answered 200, so a skipped user was handed a channel id they are not a member of — worse than the 500 it replaced, because it fails silently and only shows up later as an empty, un-postable thread. It now answers 403 with the reason. The meeting effect could not release an instance whose get()/join() was still pending: cleanup set `cancelled` and returned, and the release only ran once the SDK call settled. The closure is hoisted so both the cleanup and the `finally` can call it. It deliberately does NOT clear the handle the way the review suggested — applyDeviceConfig runs at the TAIL of get(), so a cleanup firing mid-flight releases devices that are re-enabled a moment later, and a cleared handle would leave the `finally` with nothing to release. That is the very leak the suggestion set out to prevent. Guarding on `adopted` alone keeps both passes armed; teardown is idempotent. Also extracts tryAddToExistingChannel, taking addUserToEventChannel back under the cognitive-complexity budget (Sonar S3776, 18 -> under 15 across both passes). Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…audited (#1273) * fix(stream): staff get recording metadata, admin gets playback, both audited `GET /api/stream/recordings/[recordingId]` opened with `if (isPrivileged(session.user.role)) hasAccess = true`, and `isPrivileged` is ADMIN *or* STAFF. Any staff member could therefore fetch a playback URL for any recording on the platform — including a 1:1 consultation they had no relationship to — and nothing was written anywhere to say they had. The same blanket grant sat in the meeting recording-info route. The operator path was strictly less accountable than the tenant path, where deleting a recording or exporting a call log already produced an audit row. The grant now resolves through BACKOFFICE_PERMISSIONS, which is the declared single source of truth for who reaches which internal surface and previously had no key for recordings at all. `recordings.read` (metadata) admits both operators; `recordings.play` (any URL that renders the session) is ADMIN-only. The operator branch is evaluated last, so a staff member who actually delivered or bought the session passes an ownership path and keeps playback. Every read granted by the operator branch writes a trail before the response is built and before any URL is minted: a STREAM_RECORDING_ACCESSED row on OrgAuditLog when the session belongs to a tenant, and a SystemEvent row always so B2C recordings are not invisible. A staff response withholds every media URL, not only `playbackUrl` — a thumbnail is a frame of the session and the preview clip is a cut of it — and carries `access.level` so a consumer can tell "not permitted" from "not ready yet". Separately, `GET /api/organizations/[orgId]/stream/calls` returned `Recording.recordingUrl` verbatim to any org MANAGER+. That is Stream's pre-signed S3 link: fourteen days of validity, its own credentials, no session required, so forwarding the JSON forwarded the video. It now uses an explicit select allowlist naming no field that reaches the media, matching `recordingMetadataSelect` in lib/api/scope/list-recordings.ts. It deliberately gains no signed-URL arm: ADR 20 governs this route and already considered and rejected letting an org role open session content behind an audit row. Also here: POST /api/stream/recordings/sync gets a user-keyed limiter (one POST fans out a listRecordings call per session the caller touches, and the edge rule covering the path is IP-keyed at 60/min), and the three dead "Send Email" / "Flag Account" / "Suspend User" dropdown items in OperatorUsersPage are removed rather than wired — banning is ADMIN-only and belongs to the moderation flow. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): fail closed on the operator audit write, and unbreak the doc links Review round 1 on #1273. All four findings were legitimate. The audit write was the substantive one, and it is the same gap the implementation flagged as out of scope — found independently. `recordSystemEvent` swallows its own insert failures by design, because its original callers sit on the webhook critical path where an outage of that table must not cascade. But for a B2C recording that row is the ONLY audit trail; there is no organization to carry an `OrgAuditLog` entry. So a failed insert meant an operator read served with no persisted record — the one outcome the function exists to prevent. `strict` is additive, so every existing caller keeps the best-effort behaviour it was written for. The test reset return values but not call history, so an assertion could pass on a call an earlier test had made. The response docs claimed every response carries `access`; error responses, including the documented 410, do not. The absolute `file:///Users/...` link CodeRabbit flagged was one of six across docs/ — every one a broken link for anyone who is not me. Swept them all rather than fix one and leave five, since it is the same defect and the fix is mechanical. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(ops): make the Stream cron fleet run, and visible Five scheduled workflows had never completed a single run, and nothing in the repository said so. Their entrypoints reached lib/supabase.ts, which opens with `import "server-only"` — a marker package whose main entry does nothing but throw. Next resolves it to an empty module under the `react-server` export condition; the bare Node process a workflow runs gets the throw, so the job died during module evaluation before a line of its own code ran. The Supabase clients and storage primitives now live in lib/supabase-storage-core.ts, which carries no marker; lib/supabase.ts re-exports every one of them, so application code is unchanged and keeps its client-import guard. Four workflows additionally lacked NEXT_PUBLIC_SUPABASE_ANON_KEY, which that module also throws without, and no workflow in the fleet referenced it at all. A new jest guard, __tests__/maintenance/workflow-import-env.test.ts, re-derives every scheduled workflow's import graph on each CI run and fails when one reaches a module that cannot load in a bare Node process, or is missing the env its imports read at module scope. It immediately found eight more: the payout, payment-status, earnings, dispute and webhook-sweep crons all reach lib/auth-server.ts, which calls React `cache()` at module scope — and react@18.3.1, the version this repo pins, has no such export. Fixing that means changing the money or auth subsystems, so those eight are registered and asserted rather than fixed here. Also: - ensure-webhook-subscription.ts gains a `--check` mode that annotates each finding and exits 2 on drift, plus a daily + PR workflow that runs it. It used to return 0 whatever it found, which is why it was wired to nothing — and why #1134's six-of-ten subscription went unnoticed. - New daily reconcile-orphaned-recordings job. A dropped `call.recording_ready` webhook was previously invisible until a consultant clicked Sync, and Stream deletes the file after 14 days, so the loss was permanent. - All seven Stream jobs are now listed by /api/staff/system-jobs and runnable from /api/admin/system-jobs/run, calling the same cores the workflows call. - stream-sync moves from its bespoke Redis lock to withCronLock, so it finally writes a SystemJobExecution row and appears in the operator surface. - package.json `scripts:stream-sync` pointed at jobs/stream-sync.ts, which does not exist, through ts-node, which cannot resolve this repo's path aliases. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(ops): give the drift check a Redis stub, and stop repeating the job catalogue Two CI failures on this PR, both self-inflicted. The new drift workflow crashed at import for want of Upstash credentials: `lib/stream-client` pulls in `lib/redis`, which throws at module scope. That is precisely the import-time crash class this PR exists to fix, reintroduced by the workflow that detects it. A read-only check takes no lock and stores nothing, so it gets the in-memory stub rather than production Redis credentials it has no use for. Sonar's gate failed on new-code duplication at 3.5% against a 3% threshold. The seven Stream catalogue entries were identical in shape, so they are built from tuples now; and `entrypointOf` had been copied verbatim into both workflow guards, which is how a parser that tracks tsx invocation styles drifts the moment a third one appears. One copy in a fixture, two callers. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): stop handling two chat events nothing delivers The drift guard added in this PR immediately found that no `chat` hook exists on the Stream app, so `user.flagged` and `message.flagged` are handled by the dispatcher and never arrive. The obvious response is to create the hook. That would have been wrong. The in-app report button already writes the ModerationReport itself, via POST /api/report — the webhook was a second path to the same row, and both increment `reportCount`, so one user report read as two. Automod is `disabled` on both channel types with no blocklist, so there are no automated flags to receive. Stream considers `message.flagged` obsolete under v2 moderation, which uses `review_queue_item.*`. And the one path a hook would uniquely serve — a moderator flagging from Stream's own dashboard — hits a handler that FK-violates on any flagger with no User row, then retries forever. So the events go, and `chat-moderation-handlers.ts` with them. Reporting is unaffected: it never used this path. If Stream-side moderation is wanted later, the v2 review queue is the thing to integrate, not a deprecated event. The subscription test built its unplaceable-event scenario FROM the chat events; it now inverts, using a chat-only hook against video events, so the guard stays covered. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…1272) * fix(appointments): close the join gates on both sides of a booking The join window itself was already shared and tested. Everything AROUND it was not, and six separate defects had accumulated there. Consultant Join had no booking-status gate. The appointments adapter asked only "is a slot inside its window and is the row not in the cancelled bucket?" — and that bucket is only the terminal-NEGATIVE statuses. Home asked nothing at all. A consultant could therefore open the video room for a booking still at APPROVED_PENDING_PAYMENT, where the slot is held but nobody has paid, or one already COMPLETED, whose session was closed out. The trial branch checked no status either. All of them now compose the same gate the consultee adapter has always used: isConfirmedStatus AND an open window. SessionTimeline offered JOIN on a session that had already ended. Its joinable branch compared the clock against startsAt/endsAt and never looked at meetingEndedAt, so a call the host closed early kept offering JOIN for the rest of the booked hour and dropped whoever clicked it into a fresh, empty room. Row status now routes through getSessionJoinState via a new getSessionVMJoinState, which is the same predicate the join surfaces use. SessionTimeline also rendered the word "JOIN" as inert text whenever the adapter refused to supply a handler, with none of the muted styling the other non-actionable states get. It looked like a live button and did nothing. The label is now a state ("IN PROGRESS"), muted like its siblings, with a title that says it cannot be joined from there. Six surfaces each declared their own join window, landing on four different values, so the same booking opened at four different times depending on which page the user was looking at. The planner gave a host ten minutes while the appointments list beside it gave the same host fifteen. Every caller now imports CONSULTEE_JOIN_WINDOW_MS or CONSULTANT_JOIN_WINDOW_MS: hosts get 15 everywhere, learners 10. TrialsTab's hand-rolled comparison also read only slotsOfAppointment[0], so a trial longer than half an hour stopped being joinable thirty minutes in (#1061); it now goes through the session helpers. useEventActions exported a handleJoinSession with zero callers. Deleting it drops a static @/lib/meeting import, which was dragging the Stream video SDK into every bundle that touched the hook (#248), along with the now-dead isJoining state and appointment option. The consultee adapter's join was a degraded private copy of useLazyJoinMeeting: it read the video client synchronously, so a click landing before the deferred connect finished was told "Not signed in" — destructive, and untrue — while every sibling awaits the client and shows a retryable warning. It also cleared joiningId only in catch, leaving a successful join spinning. It now uses the shared hook. Finally, the dev backdoor keyed off NEXT_PUBLIC_ENABLE_DEV_TOOLS in one place and NODE_ENV in two others, and on consultant Home the dev arm REPLACED the gate rather than adding to it (which also mislabelled every genuine Join as "Join (Dev)" on a dev build). One flag now, and every dev arm is a separate, distinctly labelled affordance that appears only where the real Join does not. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * refactor(appointments): extract the inert-status title into a lookup Sonar S3358. The nested ternary was building a tooltip for the two row states that can look actionable without being one; a keyed lookup says the same thing and leaves room for the next state. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(stream): page queryChannels at Stream's real 30-row cap Stream returns at most 30 channels per `queryChannels` call regardless of the `limit` passed — verified against the live app, where `limit: 100` answered with exactly 30. Both reconciliation call sites in `event-channel.action.ts` assumed otherwise. `syncUserEventChannels` paged with `do … while (page.length === 100)`, so the first page looked short, the loop exited after one request, and the reconcile pass only ever examined a user's first 30 memberships. A DM whose booking no longer justified it but that sat at position 41 was never read, never classified stale and never revoked — the DM revocation leak. The same loop advanced `offset` by the requested 100 rather than the 30 returned, so a second iteration would have skipped 70 channels. `getUserEventChannels` asked for 100 and did not page at all. Both now share `queryChannelsPaged` in `lib/stream/batch.ts`, which pages at the real cap, advances by the rows actually returned, and reports `truncated` at Stream's 1000 offset ceiling instead of silently handing back a prefix. The reconcile walk also pins `created_at` ascending: offset paging needs a stable order, and Stream's default `last_message_at` sort moves underneath a multi-page walk. `scripts/stream/purge-memberless-dms.ts` already knew all this; the constant lives in one place now. Separately, `channel.create()` carries its roster in the request body and accepts at most 100 members — the ceiling `upsertUsersToStream` already respected. Both create paths handed it the whole array, so a 150-seat webinar's first attendee to open chat got a rejected create and no chat. They now create with `createMemberChunk(...)` and follow with `addRemainingMembers(...)`, host and joiner ordered first so the person who triggered the create is never the one stranded in a follow-up request. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): delete getUserEventChannels, an ungated server action nobody calls Found while paging its `queryChannels` call. It is a `"use server"` export that takes an arbitrary `userId` and returns that user's channel list — ids, names, member counts — with no session check at all, in the same module where `syncUserEventChannels` carries a full `getSession(true)` self-or-privileged gate. Every server-action export is remotely invocable, so this is readable from any signed-in browser for any user id. Same shape as F-MED-6 in the 2026-08-23 review. It has zero production callers, so the fix is deletion rather than a gate: no capability is lost and there is nothing left to get wrong later. Its two paging tests went with it. They exercised the 30-cap through this function; `__tests__/stream/batch.test.ts` now covers the same behaviour against `queryChannelsPaged` directly, and the end-to-end leak case still runs through `syncUserEventChannels`, which is the path that actually matters. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
) * fix(stream): make the server the sole creator and owner of a call The Stream call for a booking was created in the browser. `getOrCreate` ran on the signed-in user's own video client from ten dashboard surfaces, which meant Stream recorded whoever pressed Join first as the call's `created_by` — the consultee for roughly half of all sessions — and every field of `custom` was authored by a browser, including the `consultantUserId` that `useSessionInfo()` derives `isHost` from and that gates "End for everyone". Entitlement was checked afterwards, in `createDbMeetingSession`, so a refused caller still left a real billable room behind. And `getOrCreate` applies the call type's device settings, so minting a room opened the camera on the dashboard (#1271 had to release it). Creation moves to `provisionAppointmentMeeting`. It resolves the anchor, short-circuits on an existing session, runs every refusal, checks entitlement BEFORE the Stream write, and then creates the call with the server client naming the appointment's host as author and reading the whole `custom` payload from the rows the gate already read. The room id is unchanged (`slot-<anchorSlotId>`). `lib/meeting.ts` keeps its name and its place in the flow but no longer imports the SDK or builds a `Call`, so the media-release workaround goes with it. Members are named `call_member` at creation instead of `host`/`user`. The live `default` type has no `host` role key at all, so those consultants held nothing, and `user` loses `join-call` the moment the grants script runs — that pair would have locked out both sides. `scripts/stream/backfill-call-member-role.ts` repairs existing calls, and `ensure-call-type-grants.ts` now refuses to --apply until it has seen a member actually holding the role. Its old guard only checked that the grant existed on the role, which is true by construction. `resolveMeetingAccess` refused three statuses while every dashboard's Join affordance is an allowlist of {APPROVED, SCHEDULED, IN_PROGRESS}, so `APPROVED_PENDING_PAYMENT`, its trial twin `AWAITING_PAYMENT`, PENDING and a DRAFT webinar were all hidden by the UI and admitted by the server. It now shares `isConfirmedStatus`. Completed-like statuses are handed to the existing time gate rather than refused outright, because the trial completion sweep has no buffer and would otherwise eject a live session. Ending a call moves to `POST /api/meetings/[meetingId]/end`, which requires the hosting side. `end-call` is still granted to `call_member` and revoking it stays a later, post-deploy step. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * refactor(stream): one authorization preamble for both meeting routes Sonar's gate failed on new-code duplication at 3.2% against a 3% threshold, and the offender was worth fixing on its own terms: the new end route repeated the join route's guard verbatim — authenticated, not suspended, meeting id present, Stream configured, access resolved — 50 lines at 34% density. Two copies of an authorization preamble is how the two drift, and the drift would be silent: a guard added to one and not the other looks like nothing at all in a diff. `guardMeetingRoute` now owns those five questions, and a third meeting route gets them for free. It returns a discriminated union rather than throwing. A refusal here is an ordinary outcome with a status code attached, and making each caller handle it explicitly keeps that visible at the call site instead of hiding it in a catch. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * style(appointments): satisfy eqeqeq in the deliberate-end predicate `== null` was deliberate — null OR undefined — but eqeqeq is a blocking rule here and SonarCloud fails the PR on it. Truthiness reads the same and treats an empty reason as no reason, which is the same conservative branch. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): an empty room is not a finished session The gate fix earlier in this PR stopped a 30-second dropout from locking people out of their own booking. It missed the other half of the same write. `handleSessionEnded` marked the SLOT `COMPLETED` unconditionally. Stream fires that event `inactivity_timeout_seconds` after the last participant leaves — 30 seconds on the live call type — so one party stepping out at 09:56 of a 10:00-11:00 booking completed the slot before the session had started. That made it review-eligible via HELD_SLOT and handed it to auto-complete-appointments, so the booking closed itself for a consultation nobody had attended yet. The session row still records that Stream's session ended, because it did. `endedReason` distinguishes it from a host closing the room, which is what `isDeliberateEnd` reads. The slot now completes only once its booked time is actually over. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(appointments): make endedReason impossible to forget Review round 1 on #1277. The most important finding was one I had missed entirely: `isDeliberateEnd` treats an ABSENT `endedReason` as deliberate, which is the safe reading for a historical row written before the column existed — but it is the wrong reading for a query that simply did not select it. Eleven projections selected `endedAt` alone, so on every one of those surfaces a timed-out session still read as deliberately ended and still locked people out of their own booking. The fix was half-applied and I could not see it. Rather than patch eleven call sites and hope, `endedReason` is now REQUIRED on the slot shape. A projection that omits it does not compile. That immediately found six more sites than the review named — the Prisma validator shapes in types/appointment.ts, the appointment-detail query, two view-model types, the consultee event processor, and the SessionVM path behind SessionTimeline, which had no way to carry a reason at all and now does. Also corrected: the doc comment on `callHasLiveParticipants` claimed it fails open. It fails closed, and that is right — the probe only ever ADDS permission, since it runs solely where the caller was already about to be refused. Failing open would be a new grant issued on the strength of an outage. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): close the booking-state leak, and the remaining review findings Review round 1 on #1277, continued. **A stranger could read another user's booking state.** `getMeetingCreationRefusal` reads the persisted slot and its parent booking status for any slotId it is handed, and the resulting string goes back to the caller as data — so running it before the entitlement check let anyone who guessed a slot id learn "This session is not confirmed yet." or "This session was cancelled or moved." Entitlement now runs first, and a stranger gets one answer that tells them nothing. Pinned by a test that seeds a real tentative slot the caller is not on. **A refusal we could not evaluate was read as a pass.** That helper caught every error and answered `null`, so a slot read that threw let the mint proceed; `createDbMeetingSession` then re-ran the same check, and a second read that succeeded threw — leaving exactly the orphaned billable Stream room the ordering exists to prevent. It now refuses, with a message vague enough not to leak. **The end button could send twice.** `endCall` is invoked from inside a `setProgress` updater, and React may run an updater more than once; both runs close over the same `isEnding === false` and sail past a state guard. A ref closes it. The request is also bounded now — `fetch` has no default timeout, and a stalled network left the host on a disabled spinner, still broadcasting, with teardown and navigation in `finally` never reached. **The live-participant probe now goes through the circuit breaker**, like every other server-side Stream call in this cohort. Without it, during an incident it ran on the request thread for every refused join and waited out the SDK's 30-second default, and its failures never fed the breaker that exists to stop precisely that. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): the lockout guard must not be satisfied by a partial result Review round 2 on #1277. Three findings the thread-resolution pass correctly refused to close, and one lint warning I introduced. **The pre-flight guard had the same shape as the bug it guards against.** `anyOpenCallMemberHolds` returned true on the FIRST member holding `call_member`, so a mixed roster passed — and the apply path then stripped `join-call` from `user` and `guest`, locking out every member who lacked the role. That is a partial outage, waved through by a check written to prevent exactly it. It now requires every member of every open call, reports how many are uncovered and in which calls, and the tests encode the new semantics: a mixed roster is refused, full coverage applies. Memberless calls stay valid — they have nobody to lock out. **`session_timeout` had zero test coverage anywhere**, which is remarkable given it is the case the whole predicate exists for. Worse, a comment I wrote claimed it was "tested separately, in meeting-join-gate", and that was simply false. Three cases added: a timeout does not end a live session, a reconciler's guess does not either, and a row with no reason stays terminal for historical safety. **The end-route test could not fail.** The `video.call` mock discarded its arguments, so the route could have ended the id from the URL instead of the one the MeetingSession points at — the exact thing the test is named for — and it would still have passed. The arguments are the assertion now. Also: `isEnding` left in the `endCall` dependency array after the ref-guard rewrite removed the last read of it. Part of #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(auth): build the session memo on first call, not at import Closes #1275. `lib/auth-server.ts` called React's `cache()` at module scope. `react@18.3.1` — the version package.json pins and `npm ci` installs — has no `cache` export at all; it resolves inside the app only because Next aliases `react` to its own vendored React 19 in the RSC layer. The module's own docstring recorded that dependency and predicted it would "silently degrade to no memoization" if the alias ever stopped applying. The reality was worse. Outside the RSC layer the call THREW, and it threw during module evaluation, so every process that imported this file transitively died before running a line of its own code: $ npx tsx -e "import('./jobs/payments/reconcile-payment-status.ts')" IMPORT FAILS: (0 , import_react.cache) is not a function Eight scheduled workflows import it that way. Seven are the payments and payouts reconciliation layer — payouts, payment status, earnings sync, lost disputes, orphaned confirmations. The eighth is sweep-stuck-webhook-events, which is the durability backstop the Stream webhook route explicitly delegates to: it acks fast and processes in after(), and the sweeper is what re-drives anything lost to a frozen instance. None of the eight had ever completed a run. Deferring the call fixes the import. Tolerating an absent `cache` fixes the job: a one-shot cron process serves a single request, so the unmemoized reader is the correct behaviour there rather than a degraded one. Inside a render nothing changes — the memo is built by the first guard's call and shared by the rest. Verified by running the jobs, not merely importing them: reconcile-payment-status Success: true reconcile-orphaned-confirmations Done. scanned=0 confirmed=0 stillBlocked=0 sweep-stuck-webhook-events Still failing: 0 `KNOWN_UNRUNNABLE` in the import-graph guard is now empty, and `lib/auth-server` is out of `EXTRA_UNLOADABLE`. Both registries are kept rather than deleted: the defect class they catch — a module that imports fine but throws on a runtime export — does not announce itself in an import graph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): stop billing an MAU for every search result #1280 Bucket 2. Stream bills chat by monthly active users, and an MAU is any user who has opened a WebSocket. The search route upserted every RESULT — people who had taken no action at all, whose only involvement was that somebody typed their name. Nothing is lost by removing it. Every path that actually needs a user to exist on Stream upserts them itself, immediately before naming them, because Stream refuses an operation referencing a user it does not hold — the chat channel creates have always done this, and the video mint has since #1271. The larger lever is still open: both dashboard layouts mount the Stream provider around the ENTIRE dashboard, so every user who opens their dashboard connects whether or not they ever use chat or video. Moving that to the routes that need it is #1280 Bucket 2 and wants a measurement first — the ratio of chat-active users to logins decides whether it is worth anything. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(moderation): stop reporting enforcement that never happened
A ban whose Stream write failed was reported to the moderator as a clean
success. The database said banned, the sessions were gone, the appointments
were cancelled and refunded — and the target's existing chat token kept
working for up to an hour, because the client type omitted the `stream` and
`errors` fields the route has always sent and the success handler toasted
unconditionally. `ModerationAction.sideEffects` recorded the failure and
nothing read it.
Six changes, all of the same shape — say what actually happened:
- The queue returns the latest action's side-effects, the target's ban state,
and a `capabilities.canModerateUsers` flag. A failed Stream step now toasts
what is still true about the account ("its existing chat token still works"),
moves the filter to the report it just resolved, and the report renders the
whole summary. Ban and Suspend are hidden from moderators who would get a 403.
- CONTENT_REMOVED on a chat message removed nothing: it was routed to the
review soft-delete, which no-ops on the null `reviewId` every MESSAGE report
has. `ModerationReport` gains `streamMessageId`/`streamChannelCid`, the
action deletes the message on Stream, and the queue offers the action at all.
- The excerpt captured at report time is rendered, so a ban is no longer
decided from a reason string.
- Message reports aggregate per message instead of per author, so the twelfth
complaint no longer shows the first message anyone objected to.
- A ban can be lifted: POST .../[reportId]/unban clears the ban columns,
records USER_REINSTATED, and calls restoreStreamAccess, which had zero
callers — reversing a ban by hand left an account permanently mute on Stream.
- A failed Stream write is retried by a sweep that reuses the
sweep-stuck-webhook-events shape, with the sideEffects JSON as its queue. It
skips a ban that has since been lifted and gives up terminally after six
attempts or 72 hours. Its scheduling workflow is not here (.github/workflows
is owned by a sibling change), so it runs on demand for now.
Schema: two nullable columns and one enum value; no backfill.
Part of #1270
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs
* fix(moderation): schedule the enforcement retry so it actually runs
The retry sweep shipped with a CRON_SECRET route and a catalogue entry reading
"On demand" — self-healing that needed a human to remember. That is the wrong
shape for this failure specifically: a ban whose Stream revocation failed is
invisible from the product, so nobody would think to press the button. The only
signal is the harasser carrying on posting.
`.github/workflows/` was off-limits while #1274 was in flight; it has merged, so
the entrypoint and workflow land here. Every-30-minutes on a minute no other job
claims — enforcement already reported as done should not wait an hour to become
true.
The env block carries the Supabase variables because the moderation import graph
reaches `lib/supabase`, which throws at module scope without them. That is the
same defect that had five workflows never running; #1274's import-env guard now
covers this one too, and it passes.
Part of #1270
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs
* fix(moderation): harden the new workflow, and clear the Sonar findings
The quality gate failed on `new_security_rating: 3`, and the cause was the
workflow this PR added: it copied the sweep template rather than the hardened
form seven other workflows already use. `npm ci --ignore-scripts`,
`npx --no-install --ignore-scripts prisma generate`, and `--ignore-scripts` on
the tsx invocation. A cron runner has no business executing arbitrary package
lifecycle hooks, and Prisma is generated explicitly anyway.
Also: `node:fs` over `fs`; `OPEN_REPORT_STATUSES` as a `Set`; readonly props on
`EnforcementSummary`; and the banned/suspended nested ternary extracted into
`describeEnforcementState`, because "banned" and "suspended until X" are
different states rather than two branches of one.
Sonar's `structuredClone` suggestion (S7784) is DECLINED in both places, with the
reasoning recorded in the code. Those calls are a serialization, not a deep
clone: `SideEffectSummary` has five optional fields, the JSON round-trip drops
the undefined ones, and Prisma's InputJsonValue rejects `undefined` in an object.
structuredClone would preserve them and break the write.
Part of #1270
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs
* fix(moderation): verify a reported message with Stream before trusting its identity
Review round 1 on #1279. Seven findings; one was a vulnerability I introduced.
**A reporter could get an arbitrary message deleted.** The route took
`streamMessageId` and `streamChannelCid` from the browser and stored them, and
`CONTENT_REMOVED` later forwards the stored id to Stream's server-side delete.
So an authenticated caller could report user X while supplying a message written
by someone else, and a staff moderator acting in good faith became the
instrument for deleting it. The caller-supplied cid meant the staff deep-link
pointed wherever the reporter chose, too.
Both now come from Stream's own answer: the message is resolved with server
credentials and its author must be the reported user, or the report is stored
with no message identity at all. That degrades to the pre-#1270 behaviour — a
report a human reads — rather than refusing the report, because a reporter
should not be blocked by Stream being briefly unavailable. Three tests pin it,
including the wrong-author case and the Stream-down case.
**The failed-enforcement summary could vanish.** That row IS the outbox: the
retry sweep selects on `sideEffects.stream === "failed"`, so a swallowed write
did not merely lose a status field, it lost the queue entry — and a ban that
never reached Stream was then never retried and never surfaced. It retries once
and escalates at `fatal` if both attempts fail.
**The sweep forwarded a message id without its channel**, so a re-drive could not
address the message it was retrying. The test that should have caught it asserted
only the id.
Also: the sweep reported `success: true` while recording errors, which is what
the workflow's exit code reads; `persist-credentials: false` on checkout, since
no git write happens in a job that runs third-party package code; the Jobs page
still described this cleanup as on-demand after it was scheduled; and the action
spinner replaced the icon on every button rather than the clicked one.
Part of #1270
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs
* docs(moderation): the sweep IS scheduled — say so in all three places
Review round 2 on #1279. The finding was a consolidated one naming three sites,
and the first pass fixed only the anchor. The other two still claimed, inside
this very PR, that the scheduling workflow belonged to a sibling change and that
the sweep ran on demand — while `.github/workflows/retry-moderation-enforcement.yml`
sits in the same diff carrying `8-59/30 * * * *`. Documentation that contradicts
the code in its own commit is worse than none.
The ADR now records WHY it is scheduled rather than merely that it is: a ban
whose Stream revocation failed is invisible from the product, because the
database says banned and the moderator has already been told it worked. Nobody
would think to press the button, so the only signal would be the harasser
continuing to post.
Also drops `streamChannelCid` from the request destructure. It is still accepted
by the schema and deliberately ignored — the stored cid comes from Stream's own
answer, because a caller-supplied one pointed the staff deep-link wherever the
reporter chose. Left unread it was an unused binding, which SonarCloud fails on.
Part of #1270
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs
* fix(stream): HMAC the bytes Stream signed, not the bytes it sent
Not moderation. Folded into this PR deliberately rather than opening another,
since it is small and self-contained.
Stream computes its webhook HMAC over the UNCOMPRESSED payload and then
optionally gzips the body on the wire. `enable_hook_payload_compression`
defaults to TRUE for apps created after 2026-05-07, with a 256-byte threshold
that every recording and session event clears. This route did
`crypto.createHmac(...).update(await req.text())` — the bytes that arrived.
Today those are the same thing, because the live app has the setting unset. I
verified that against Stream rather than assuming it. So the route has been
right by accident, and the accident is not worth keeping: the moment a gzipped
body arrives the signature cannot match, the route answers 401, and Stream
treats a 401 as FINAL. It is not in the retryable set, so the event is dropped
and never redelivered — silently, for every delivery. No attendance rows, no
recordings, no session ends. That is precisely the #1134 shape: 0 WebhookEvent
rows for provider='stream', 0 MeetingAttendance, and 1,663 sessions that never
ended.
`readSignedBody` reads the raw bytes and gunzips them when they start 0x1f 0x8b.
Detecting the magic number rather than trusting `Content-Encoding` is
deliberate: a platform layer may decompress the body and leave the header on, or
pass it through and strip it, and this repo's own history with Netlify request
handling says not to assume which. The bytes cannot lie about what they are.
Six tests, including one asserting the OLD behaviour would have failed the
gzipped case — a fix to a signature path nobody exercises is worth very little.
This also makes pinning the Stream-side setting unnecessary rather than urgent.
Fixing the code so the configuration cannot hurt us is the better half of that
choice; the config can still be pinned, but nothing now depends on it.
Part of #1270
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* chore(stream): bump the video and chat SDKs to current The declared ranges were stale by up to eleven minors, so the lockfile alone decided which video stack shipped. This brings both into line and picks up a second, independent cause of the hung join that started #1270 — client 1.50.0 fixes a call.join() hang on a silent WS handshake stall, which #1271's server-side fix could never have reached. Also lands three fixes for a network blip tearing down a healthy SFU socket, which is the client-side mechanism behind the empty-room session-ended bug #1277 fixed the consequences of, two camera-release bugs below lib/stream/media-teardown.ts, and the concurrent-join guard. No source changes: the diff is package.json and package-lock.json. The two named risks were measured rather than assumed. #1078 warned that 1.39.0 pins ES2022 while we compile es2017, but the emitted bundles contain no ES2022 syntax at all — the highest feature is ES2021 `??=`, supported since 2020 — so transpilePackages is deliberately not extended, which would only add weight against #1158's non-raisable 250MB server-function cap. The packed delta there is +470KB, and video-filters-web still fetches its 26MB of MediaPipe assets from a runtime URL rather than a filesystem require, so nothing traces them. stream-chat 9.52.0 also brings verifyAndParseWebhook, which the webhook compression fix needs later. Held deliberately: stream-chat v10 (rc.8, breaking renames as recently as rc.7), stream-chat-react v14 (a design-system refresh against heavy custom chat UI, zero correctness gain), and node-sdk 0.8.x (drops Node < 22.12 for no benefit). Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * chore(stream): pin the Stream stack to exact versions Carets let a lockfile regeneration move the video stack silently, which is how the declared range drifted eleven minors behind what was installed. Two things here make that worse than ordinary dependency drift. The server function sits near 245MB against a non-raisable 250MB Netlify cap, and the failure presents only at the deploy stage with every other check green (#1158). And 1.39.0 raised the supported-browser floor, which no type check would surface. Neither is something a silent minor bump should be able to introduce. Pinning the top of the chain is enough: @stream-io/video-react-sdk pins video-client, video-react-bindings and video-filters-web exactly in its own dependencies, so one pin fixes the whole video stack. Dependabot remains the upgrade path, which is the point — the bump becomes a reviewable PR with a deploy preview rather than an accident of reinstall. Resolved versions are unchanged; this is a declaration change only. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…1284) * fix(recordings): move storage access out of the transfer pipeline The transfer service is scheduled for deletion — once Stream writes recordings straight into our bucket via recording_external_storage there is no download step left to keep. But three things inside it have to outlive it, and one of them is load-bearing for revenue. getBestRecordingUrl is what mints playback for a PAID replay. It lives in the file being deleted, so removing the pipeline without moving it first would take the recordings marketplace down alongside it, silently — the player would simply stop resolving a URL. Alongside it, publish, purchase and the marketplace listing query each carried their own copy of `status === "AVAILABLE" && storageType === "SUPABASE"`. That spells a vendor into a business rule that is really about custody: has this recording outlived Stream's fourteen-day clock, or not. Three copies of a rule drift three ways, and the rule is about to change under all of them. It is one predicate now, in two forms — a check for loaded rows and a where-fragment for queries that cannot load one first, built fresh per call for the same reason dmEligibleStatusFilter is. No behaviour changes here. The predicate is the same pair of values it was. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(recordings): take the vendor name out of the storage model The schema said Supabase in three places that are really about custody, not about a vendor: RecordingStorageType.SUPABASE, RecordingStoragePolicy .SUPABASE_PERMANENT, and the supabaseUrl/supabasePath columns. The question each of them actually answers is "have these bytes outlived Stream's fourteen day clock", and the answer is about to stop involving Supabase at all — Stream can write straight into whichever bucket we point it at, and which bucket that is has been left deliberately open. So: SUPABASE → PLATFORM, SUPABASE_PERMANENT → PERMANENT, supabaseUrl/ supabasePath → storageUrl/storagePath. Doing it now rather than later is the cheap moment. Checked against the live database first: no row anywhere uses either enum value. All 191 recordings are STREAM_S3 and all 971 plan rows across the four plan models are STREAM_ONLY, so this is a pure schema change with nothing to migrate. Five files that mention supabaseUrl are deliberately untouched — there it is the client URL from NEXT_PUBLIC_SUPABASE_URL, not the column, and renaming it would have broken the storage client. Needs a db push before deploy: two enum values and two column names. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(recordings): record the DDL the rename needed `prisma db push` cannot express this change. Postgres has no `ALTER TYPE ... DROP VALUE`, so a push would have added PLATFORM and PERMANENT while leaving SUPABASE and SUPABASE_PERMANENT behind in the live types, and the Prisma client refuses to read a column whose enum carries a label it does not know — P2023, on every read of Recording.storageType and of the four *Plan.recordingStoragePolicy columns. That is the failure that left reconcile-disputes red for weeks after 183d0e7. Push also treats a column rename as a drop plus an add. Unlike that earlier case this is a rename rather than a drop, so it does not need the create-replacement-type-and-swap-columns dance: `ALTER TYPE ... RENAME VALUE` has existed since Postgres 10 and rewrites the label in place. No new type, no column swap, no defaults to rebuild, a much lighter lock. Applied to pzmbxqdgibfkhjwzeprf as migration rename_recording_storage_vendor. Verified after: both enums read STREAM_S3/PLATFORM and STREAM_ONLY/PERMANENT with the old labels gone rather than accumulated, both columns renamed, all 191 recordings intact, and check-db-drift green at 118/118 enums. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…pwire, make the DB guards real (#1285) * fix(stream): close the live billing tripwire and make the DB guards real Two operational fixes that need no deploy, and one guard that has never run. ## The call type `audio.noise_cancellation.mode` was `auto-on`, which means registering the Krisp instance and switching on per-participant-minute billing are the same act — the day `@stream-io/audio-filters-web` ships, every call on the platform starts charging for it. Now `available`, so enabling it becomes a deliberate decision. `frame_recording` and `ingress` were both live and entirely unused. Stream ships permissive call-type defaults, so "we have not built it" and "it cannot be started" are different statements; both are billable and both were startable by anything holding the capability. `session.inactivity_timeout_seconds` was 30. That is how long Stream waits after the LAST participant leaves before ending the session, and it is what let one party stepping out mid-appointment end a booking — locking both sides out and, in one direction, auto-refunding in full against a consultant who was three minutes late. Now 900. #1277 fixed the consequences in code; this removes the event from the hot path. They fail independently, which is why both. The script probes before it writes. `updateCallType` is undocumented on whether it merges or replaces, its chat twin `channel.update()` is a full replace, and `user`/`guest`/`call_member` all currently hold `join-call` — so a replace would have been a total video outage. The probe settled it: top-level settings merge, grants are untouched. Sub-objects are validated whole, so each is rebuilt from the live read rather than written from a literal. ## The guards `check-db-sidecars` and `check-db-drift` both printed "DATABASE_URL unset — skipping" on every CI run. CI writes the secret to `.env` as a file and only Next loads that implicitly; every other database-touching script here imports `dotenv/config` and these two did not. So the guard built to catch enum drift has never executed once — which is why #1284's drift reached the build stage and surfaced as an opaque prerender error instead of a four-line diagnosis. Turning it on exposed a bug in it. `parseSidecars` regexed raw SQL, so the block of PROPOSED constraints commented out in check-constraints.sql counted as required objects and the guard demanded three nobody had agreed to create. The bug and the reason nobody saw it were the same bug. Comment stripping is quote-aware: a `--` inside a literal is data, and the trigger bodies live in dollar-quoted blocks that must survive intact. That leaves one genuine drift, now repaired: the 2026-08-29 per-session-review migrations dropped `consultant_review_legacy_pair_key` and never recreated it, so pre-appointmentId rows silently lost their one-review-per-pair guarantee. Applied as restore_consultant_review_legacy_pair_index after verifying zero duplicate legacy pairs. Both guards now run and pass under CI conditions: 46 sidecar objects present, 118/118 enums. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): disable individual and raw recording too The first pass caught frame_recording and missed these two. My read script only printed three capabilities, so I hardened what I could see rather than what #1160 actually listed — the new Stream MCP's full call-type read surfaced both still sitting at `available`. Same class as the others: billable, entirely unused, and startable by anything holding the capability rather than merely unbuilt. Applied and verified: all six now read as intended, grants unchanged. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): resolve the call type from the shared constant The script read `process.env.STREAM_CALL_TYPE`, which resolved correctly only because that variable is unset. The app does not use an env var: `call-cid.ts` exports `STREAM_CALL_TYPE = "default"` and `ensure-call-type-grants.ts` imports it from there. Left as it was, setting that variable would have pointed the settings script at one call type while the grants script hardened another — two tools disagreeing about which type they are protecting, which is the exact divergence class this subsystem keeps repeating. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): refuse webhooks for call types this app does not use Any signed-in user could bind a recording of their own choosing to somebody else's appointment. Every handler resolves its row with `call_cid.split(":")[1]`, discarding the type half — eight sites across recording-handlers and session-handlers. The app only ever uses `default`, but the Stream app also carries the built-in `livestream`, `audio_room` and `development` types, and the plain `user` role holds `create-call` on all three. On `development` it also holds `start-recording`, `start-transcription` and `start-broadcasting` outright. Video tokens here are app-wide — `generateUserToken` with no `call_cids` claim, deliberately, since the call-scoped wrapper was removed as unused — so every signed-in user already holds a token that works on all of them. A user knows their own anchor slot ids. Calling `getOrCreate` on `development:slot-<id>`, recording anything, and letting Stream fire a genuine `call.recording_ready` produced a cid whose id half collided with a real MeetingSession. The handler created a Recording row against someone else's appointment, inheriting its title and organizationId, and surfaced it to that consultant and their org library. Signature verification cannot help: the event really is from Stream. The same collision reached the session handlers, where injected participant events feed attendance, which feeds no-show detection, which issues refunds. Confidentiality was never at risk — call type is part of call identity, so this never reached an existing consultation. It is injection, not disclosure. The check goes at the dispatch choke point rather than the eight call sites, so a type added later cannot reintroduce it by forgetting one. `callTypeFromCid` already existed in call-cid.ts for exactly this and had zero callers anywhere in the repo. Refused events are marked processed so the sweeper does not re-drive them for its full 168-hour window. The test fails without the guard — verified by disabling it, which turns four of the seven red. An earlier draft called processStreamEvent with three of its six arguments and went green on the refusal assertions anyway, because dispatch bailed long before the guard ran; `baseEvent.call_cid` is passed separately from the event body and is the thing under test. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): address the review round on the ops hardening Four items from CodeRabbit and the Sonar MCP, triaged against the current code. **Block comments were removed rather than replaced with whitespace.** Postgres treats a comment as whitespace, so it can legally be the only separator between two tokens: `CREATE/* note */INDEX "x"` became `CREATEINDEX "x"` and the parser stopped seeing an object that IS declared — the guard passing because it had stopped looking. That is the dangerous direction for a drift check, and it was a bug in code added hours earlier in this same PR. Block comments also NEST in Postgres, unlike C, so scanning to the first `*/` left the tail of an outer comment behind as apparent SQL; both are fixed and pinned by six tests covering the commented-out proposal, the token-separator case, nesting, a `--` inside a string literal, a dollar-quoted trigger body, and an unterminated comment. Testing it meant importing the module, which ran `main()` and opened a database connection that raced the runner's teardown. The entry point is guarded now, so the file is importable and still behaves identically when invoked as a script. **The call-type check moved into `isOwnCallType`.** It was inline, which pushed `processStreamEvent` one point over the complexity threshold; as a named predicate at the boundary it also reads as what it is. The redundant `undefined` argument to `markWebhookEventProcessed` is gone — its second parameter is optional — and the guard test's assertion moved with it, which is how the change was caught. **Two small ones in the settings script:** the nested ternary in the canonical sort became a named `byCodeUnit` comparator, and the pre-image filename now replaces `:` in the ISO timestamp, which is an illegal filename character on Windows and would have thrown before the probe ever ran. Not done: `main()` in the settings script and `stripSqlComments` still exceed Sonar's cognitive-complexity threshold. Both are linear — a sequence of steps and a lexer state machine — and #1134 already declined SonarCloud-motivated refactors on this basis. The quality gate passes; these are open issues, not gate failures, and CodeRabbit's claim that the gate "currently fails" was checked against the Sonar MCP and is wrong. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * style(stream): prettier on the new SQL comment test Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * refactor(stream): split the SQL comment scanner, stop mutating in an expression Two problems I introduced with the previous commit, both surfaced by reading the Sonar analysis afterwards rather than assuming the fixes were clean. Adding nested-block-comment handling inline took `stripSqlComments` from 27 to 34 cognitive complexity — I made the thing I was fixing harder to read. It is now a dispatcher over three named scanners (`scanSingleQuoted`, `scanDollarQuoted`, `scanBlockComment`), which is what the function was always doing implicitly. Behaviour is unchanged and the six tests still pin it. Replacing the nested ternary with a named comparator introduced a new S4043: the sort ran in-place inside an expression. `Object.entries` returns a fresh array so nothing observable was wrong, but `toSorted` says what is meant. Verified after: guard still reports 46 sidecar objects under CI conditions, the settings script is still idempotent, tsc and eslint clean. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…1286) * fix(recordings): let the reconciler see partially delivered sessions The candidate filter was `recordings: { none: {} }`, so the job could only ever notice a session that received NOTHING. Stream fires `call.recording_ready` once per FILE, and splits any session over two hours into separate files — a three-hour class is three deliveries. Lose the second and third and the session holds one Recording row, fails `none: {}`, and is never examined again. The missing segments are gone when Stream deletes them at day fourteen, and nothing ever reports it: the session has recordings, so it looks healthy from every angle we had. This is not a hypothetical shape here. The webhook endpoint rejected every delivery for months because it required a secret Stream does not issue, and exactly one Stream event has arrived since that was fixed. Partial delivery is the expected case for recovery, not an edge case. Correcting my own earlier framing: this is NOT a bug in handleRecordingReady. One event per file is right, and `syncSessionRecordings` already iterates every file `listRecordings` returns and already skips any `streamRecordingId` that has a row. The recovery machinery was complete; only the candidate query was blind. So the second pass reuses it verbatim. It runs after the orphan pass and spends what that pass left of MAX_SESSIONS_PER_RUN, so a session with nothing is always preferred over one that merely might be short — without that ordering, older complete sessions would crowd out genuinely orphaned new ones. Re-examining a complete session costs one Stream read and writes nothing. Counted and alerted separately from `recovered`. A row created by this pass means a session we already believed complete was missing a file — a delivery lost from the middle of a set, which is a different and more alarming signal than one that never arrived at all. Bounded as before: the same fourteen-day window, the same 200-session cap. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * refactor(recordings): share one recovery pass between both queries The second pass duplicated the first almost exactly — same sync call, same error handling, same counting shape — and took the enclosing function to cognitive complexity 20. Two near-identical loops is also the shape Sonar's duplication gate objects to. `runRecoveryPass` now owns the loop, and the two passes differ only in which sessions they select and how the result is counted, which is the only thing that was ever actually different between them. Tests unchanged and still fail without the second pass. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(recordings): address the review round on the orphan reconciler Both CodeRabbit findings were real, verified against the code before fixing. **A recovered orphan was feeding itself into the partial pass.** The partial query ran AFTER the orphan pass, so a session the orphan pass had just repaired then matched `recordings: { some: {} }` and was re-examined in the same run — consuming the partial budget, inflating `partialScanned`, and displacing a session that was genuinely short all along. The candidate set is now snapshotted before the orphan pass, so the two passes see disjoint sets. **A swallowed failure was being read as an answer.** `getCallRecordingsFromStream` returned `[]` on any transport error, so an unreachable Stream was indistinguishable from "this call has no recordings" — the session was counted as checked-and-empty and the run stayed green. That is precisely the mistake this job exists to catch. It returns `null` for "could not ask" now, and `syncSessionRecordings` returns a `SyncOutcome` saying whether the sync actually completed and why not. The outcome is additive: the two other call sites ignore the return value, so their behaviour is unchanged. `syncSessionRecordings` still swallows errors so one bad session cannot abort a batch — it just reports them now, which is what the reconciler needs to tell "Stream says nothing" from "Stream did not answer". My existing failure test only ever exercised the throw path, which production almost never takes. Added one that models the swallowed outcome and asserts the session is NOT counted as a session Stream had nothing for. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ging for sessions nobody attended (#1287) * wip(no-show): corroborate against Stream before auto-refunding Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(appointments): corroborate a no-show against Stream before refunding, and stop charging for sessions nobody attended Two halves of #1280's money pair. Neither existed; both move real money. ## The refund that fires on a lost webhook `evaluateConsultantNoShow` decides on the ABSENCE of a MeetingAttendance row: consultee has one, consultant does not, therefore the consultant no-showed, therefore cancel and refund in full. Those two rows come from two separate `call.session_participant_joined` deliveries, potentially to different serverless instances. Lose only the consultant's and the predicate is satisfied exactly — a full refund against a consultant who was in the call. It is idempotent so it will not double-refund, but reversing it means re-charging a customer by hand. That this has never fired is luck rather than design: the webhook endpoint rejected every delivery for months, so there are no attendance rows at all and the detector has never had a candidate. It goes live the moment attendance starts working. Stream holds the same fact independently. `report.participants.unique` is a count of distinct participants in the session and owes nothing to our webhook pipeline — two, in a 1:1, means both parties were there whatever our rows say. So the refund is refused when Stream contradicts it, with an alert, because a contradiction means a delivery was lost and this is the only place that surfaces. Refused on missing evidence too. "We could not check" is not "it definitely happened", and the asymmetry is stark: refusing wrongly costs a support ticket, refunding wrongly costs a manual re-charge. ## The session nobody attended There was no both-absent detector, so a consultation where neither party turned up is charged in full, silently, and auto-complete then closes it as COMPLETED. It raises a ticket and does not decide. The product copy commits to exactly this — "a genuine connectivity failure and a no-show look identical to a script" — and two absent parties is precisely what a script cannot attribute. The ticket carries both parties, the Stream call ids, and why it was not refunded automatically. Uses TECHNICAL_ISSUES rather than CONSULTANT_NO_SHOW: the latter presumes the fault this deliberately refuses to assign. There is no neutral enum value, and inventing one is a schema change this does not need. Idempotent — `createSupportTicket` documents that callers own dedup, and this runs on a cron. ## Tests Ten, and they are real: disabling the corroboration turns four of them red. They cover both parties seen, no report, no Stream call, a corroborated single participant, a multi-slot booking where only the second session shows both, the ticket being raised and its ownership, cron idempotency, and the two cases that must stay silent — Stream saw someone, and Stream cannot speak at all. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(appointments): update the existing no-show suite for corroboration CI caught what my own tests could not: `__tests__/booking/no-show-refund-front-door.test.ts` exercises the whole detector, and every refund case in it started refusing. Both reasons were correct behaviour, which is the point of the change: Its fixture's `meetingSession` carries no `streamCallId`, so there is nothing to ask Stream about and the detector refuses rather than refunding on an un-corroborated inference. The fixture predates the requirement; it has a call id now. And the suite did not mock `getCallPresenceEvidence`, so the real one ran, found no Stream, and returned "no evidence" — again a refusal. It now models a no-show Stream AGREES with: exactly one participant in the call. Also switched my two new imports in the detector from `@/` to relative paths, matching every other lib import in that file. The alias resolves to a different module instance under jest, so `jest.mock` on the relative path did not bind and the mock silently had no effect — the failure looked identical to the fixture problem and masked it. Full suite green: 328 suites, 3612 tests. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * refactor(appointments): decompose the both-absent check Sonar flagged two things in the previous commit, both mine. The `SupportIssueType` import was a second `@prisma/client` statement beside the existing one; merged. `detectBothAbsent` sat at cognitive complexity 23, and the two things making it dense were both decisions with names: what Stream says about the sessions, and whether a ticket already exists. `streamPresenceAcross` and `bothAbsentTicketExists` now carry those, which also makes the corroboration result readable as `{ sawSomeone, evidenceMissing }` rather than two loop variables. Behaviour unchanged: 78 tests green, and disabling the corroboration still turns the guard tests red. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(appointments): address the review round on the money pair Three findings, all real, all verified against the code first. **Any prior technical-issues ticket suppressed the escalation.** The dedup matched on `consultationId` + `issueType` alone, so a ticket the USER filed during the failed call already satisfied it — and the both-absent escalation was then never raised for that consultation. It lost the automated escalation in precisely the case where someone had complained. Matches on a title prefix this job owns now, so an unrelated ticket cannot stand in for it. **Both passes were doing the same expensive work twice.** `detectBothAbsent` re-ran `findNoShowCandidates` — the same heavy nested query the caller had just run — and a candidate the main loop refused on Stream evidence keeps its APPROVED status, so it came back and had its Stream report fetched a second time. Sequential calls, inside a held cron lock. It takes the candidate list now, and one per-run cache serves both passes. **A test proved the wrong half.** The saw-someone branch asserted that no ticket is raised, but not that the alert fires — and the alert IS the feature there, being the only place a lost `call.session_participant_joined` delivery becomes visible at all. It asserts the Sentry warning now. Two cases added: one pinning the narrowed idempotency key, one proving a call id is looked up at most once per run. 80 tests green, and disabling the corroboration still turns the guard tests red. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(stream): make the call types this app never uses unusable #1285 closed the exploit route at the webhook boundary. This closes the other half — the ability to mint the call in the first place. We use exactly one call type. The Stream app also carries three built-ins, and Stream ships them permissive: on `development` the plain `user` role held thirty-six permissions including create-call, start-recording, start-transcription and start-broadcasting, and on all three `guest` and `anonymous` held join-call. Video tokens here are app-wide — generateUserToken with no call_cids claim, deliberately — so every signed-in user already held one that worked on all of them. Deleting them would be simpler and is not possible: all three are Stream built-ins. Strips reach (create-call, join-call, join-backstage, join-ended-call and their -any-team variants) and the billable starters (recording, frame recording, transcription, closed captions, broadcasting) from every role an end user can hold. Platform staff keep theirs: staff are Stream `admin` via mapRoleToStream, and an operator needs to be able to inspect or end a call on any type. The billable ones go even though reach is already gone, so re-granting reach later cannot silently re-arm the meter. Applied and verified. Blast radius was zero — no call has ever existed on any of the three. `default` re-read afterwards and confirmed untouched: `user` keeps create-call and join-call, `call_member` keeps join-call. That was the risk worth checking, since a grants map is catastrophic to get wrong and invisible when you do; the script reads back after every write and refuses if anything it stripped survived. Tests pin the one mistake that would take the platform down — the strip list containing the type the app actually uses — plus the -any-team variants, which `call_member` held on `development` and which stripping only the plain grant would have left behind. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs * fix(stream): address the review round on the call-type hardening Three findings, all valid. **A dry run was not a dry run.** `mkdirSync(BACKUP_DIR)` ran before the `--apply` check, so merely inspecting created `.stream-backups`. A dry run has to leave the tree exactly as it found it or the distinction stops meaning anything. It happens inside the apply path now, and the fix is checked: the directory is removed, a dry run is run, and it stays absent. `main` was at cognitive complexity 20 against a limit of 15; the per-type work is `hardenOne` now, which also makes the loop read as what it is. `STRIP` is a Set — it was a list scanned with `.includes()` once per permission per role per type. Behaviour is unchanged: the three types still report `already hardened`, and 432 Stream tests stay green. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…st door (#1301) * fix(stream): revoke end-call from every participant, and shut the guest door Two doors that #1134 and #1280 found open and could not close at the time. `end-call` on `call_member`. The join route assigns that role to EVERY participant, and Stream's roles do not separate host from participant here — host-ness is `custom.consultantUserId`, which Stream knows nothing about. So any attendee could end a paid consultation for both sides from devtools; `EndCallButton`'s `isHost` only decides what renders, not what Stream permits. It could not be revoked while the client needed the grant. #1270 built the replacement — `POST /api/meetings/[meetingId]/end` resolves access server-side and ends the call with the server client, and the button posts to it behind an `endingRef` guard. So the grant can go, gated on the same `--join-route-is-deployed` flag as the join-call move, because both routes ship in one deploy and a host on an old bundle would otherwise lose End Call silently. The rollback hands `end-call` back to `call_member` and to nobody else. That rollback exists for exactly one situation — the end route is not actually serving traffic — and in that situation restoring join alone would fix the lockout and strand every host inside a room they cannot close. `guest_user_creation_disabled`, read live as `false`. Guest sessions are mintable from the browser with nothing but NEXT_PUBLIC_STREAM_API_KEY, and `guest` holds `join-call` on the `default` type. #1134 decided against guest access and nothing in the tree creates a guest, so this is an unused capability with the door left open. The grants script strips the grant; the new `ensure-app-settings.ts` stops the accounts existing. Two independent locks, because the grants script has been wrong about which roles exist before. `enable_hook_payload_compression` is pinned off in the same request rather than its own — the blast radius is per-request, not per-field. It is WRITE-ONLY: on `UpdateAppRequest`, absent from `AppResponseFields`. So it cannot be read back, idempotency-checked or verified, and the script reports it as sent rather than as confirmed. The route's own gzip handling is the real guarantee. The dangerous part is neither value. `updateApp` takes a partial and nothing documents whether the fields it is not given survive, while the chat twin `channel.update()` is a full replace. This app's `event_hooks` is one hook carrying the nine video event types the entire webhook pipeline depends on, and losing it would present exactly like the 2026-08-13 outage in which that pipeline had never processed a single event. So the script writes a pre-image to disk, sends a no-op probe, re-reads, and only proceeds if nothing moved. Both new guards are pinned by tests proved red with the guard disabled and green with it restored. Verified by dry-run against the live app: `call_member` `end-call` true -> false, `guest_user_creation_disabled` false -> true. Part of #1280 Part of #1144 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stream): review round — a probe that can silently pass, and an unverified rollback Three CodeRabbit findings on #1301, all legit, all verified against the code before being taken. **The no-op probe could test nothing and report clean.** `updateApp` is sent `{ moderation_enabled: before.moderation_enabled }`. `AppResponseFields` types that as a required boolean and the live app returns `true`, but a type is a contract rather than a runtime guarantee, and this subsystem has been burned by trusting the declared shape before. If the field were ever absent the body serialises to `{}` — Stream changes nothing, the fingerprint matches, and the probe reports CLEAN having proved nothing about merge-versus-replace, then licenses the real write against the document that holds `event_hooks`. A probe that can silently pass is worse than no probe, so an absent field is now a refusal. **The key-ORDER test was vacuous.** It reassigned `file_upload_config`, which has ONE key, so no reordering occurred: the fingerprints matched under plain `JSON.stringify` and the case passed with `canonical`'s sorting deleted. It now reverses the eight-key event hook, and deleting the sort turns it red. **The rollback had no post-write verification at all.** Both grant assertions are gated on `!opts.restore`, so `--restore-user-join` fell through to the settings comparison, found nothing moved, and returned 0 — reporting success without ever asking whether the restoration landed. That is backwards: the rollback is the emergency path, reached when the revocation has already locked people out, and "it worked" is the one thing an operator cannot afford to be told wrongly. Asserted only when the run actually intended to restore the grant, so rolling back a call type that never had it does not fail on a no-op. Each new guard proved by disabling it, confirming red, and restoring. 24 grants tests and 9 app-settings tests green; tsc and eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(stream): one drift comparison for the three config scripts, not three SonarCloud failed #1301's gate on new-code duplication (3.7% against a 3% cap), pointing at 28 lines shared between `ensure-app-settings.ts` and `ensure-call-type-settings.ts`. Chasing it found a third copy in `ensure-call-type-grants.ts`, written differently again — an inline `.sort()` where the other two use `.toSorted()`. The duplication is the symptom; three implementations of one safety check is the problem. All three scripts write a partial to Stream, none of the endpoints document whether the omitted fields survive, and the chat twin `channel.update()` is a full replace — so each snapshots, writes, re-reads and compares. That comparison is what decides whether an operator is told their `event_hooks` was just destroyed, and keeping three copies of it is three chances for one to drift into a false negative on the run where it matters. `lib/stream/config-fingerprint.ts` now owns `canonical`, `byCodeUnit` and `diffFingerprints`. Code-unit ordering, never `localeCompare` — same rule as the channel ids, and for a sharper reason here: an ICU-dependent comparison would make an identical config report as drift on one machine and not another, which mid-incident reads as "Stream wiped settings it never touched". No behaviour change. 445 tests across 34 suites green, tsc and eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…t can never work (#1302) * fix(stream): unpick the shared circuit breaker, and stop retrying what can never work The Bucket-2 residue from #1280 plus the review findings #1146 caught after the PRs that owned them had already merged. ## Stream and Redis had one circuit breaker between them `lib/redis.ts` declared a single module-level object and every Stream call routed through it. The coupling ran both ways and both were wrong: five Stream failures opened the breaker that `utils/appointmentlock.ts` acquires booking locks through, so a VIDEO VENDOR OUTAGE STOPPED CHECKOUT; and a Redis outage surfaced to users as "Video is temporarily unavailable" while `/api/health` blamed Stream, pointing whoever was on call at the wrong vendor mid-incident. Counts interleaved too, so three Stream errors plus two Redis errors tripped a breaker neither service had earned. Now a factory, one instance each. The thresholds are deliberately untouched — #1280 Bucket 4 is right that per-instance state on short-lived serverless rarely accumulates five failures. The coupling is the correctness bug; the numbers are not. ## A billing refusal is not an outage Only 404 and 429 were classified, so a MAU cap, a declined card or a suspended account fell into the generic branch: Sentry error, breaker trips, 30s reset, half-open probe, trips again, forever. Nothing in that loop says "we owe Stream money", which is the only actionable fact. `isStreamBillingError` (402/403, API codes 99 and 2) is treated like 429 for the breaker — retrying cannot fix it — but escalated to its own alert, because unlike 429 it does not self-resolve. ## The health probe had no deadline of its own `getAppSettings()` takes no arguments, so there is no per-request timeout to narrow, and the breaker's only timing knob governs OPEN→HALF_OPEN rather than the operation. The breaker therefore protected the SECOND probe of an outage and not the first: in the opening minutes `/api/health` could hang for the client's full 30s on the endpoint whose whole job is to report that outage quickly. Now 2s, raced INSIDE the breaker so the timeout counts as a failure — a caller-side race would return early and never trip the thing that makes later probes cheap. ## Eight `.parse()` calls that punished a permanent failure like a transient one A ZodError landed in the handler catch, was stamped as an ordinary error, and the sweeper re-drove the row every ten minutes for its 168-hour window — roughly a thousand attempts at a payload that cannot become valid, burning the sweeper's budget and hiding a real contract break behind noise that ages out on its own. One `safeParse` at a choke point now, against a schema/handler table whose `satisfies Record<HandledEventType, …>` is the same exhaustiveness proof the `never` default branch was. A mismatch is stamped `permanent:` — a prefix the sweeper's selector skips, alongside the `gave up:` marker it already had, both now derived from one shared constant rather than a string literal in each place with a comment asking the next editor to keep them in sync. One Sentry alert naming the offending fields, instead of a thousand silent retries. **Not adopted: `verifyAndParseWebhook`.** It returns only the parsed `Event`, not the uncompressed bytes, and our dedup key is `sha256` of exactly those bytes because they are the only material Stream signs. Adopting it would force the key to be re-derived by re-serialising, and `JSON.stringify` is not byte-stable, so two retries of one delivery could hash differently and dispatch twice. The SDK's `verifySignature` is adopted instead — same algorithm, constant-time, maintained against the cross-SDK contract, and it drops a hand-rolled comparison that never validated its input was hex. ## Join had no in-flight guard on any surface The first `await` is `waitForGlobalVideoClient()`, which waits out a retry ladder on a cold provider, and the per-row "joining" flag that looks like a guard is React state — written asynchronously, so the second click reads the stale value. Two concurrent runs mint the call twice, and `useLazyJoinMeeting`'s `?? slotsOfAppointment?.[0]` fallback reads an UNSORTED array, so they can resolve two different anchors and put the two sides of one booking in two different rooms. That is #1061. `createInFlightGuard` copies `ExitMeetingButton`'s ref pattern rather than inventing a second mechanism, keyed so one row's Join does not block another's. Split from its React binding because this repo has no renderer in its dev dependencies, and a guard against a double-click is exactly the thing that must not go untested. ## The maintenance drain could skip the entire maintenance posture `drain-sessions.ts` had NO test coverage at all, which is why all three of these survived. Stream grants `use-frozen-channel` to no role, so a channel left frozen is unwritable by every user AND every admin, with no error and no visible cause. - The per-session transaction was unguarded inside a plain `for`, so one rejection propagated out of `drainActiveSessions` and cost two things: every later session kept running on Stream while the platform went OFFLINE, and — because the freeze happens after the loop — no channel was frozen at all. - The unfreeze re-derived its set from a heuristic that was approximate three ways: a window longer than six hours matched nothing, an uncapped 200-row take left an arbitrary remainder, and a session whose `call.end()` failed was frozen but never stamped, so it could not be found — and `call.end()` failing means a Stream outage, which is when maintenance runs. Now a Redis ledger of what was CONFIRMED frozen, retiring only what came back unfrozen so a refusal is retried on the next OFF rather than left forever. The heuristic stays as a fallback: approximate beats never. - `runPostRecovery()` was awaited bare, ahead of the unfreeze. Every step inside it is individually wrapped today, but the call site took no responsibility, so one future unguarded `await` in there would make an OFF transition skip the unfreeze and brick group chat with no signal. Note the breaker fallbacks were REMOVED from the ledger writes: a fallback returns its value and swallows the rejection, so the `catch` meant to record the failure never ran and a Redis outage left no ledger and no error. ## Docs `SKILL.md` taught three things the code had since decided against: scoping video tokens per call (the wrapper was written and removed — `stream-client.ts:117`), dedup on `X-Webhook-ID` (spoofable; the route rejects it at `:183-201`), and `noise_cancellation: auto-on` (it is `available` since #1285). Also drops a reference to `chat-moderation-handlers.ts`, which no longer exists. ## Verification Full suite: 332 suites, 3640 tests, green. Cold `tsc` clean, eslint clean on every changed file. Every new guard proved by disabling it, confirming red, and restoring — including the exhaustiveness table, which fails `tsc` in two places when a row is removed. Part of #1280 Closes #1146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stream): narrow the billing classification to what Stream actually documents CodeRabbit round on #1302. Four findings, all legit; one was already fixed, one is Major. ## `isStreamBillingError` matched three things it should not have Checked against Stream's published error table (<https://getstream.io/chat/docs/node/api_errors_response/>) rather than reasoned about: | code | HTTP | meaning | |------|------|--------------------| | 99 | 403 | App suspended | | 2 | 401 | Access Key invalid | | 17 | 403 | Insufficient perms | | 70 | 403 | No channel access | The first revision matched `402 || 403 || code 99 || code 2`. Three quarters of that was wrong in a way that mattered: - **code 2 is authentication, not billing.** A rotated-away or mistyped API key would have been excluded from the breaker and reported as "we owe Stream money" — the most misleading diagnosis available for a misconfiguration, because it sends whoever is on call to the billing page instead of the env vars. - **a bare 403 is not billing either.** Codes 17 and 70 share it, so an ordinary permission refusal would have been laundered into a billing alert. - **Stream documents no 402 at all**, so including it implied knowledge of a contract that does not exist. Now code 99 and nothing else. Narrow and cited beats broad and guessed — anything this does not catch still reaches the generic branch, which pages. ## The main claim of that change was untested The breaker mock ignored its third argument, so `shouldTrip` — the predicate deciding whether a failure counts toward opening the breaker — was invisible to every test. The headline behaviour, that a suspended-app error must NOT trip Stream's breaker, had no assertion behind it. The mock now records the verdict. Note the mock had to be restored per-test as the RECORDING one: an existing case swaps the delegate to capture `shouldTrip`, and that assignment persisted, so restoring a bare pass-through in `beforeEach` left every later case blind again. ## A partially-written ledger was treated as complete The sharper of the two Major findings, and correct. The frozen-channel ledger is written incrementally per batch, so "non-empty" and "complete" are different claims. If batch A records, batch B freezes successfully, and B's `sadd` fails, the ledger holds only A — and the unfreeze, seeing a non-empty ledger, would reverse A, skip the derived fallback entirely, and leave every channel in B frozen after the OFF transition. Frozen means unwritable by every user and every admin, with no error text and no visible cause. A failed ledger write now records a marker, and the unfreeze UNIONS ledger with derived whenever that marker is set or unreadable. Union rather than either-or, because the asymmetry decides it: unfreezing something already unfrozen is an idempotent no-op costing one rate-limited call, while missing one is permanent. The clean path is unchanged and still skips the extra query. An earlier draft of this also kept a module-level `ledgerComplete` boolean as a belt-and-braces in-process signal. It was removed rather than kept: it is redundant with the marker and the existing fail-to-derived catch, and module state outlives a single call, so a warm instance carried one drain's failure into an unrelated later unfreeze — which the test suite caught by leaking the same way. ## Already fixed `run`'s cognitive complexity of 21 was decomposed into `isRefusing`, `recordSuccess` and `recordFailure` before this review landed. Also aliased the repeated `"ledger" | "derived" | "none"` union (`typescript:S4323`). 13 drain tests (was 8) and 30 client tests (was 26), each new guard proved by disabling it and confirming red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(stream): cover the MockRedis set operations the ledger relies on `USE_MOCK_REDIS=true` is how local dev and parts of CI run, so a ledger that silently misbehaves against the mock would make the maintenance drain look correct everywhere except production. Six cases: incremental accumulation across batches, no duplicate on a retried batch, partial removal leaving refused channels for the next run, absent key reads empty, key dropped when the last member is retired, and a non-set value degrading to empty rather than throwing inside a drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ook again (#1303) * feat(stream): freeze a dormant pair's DM, and unfreeze it when they book again The last piece of #1270's scope. Nothing has ever ended a direct-message channel: `syncUserEventChannels` reconciles membership but no stage freezes or deletes a DM, so channel count and membership grow without bound on a product billed per monthly active user, and there is no retention answer for a compliance review. ## Dormancy is a property of the PAIR DM ids are keyed on the pair (`dm-<a>-<b>`), never on an appointment, and `DM_ELIGIBLE_STATUSES` deliberately includes `COMPLETED` so a finished booking keeps the conversation open. A per-appointment trigger would therefore freeze a live relationship the moment one of its bookings completed. So the job groups by CHANNEL — the id is a function of the pair AND the funding context, so one pair legitimately holds a personal `dm-` channel and a separate `dmo-` per org — and measures against the latest slot across every booking that channel covers. Ninety days, not the event stage's seven. An event ends on a schedule and its chat has a natural tail; a consulting relationship does not, and a fortnight between sessions is ordinary. Deletion follows at the org's `chatRetentionDays`. ## The unfreeze is the part that makes it safe An event never resumes, so a frozen event channel stays frozen correctly. A pair does resume. Without a reversal, the first thing a returning consultee would find is a channel they cannot post in — and because Stream grants `use-frozen-channel` to no role, with no error text explaining why. Freezing without unfreezing would be a worse bug than never freezing. So an active-but-stamped pair is unfrozen, FIRST and outside the per-run budget: a frozen channel belonging to an active pair is a live user-facing fault, while a dormant pair staying unfrozen one more day is not. ## Schema `Consultation.chatFrozenAt` and `Subscription.chatFrozenAt` mirror the existing columns on `Webinar` and `Class`, but are read as MAX() across the pair and cleared across all of them — stamping one row would let a second booking report the channel as unfrozen while it was not. `Organization.chatRetentionDays` defaults to 365, split from `streamRecordingRetentionDays` (90) which chat used to borrow. The two are not the same question: a recording is a stored asset with a storage bill, a chat channel is the written record of a professional consultation. Deliberately NOT clamped to a floor — PR #1266 establishes Rule 8(3) as a narrow preservation-for-State-access duty that does not commence until 13 May 2027, so a hard minimum would over-state what binds us. Migration applied to the shared dev database and verified; purely additive, and recorded at prisma/sql/one-off/2026-09-01-add-dm-chat-freeze-ledger-and-chat-retention.sql. `check-db-drift` green, 118/118. ## The freeze says so before it lands `lib/stream/system-message.ts` is the first `sendMessage` wrapper in the repo — nothing anywhere sent a Stream message from the server before. A frozen channel refuses every send with no error a user sees: they type, nothing happens. So the freeze posts a system notice first, and the order is load-bearing, because a message sent after the freeze would itself be refused. ## Also Two chat features were dead code. `client.on("*.**", handler)` registers under the LITERAL string key — verified against the installed SDK, which records `'*.**'` and `'all'` as separate keys and fires only the latter — so the entire live channel-list updater in `ChatSidebar` and every counter in `DebugDialog` have never fired once. The single-argument form is the "every event" listener, which is why `useChatUnreadCount`'s badge updated while the list it points at did not. 351 legacy underscore channels (`webinar_`, `class_`) are declared rather than resolved by accident. They are deliberately NOT added to `MANAGED_CHANNEL_PREFIXES`, which the plan for this work called for: that list makes the reconciler REMOVE a user from any channel carrying the prefix that is absent from the expected set, and the expected set emits `webinar-<id>`, never `webinar_<id>` — so all 351 would be classified stale and their members removed on the owner's next dashboard load. That is #1134 P0-7 exactly. Ran `purge-memberless-dms.ts`: 30 phantom channels deleted, all with zero messages, the 6 message-bearing ones preserved by the script's default. 135 → 105 channels, re-run confirms 0 candidates remain, pre-image on disk. ## Verification 331 suites / 3636 tests green, cold tsc clean, eslint clean. The DM stage goes from no coverage to 12 tests, each proved by disabling the behaviour it pins: removing the unfreeze branch reds 2, sending the notice after the freeze reds 1, measuring dormancy on the oldest booking instead of the newest reds 3. Part of #1280 Closes #1270 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stream): never hard-delete a DM from a page that may be incomplete CodeRabbit round on #1303. Seven findings, all legit, one of them serious. ## The critical one: truncation could destroy a live pair's history Both booking queries cap at `MAX_DM_PAIRS_PER_RUN` and order by `requestedAt`, which is NOT the key dormancy is measured on — that comes from the latest slot `endsAt` across the pair. The two are independent, and a long-running booking is requested once and then generates sessions for years, so it sorts old and is the first thing a full page drops. If a pair keeps one low-activity booking on the page and loses its active one, `lastActivityAt` is computed from the stale row, the pair classifies as past retention, and `hard_delete: true` destroys the chat history of a live consulting relationship. Unrecoverable, and silent — nothing recorded that rows had been dropped. The delete stage is now withheld entirely when either query fills its page, the run is marked unsuccessful, and the held-back count is reported. Freezing still runs, deliberately: an over-eager freeze is undone by the next run's unfreeze branch, and withholding it too would be over-correction. ## And an org above 365 days got no deletion at all The scan window came from `MAX_RETENTION_DAYS` (365) plus margin, while `chatRetentionDays` accepts any value. An org on 500 days had every booking dropped by the bound before it could be classified — silently, and in the direction of keeping personal data forever. The window is now derived from the largest value actually configured across all organizations. ## SonarCloud gate `runDmStage` was cognitive complexity 20 and `applyDmFrozen` 16, against a limit of 15. Extracted `classifyDmPairs` (pure, now directly testable), `announceFreeze` and `writeDmLedger`. Behaviour-preserving. ## The rest `deletedDms` counted requests, not deletions — `deleteChannels` is idempotent and returns a task id, so a pair past retention is re-sent every run until it ages out of the scan window. Renamed `dmDeleteRequests`, which is what the number is. Bounded by the lookback rather than unbounded, so the metric was the defect, not the work. `sendSystemMessage` spread `custom` LAST, so a caller key named `type` could override `type: "system"` — the field the docstring calls load-bearing, because a regular message touches unread counts. Fixed ordering; no caller does this today. The channel-identity test proved nothing: it re-derived both ids from `getDmChannelId` and compared them to each other, which passes even if the job addressed the wrong channel or the wrong Stream type. The mock now captures `channel()` arguments and the test asserts the org channel was frozen and the personal one was not — the failure `lib/stream-channel-ids.ts` records as having gone unnoticed for months. Left as-is with a reason: `loadOrgChatRetention` still reads every organization rather than only those with a booking on the page. It has to — the scan window is derived from the largest configured retention, and narrowing it to the orgs already loaded would make the window depend on the page, which is the same incomplete-input-drives-a-destructive-decision shape as the finding above. 15 DM tests now (was 12), each new guard proved red with it disabled. 331 suites / 3639 tests green, tsc and eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…er ran (#1304) * fix(stream): tag the org at source, and find out why the backfill never ran Measured against the live Stream app on 2026-08-30: ZERO of 886 channels carry `organization_id`, in any form, and `dmo-` is 0. The org Messages tab and the `/api/organizations/[orgId]/stream/channels` compliance route both filter on that field, so both return empty for every organization — a compliance export that reports "no channels" rather than failing. #746 §1 records per-org channel tagging as done. It is not, and there are three separate reasons, each of which had to be found by running things rather than reading them. ## 1. The lazy create path never carried the field `createChannel` tags on the eager path, but `addUserToEventChannel` — the create-on-miss that actually mints most channels — built its `create()` payload without it. Now resolved through the existing `bookingOrgId`, which had to be widened: it knew about consultation and subscription plans but not `webinarPlan`/`classPlan`, so the event path had nothing to ask. One resolver, not two — a second implementation for events is how the tag came to disagree between the creator, approval and the reconciler in the first place. Spread in only when set. A literal `organization_id: null` is a SET on Stream's side, and the reconciler's `$exists` filters treat present-but-null differently from absent. ## 2. The backfill script has never been able to start `scripts/stream/backfill-channel-org.ts` is the only script in that directory without `import "dotenv/config"`. `tsx` does not read `.env` on its own, so `lib/redis.ts` — pulled in transitively through `lib/stream-client` — hit its module-scope env check with `UPSTASH_REDIS_REST_URL` undefined and threw before a single line of the script executed. Written, reviewed, merged, and never once runnable. Same shape as the CI database guards in #1285, which printed "DATABASE_URL unset — skipping" on every run for the same reason. ## 3. And once it started, every channel errored `channel.query()` maps to Stream's GetOrCreateChannel endpoint, which under server-side auth refuses without an author: StreamChat error code 4: GetOrCreateChannel failed with error: "either data.created_by or data.created_by_id must be provided when using server side auth." A read-shaped call that can create is not a read — the same trap #1270 hit on the video side. Replaced with `queryChannels` filtered by id, which cannot create: a missing channel comes back as an empty page, which is the right answer for a backfill. Minting an empty channel as a side effect of inspecting it would be worse than not tagging it. While there: dry run is now the DEFAULT and `--apply` writes. It was the other way round, so the bare command wrote to production Stream — the opposite of every sibling script in that directory, which is a trap for an operator who has learned that these are safe to run and read first. `--dry-run` still works. ## 4. Logout left the connection store reporting "connected" `resetStreamConnection` had exactly one reference in the repo: its own definition. The obvious reading is dead code to delete, and the plan for this work said to delete it. It is the opposite — a correct helper nobody wired up. `disconnectStreamClients()` nulls the global client refs but never touched the store that `useStreamConnection` reads through `useSyncExternalStore`. So after sign-out the store still reported `{ chatConnected: true, videoConnected: true, clients: <the objects just torn down> }`, and the next user's session started against the previous user's state until the provider republished. Now called from the teardown. `connection-store` is `import type` only for the SDK, so `disconnect.ts` stays SDK-free. ## Not done: the org switch The plan called for `resetStreamConnection` on an org switch too. It is not needed and would be a no-op with a cost: `OrganizationSwitcher` is pure navigation, `StreamProvider` is keyed on the session `userId` which does not change across orgs, and org scoping is route-derived filtering in `ChatSidebar`. Tearing the connection down and rebuilding it on every org switch would add a reconnect to a navigation that does not need one. ## Docs Corrected #746 §1's claim (in a comment on the issue) and added a dated correction to `docs/stream/13-recording-webhooks.md`, which describes the recording transfer pipeline as healthy and running when it has never executed once — 191 recordings, all `STREAM_S3`, `transferAttempts = 0`. Follows the addendum pattern already used in `architecture-review-2026-08-23.md` rather than deleting the section, because it still describes the design accurately. ## Verification 653 tests across 49 suites green, cold tsc clean, eslint clean on every changed file. The resolver's new arms are pinned by 4 tests, proved red by removing them. The backfill was run for real against the live app: it now completes with 0 errors where every channel errored before. Part of #1280 Part of #746 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stream): make the org resolver answer the same way for every booking kind Self-review of this PR, since it is the one with no bot review — CodeRabbit has been rate-limited on it since it opened. `bookingOrgId` resolves plan-first-then-appointment, but the consultation and subscription arms of `getEventData` were only passing the PLAN. A personal plan booked under an organization carries its org on the APPOINTMENT, and the DM-eligibility path (`pairBookingContexts`) does read it — so the same pair would be treated as org-scoped by the eligibility gate and produce an untagged channel from the tagger. That is the same class of defect this PR exists to remove: a resolver that answers differently depending on which caller asks. Leaving two arms half-resolved while fixing the other two would have written the bug back in a quieter place. Not a live defect. `app/api/stream/channels/open/route.ts:69` restricts `eventType` to `["webinar", "class"]`, so those two arms are reachable only from tests today — verified rather than assumed. Fixed anyway, because whether the trapdoor is currently reachable is not the same question as whether it is a trapdoor. Two tests, proved by deleting the appointment fallback from `bookingOrgId` — two go red. 331 suites / 3,629 tests green, tsc clean, eslint clean on every changed file. Part of #1280 Part of #746 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stream): a teardown should only clear the session it started CodeRabbit's first look at #1304 — it had been rate-limited since the PR opened, and got through only after pausing automatic review and asking for a full one. Three findings, two Major, both real. ## The teardown could clear a session it did not start `disconnectStreamClients` awaits `disconnectUser()` on each client, and those awaits take real time. The provider retries connecting on failure, so a retry landing mid-teardown publishes new clients and new store state — and the code after the awaits then nulled a client nobody asked it to touch and reset a snapshot describing a live session. The app ends up connected on Stream's side and disconnected in its own state. The ref-nulling half of that predates this PR; adding `resetStreamConnection` widened it to the store, which is what made it visible. Both halves are fixed together: the clients being torn down are captured at entry, and nothing is cleared if a newer connection replaced them. A supersession is logged, because it means a sign-out and a reconnect interleaved and the reconnect won — not broken, but not what the user asked for either. ## The org fallback was order-dependent `find()` returns whichever tagged appointment the relation happened to yield first, and Prisma relations come back unordered. With two different orgs present the answer changed between reads — and since the DM channel id is a function of the org, one relationship would mint `dmo-<digest(A)>` on one path and `dmo-<digest(B)>` on another. Two channels, split history. That is the same shape as the `[0]`-versus-`find` bug this helper was written to fix; `find` closed the null-versus-org half and left this one. Fixed in `bookingOrgId` rather than by adding `orderBy` to each query, because there are several callers and the ones that forgot would keep the bug. Code-unit ordering, never `localeCompare`, for the same reason as the channel ids. A booking is funded once, so in a well-formed dataset there is one distinct org and any choice is the same choice. This only decides what happens when that invariant is violated — and a stable wrong answer is recoverable where an unstable one is not. ## Docs `Last Updated` still read 2025-01-22 on a file amended tonight, and the footer had ended up above the correction rather than below it. Both fixed. The `2026-09-01` date on the correction itself is kept: it is 05:20 on 2026-09-01 in Asia/Kolkata, which is the authoring timezone. It only looks future-dated read as UTC. Each fix proved by reverting it and confirming the test goes red. 331 suites / 3,631 tests green, tsc and eslint clean. Part of #1280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…econd dead role check (#1305) * feat(stream): bound the call, unbrick the appointments page, kill a second dead role check Four independent items, none depending on another. ## A server-side duration cap, as a billing backstop `limits.max_duration_seconds` reads `null` on the live call type and is not metered. #1144 listed it as the highest-value unbuilt item on the grounds that the SFU would end calls "at the slot boundary". #1160 corrected that, and the correction is the entire design: **the timer counts from the moment the first participant joins, not from `starts_at`.** Set to the booked length, a consultant arriving fifteen minutes early to check their camera would have Stream hard-terminate the session before its booked end, ejecting both parties mid-sentence. `lib/meeting.ts` declined to send the field for exactly that reason, and on that point it was right rather than over-cautious. So it is set generously — booked run + `CONSULTANT_JOIN_WINDOW_MS` + a 30-minute grace matching the server's own `REJOIN_GRACE_MS`, so the cap cannot expire while the join gate still honours a reconnection. Floored at two hours (bad input must fail long, never short) and ceilinged at twelve (a corrupt booking must not disable the bound entirely). It is NOT slot enforcement and must not be mistaken for it. What it buys is that Stream stamps `ended_at` whether or not our webhook pipeline works — #1134 found 1,417 sessions with no `endedAt` against a pipeline that had never processed one event, and this is the only control in the stack that degrades through that because it does not run on our infrastructure — and that a forgotten tab stops billing participant minutes forever. Extracted to `lib/meetings/duration-cap.ts` rather than left inside the `"use server"` action: it is a pure policy calculation, and putting it in its own module means it can be tested without dragging Prisma and the Stream client into the test process. ### The existing pin had to be sharpened, not weakened `session-room-identity.test.ts` asserted `settings_override` absent entirely. Its stated invariant is that we never let Stream REFUSE a join — backstage and `join_ahead_time_seconds`, deferred in #1070 — and a duration cap ends a session already running rather than gating entry, so it does not touch that. But the blanket assertion was the blunt form of the pin, so the fix whitelists the exact nested shape instead. That is strictly stronger: `backstage` smuggled one level down inside `settings_override` would have satisfied the old check by the object simply not existing, and now fails on the key set. Verified both ways. ## A withdrawn consent no longer takes the appointments page down (#1269) `resolveSessionCallProfile` called `upsertUsersToStream` unguarded, and that throws `ConsentRequiredError` when STREAM_DATA_PROCESSING is absent. Failing closed is correct for Stream; doing it from a render path is not — the throw propagated out of a React Server Component and the consultee lost access to their own bookings. Withdrawal is a right DPDP explicitly grants, and the first person to exercise it should not be locked out of the list of things they paid for. Now returns `null`, an outcome this function already reaches three other ways and every caller handles. The refusal still bites where it should: the JOIN path upserts separately and is left to throw, because "you cannot join a video call without consenting to the video processor" is the right answer to a join and the wrong answer to a page load. ## The second dead role check, which the first fix missed `ChannelInfoAndManageDialog` had `isEventOwner` fixed with a comment explaining that `client.user.role` is the STREAM role and `mapRoleToStream` collapses every non-staff account to `"user"`. Two hundred lines below, `canTruncateChannel` still did the same comparison. `isPrivileged` was therefore permanently false and only a channel's creator could ever clear a group channel — staff and admins could not, despite the branch claiming they could. #1144 asked for a grep of this pattern; this is what it found. Now reads the app role from the session, the same source and pattern `ChatSidebar` already uses. ## Sonar `buildSlug`'s `.replace(/^-+|-+$/g, "")` is `typescript:S8786`, and the only Stream-owned contributor to the failing `new_reliability_rating` on `dev`. Rewritten as split-and-join, which needs no trim at all. Honestly: V8 optimises the old pattern and a 40,000-dash input still returns in under a millisecond, so this is a gate fix and a simplification rather than a demonstrated denial of service. Output verified byte-identical across leading/trailing punctuation, unicode, empty input, all-separator input, and titles past the slice boundary. ## Not included `FAMILIARISE_WEB-10` ("Unauthorized: sign in to request a Stream token", 6 events across 3 production releases over 21 days) is left alone. It is a session expiring mid-visit, and suppressing it means changing how a server action's throw reaches Sentry — too speculative to bundle at the end of a train. Filed rather than guessed at. Closes #1269 Part of #1280 Part of #1144 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stream): keep the publish-route diff to the slug, not prettier churn Running `prettier --write` over the whole file reformatted six unrelated blocks inside the POST and DELETE handlers — line wrapping only, no behaviour change, but it moved every line in `POST`. SonarCloud attributes new code by blame, so a cognitive-complexity finding that has been sitting on `dev` since long before this branch was re-attributed to this PR. Reverted to `origin/dev` and re-applied only `buildSlug`. The complexity finding goes back to being `dev`'s, where it belongs and where someone can decide about it on its own merits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stream): no cap beats a guessed one, and a consultant is not a moderator CodeRabbit round on #1305. Three findings, all legit, two of them Major and both defects in code from the first pass. ## A guessed duration cap could terminate a four-hour webinar two hours in The worst of the three, and it reintroduced the exact failure the feature exists to prevent. `resolveSessionCallProfile` returns `null` when it cannot resolve the run — a DB blip, or the consent refusal this same PR added. The cap then fell back to `DEFAULT_MEETING_DURATION_MS` (60 min) and floored the result at two hours, and that value went to Stream. Since the timer counts from FIRST JOIN, a four-hour webinar with an unresolved profile would have been ended by the SFU two hours before its booked end, mid-session, for everyone. The floor was written to stop a *mis-derived* run producing too short a cap. It cannot do that job for a run never derived at all: 60 minutes is not a conservative estimate of an unknown booking, it is a guess that is wrong in the dangerous direction for every booking longer than it. So an unresolved profile now returns `null` and the caller omits `max_duration_seconds` entirely — which is exactly the behaviour before this backstop existed, because the call type carries no limit of its own. Losing a billing backstop on a session we could not describe beats ending a real one early. ## The truncate button would have shown to consultants who cannot truncate `handleClearChat` calls `channel.truncate()` from the CLIENT, so Stream's permission system decides, not ours: ADMIN / STAFF → mapRoleToStream gives Stream `admin` → allowed channel creator → channel-scoped `channel_moderator` at create → allowed other consultant → plain Stream `user`, no moderator grant → REFUSED Putting the app role `CONSULTANT` in the privileged set showed the button to every consultant and handed the non-owners a Stream authorization failure. That trades a dead button for one that visibly errors, which is worse than the bug it replaced. A consultant now qualifies only by owning the channel. Relative to `dev` this is still a strict improvement: `isPrivileged` was permanently false there, so ADMIN and STAFF — who genuinely can truncate — were also locked out. ## Round the cap up, not down `Math.floor` loses up to a second whenever either `Date` carries milliseconds. Immaterial in size, wrong in direction for a number whose whole design is that it errs long. Each fix proved by reverting it and confirming red, including the caller-side omission: making the caller send a fallback `7200` reds the profile-failure case in `session-room-identity`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(chat): ownership required both ids, and `undefined === undefined` is true CodeRabbit round 2 on #1305. One Minor finding, and it was reachable. `creatorId === client?.userID` reads as an identity test and is not one when both sides are absent. Both are reachable independently: `created_by` is missing on a channel whose creator metadata did not come back from the query, and `client.userID` is missing for the moment before the client connects. Together they granted ownership to whoever happened to be looking. The report named the Clear Chat branch. The same comparison is four lines from the top of the same file gating `isEventOwner`, which controls the remove-member button — the one that mutates. Both are fixed, and the predicate moved to `channelUtils.viewerOwnsChannel` because writing it twice is how it came to be wrong twice. Empty strings are excluded too, which the report did not ask for: `"" === ""` is the same trap one layer down, and Stream returns an empty string rather than omitting a field often enough to matter. Five tests, proved by reverting the predicate to the bare comparison — two go red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ied about why (#1306) Two blockers on the same operation, both found by actually running `ensure-call-type-grants.ts --apply` rather than reading it. ## The pre-flight could never complete `--apply` refuses unless it can prove every member of every open call already holds `call_member` — the write makes that role the only thing admitting anyone, so an uncovered member is a lockout. The proof needs a full traversal, and the traversal always threw: QueryCalls failed with error: "cannot specify sort and next/prev at the same time" `anyOpenCallMemberHolds` fails closed, correctly, so the refusal looked like a transient Stream problem. It was not. The whole call-type hardening rollout — #1297 and #1301 — has been unrunnable. Measured against the live app rather than reasoned about: limit=25 -> 25 calls, next present limit=50 -> 50 calls, next present limit=100 -> 84 calls, NO next limit=250 -> "limit must be 100 or less" any request carrying `next` -> the sort/next error, unconditionally That last point is the crux. Dropping `limit`, dropping the filter, and passing `sort: []` or `sort: undefined` all fail identically — the server sees a default sort on every request the SDK builds, so the cursor is unusable from here. The old page size of 25 therefore guaranteed a second page, and the second page always threw. At 100 the current 84 fit in one page with no cursor. Past 100 there is no supported way to continue, so the traversal raises `OpenCallScanTruncatedError` rather than returning a prefix — a caller must not conclude "every member holds the role" from an arbitrary hundred of them. The pre-flight now reaches a verdict against the live app: Scanned 84 open call(s). 7 member(s) across 4 call(s) do NOT hold `call_member`. Backfill the role first, then re-run. Which is the guard working as designed, and actionable. ## The flag was named after half of what it asserts `--join-route-is-deployed` gates a write that depends on TWO routes. Verified on prod today: the join route IS deployed, the END route is NOT, and prod's `EndCallButton` still calls `call.endCall()` client-side. So an operator asserting something entirely true about the join route would have stripped `end-call` and taken End Call away from every host. The refusal message names both routes, but the message is what you read AFTER being refused; the flag is what you type from memory. Renamed `--routes-are-deployed`, with the old spelling kept as a working alias so any runbook, shell history or pasted command still functions — being refused for typing the previously-documented flag would be its own small outage. Both guards proved by reverting them and confirming the tests go red. 335 suites / 3,676 tests green, tsc and eslint clean. Part of #1280 Part of #1134 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The config has never been applied. CodeRabbit reports the parsing error only in a collapsed <details> block on each PR, so it went unnoticed: every review since #1223 (2026-08-23, 168 commits) silently ran on DEFAULT settings — no assertive profile, no payments path instructions, no security analyzers. Two independent defects, both from #1223: 1. `tone_instructions` was 259 characters against a schema cap of 250. One over-long field fails the WHOLE file, not just that key. Rewritten to 238, with the cap noted in a comment so the next edit counts first. 2. `tools` sat at the root. The schema allows it only under `reviews`, and rejects unknown top-level keys — so even with a valid tone string the file would still have failed. All 14 analyzer names check out against the schema; they were just nested one level too high. Validated against CodeRabbit's published schema.v2.json: `.coderabbit.yaml valid`. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#1307) * fix(db): share one sidecar SQL splitter, clearing both S5852 criticals The three `db:sidecars` scripts each carried a byte-identical statement splitter, including `/^(--.*\n?)*$/`. SonarCloud flags that regex as `typescript:S5852` on two of them, and they are the only CRITICAL security findings on `dev` — the reason `new_security_rating` sits at 4. The flag is a heuristic and here it is a false alarm: `.` never crosses a newline, so each iteration of the group is line-bounded and there is no ambiguous partition for the engine to explore. Measured over six adversarial shapes (20k near-miss lines, a 300k-char single line, alternating blanks) the old regex never exceeded 1ms. There was no live denial of service. Replaced anyway, because clearing the gate is worth more than the regex and these files recreate the money-path invariants on a fresh database. Extracted to `sql-chunks.ts` so the next fix lands once instead of three times. Equivalence is pinned rather than asserted: the test keeps the old regex as an oracle and checks agreement on every chunk of all three real SQL files, plus the adversarial inputs. Both mutants (`every`->`some`, dropped `trimEnd`) turn it red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(db): make the exported predicate exactly match the regex it replaced #1307 review — `trimEnd()` disagreed with the oracle in two places: `""` returned false where the old regex's `*` permitted zero iterations and returned true, and a trailing whitespace-only line was trimmed away rather than rejected. Neither is reachable through `splitSqlStatements`, which trims and drops empty chunks first. But `isOnlyComments` is exported, and the docblock claimed exact equivalence — so the claim was wrong even though the pipeline was not. Pop the single empty element a trailing newline produces, which is precisely what the old `\n?` allowed, and reject everything else. Seven degenerate inputs added to the oracle comparison; three of them fail against the previous implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…1308) #1302 review. `source` was doing two jobs: reporting how the unfreeze set was determined, and gating ledger retirement. On the union path it reads "derived" because the set is only best-effort complete — but the set still CONTAINS the ledger entries, so `result.source === "ledger"` skipped `retireFrozenChannels` and `FROZEN_CHANNELS` never shrank. Left alone that is not just untidy. The stale set is re-unfrozen on every later OFF transition, spending the 300/min UpdateChannelPartial budget on it, and — since #1303 landed — reopening DM channels the dormancy sweep froze on purpose. Maintenance would silently undo another subsystem's decision. Split the two: `usedLedger` tracks participation, `source` keeps reporting provenance. `srem` on an id the set never held is a no-op, so passing the derived ids through with the ledger ids costs nothing. Also corrects two `withStreamCircuitBreaker` fixtures that simulated Redis's breaker message. They passed only because the guard matches the substring "circuit breaker is OPEN", so they were asserting against a string this path cannot emit. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
✅ Deploy Preview for familiarise ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Essentials Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Warning
|
|




Release of
devintoprodfor 2026-09-01. 60 commits (55 non-merge + 5 merges), 332 files changed, +28,901 / −4,411.Merge base:
e6d9e82a(prod tip, PR #1264 release of 2026-08-27).Warning
Do not merge until the production schema is pushed. This range renames two Postgres enum values and two
Recordingcolumns. Merging first will take reads ofRecording.storageTypeand the four*Plan.recordingStoragePolicycolumns down with P2023. See "Schema changes in this range" below.What is shipping
Stream / video (19 PRs — the #1270 join outage + the end-to-end hardening train)
queryChannelsat Stream's real 30-row capSupport, feedback and reviews (the #705 / support-hub sweep, ~25 commits)
FAM-ticket reference numbers, IT-Rules-2021 SLA clocks (ack ≤ 24h, disposal ≤ 15d) with a pause clock while the ball is in the user's courtSupportFlowOutcomerecords every terminal turn in both scopes so bot efficacy is answerableOnboarding (5 commits)
__vgeneration marker; free-text fields capped; the byte budget is now backed by apg_column_sizeCHECK; the user is shown when a draft was dropped or is too big to saveModeration
USER_REINSTATEDand per-message report identityAuth / performance
Infra, CI and tooling
.coderabbit.yamlso it actually loads (168 commits had been reviewed on defaults)BETTER_AUTH_TRUSTED_ORIGINSand the new schemaSchema changes in this range
Caution
A production
db push(plus two one-off SQL files, in order) is required BEFORE this merges.scripts/ci/check-db-drift.tswill fail the branch until the renames are applied.Destructive / ordering-sensitive — must run
prisma/sql/one-off/2026-08-30-rename-recording-storage-vendor.sqlFIRSTPostgres has no
ALTER TYPE ... DROP VALUE, anddb pushsees a column rename as drop + add, so these cannot be left to the push:RecordingStorageTypeenum valueSUPABASEPLATFORMRecordingStoragePolicyenum valueSUPABASE_PERMANENTPERMANENTRecordingcolumnsupabaseUrlstorageUrlRecordingcolumnsupabasePathstoragePathIf the push runs first, the live enums keep both labels and the Prisma client refuses every read of the five columns typed by them (P2023).
Also recorded as a one-off —
prisma/sql/one-off/2026-09-01-add-dm-chat-freeze-ledger-and-chat-retention.sqlPurely additive, safe in either order, already applied to the shared dev DB:
Consultation.chatFrozenAt TIMESTAMPTZ NULLSubscription.chatFrozenAt TIMESTAMPTZ NULLorganizations.chatRetentionDays INTEGER NOT NULL DEFAULT 365(note the@@mapped table name)New models
SupportTicketCounter(support_ticket_counters) —year Int @id,nextSeq Int @default(1),updatedAt. Per-year allocator behind the ticket reference series.SupportFlowOutcome—id,scope VarChar(32),flowKey VarChar(64),terminalNodeId VarChar(64)?,reason VarChar(64)?,outcome SupportFlowOutcomeKind,userId(Cascade),organizationId?(SetNull),createdAt Timestamptz; indexes[createdAt, outcome],[flowKey, outcome],[userId, createdAt],[organizationId, createdAt].New enums
SupportFlowOutcomeKind—RESOLVED,ESCALATEDAppointmentFeedbackRole—CONSULTEE,PROVIDERNew enum values on existing enums
SupportThreadCategory+=DOCUMENTSModerationActionType+=USER_REINSTATEDNew columns
SupportTicketreferenceNumber String? @unique @db.VarChar(20)lastMessageAt DateTime?ackDueAt,acknowledgedAt,resolutionDueAt,resolvedAt,closedAt,firstAgentReplyAt,awaitingUserSince— allDateTime? @db.TimestamptzpausedSeconds Int @default(0)assignedToIdgains a real FK toUser(onDelete: SetNull); it was a bareStringAppointmentSupportThreadlastMessageAt DateTime?messageSeq Int @default(0)SupportMessageseq Int @default(0)authorUserId String?+authorUser User?(onDelete: SetNull)AppointmentFeedbackraterRole AppointmentFeedbackRole?OrganizationchatRetentionDays Int @default(365)(also in the one-off above)ConsultantProfilepublishedRating Float?ratingUnitCount Int @default(0)reviewCount Int @default(0)ratingAggregatedAt DateTime? @db.TimestamptzConsultantReviewappointmentId String?+appointment Appointment?(onDelete: SetNull)ratingUnitId String?replyBody String? @db.Text,repliedAt DateTime? @db.Timestamptz,replyDeletedAt DateTime? @db.TimestamptzConsultation,SubscriptionchatFrozenAt DateTime? @db.Timestamptz(also in the one-off above)ModerationReportstreamMessageId String?streamChannelCid String?Constraint and index changes
ConsultantReviewunique replaced:@@unique([consultantProfileId, consulteeProfileId])→@@unique([appointmentId, consulteeProfileId]). Legacy rows carry a NULLappointmentId, so the old per-pair rule is preserved for exactly that band by a new partial unique index in the sidecar.SupportTicketindex replaced: single-column@@index([organizationId])dropped in favour of@@index([organizationId, status])(Postgres reads the composite's leftmost prefix). Added@@index([acknowledgedAt, ackDueAt])and@@index([resolvedAt, resolutionDueAt])for breach sweeps.AppointmentFeedbackindex replaced:@@index([organizationId, createdAt])→@@index([organizationId, raterRole, createdAt]).AppointmentSupportThread [status, lastMessageAt];SupportMessage [threadId, seq]and[authorUserId];ConsultantProfile [publishedRating];ConsultantReview [consultantProfileId, deletedAt, ratingUnitId]and[appointmentId];ModerationReport [streamMessageId].prisma/sql/check-constraints.sqlgains two entries that must be re-applied after the push (sidecars are never created bydb push):consultant_review_legacy_pair_key— partial unique on(consultantProfileId, consulteeProfileId) WHERE appointmentId IS NULLonboarding_draft_payload_size—CHECK (pg_column_size(payload) <= 65536)ononboarding_draftsMerge method
Every one of the last five dev→prod releases (#1264, #1259, #1248, #1239, #1228) landed as a merge commit — each merge SHA has two parents. This one should too: squashing would permanently diverge prod's history from dev's.
Pre-merge checklist
prisma/sql/one-off/2026-08-30-rename-recording-storage-vendor.sqlto productionprisma/sql/one-off/2026-09-01-add-dm-chat-freeze-ledger-and-chat-retention.sqlto productiondb pushprisma/sql/check-constraints.sqlscripts/ci/check-db-drift.tsis green