Skip to content

release: dev → prod — 2026-08-01 (stream, offerings editor, scheduling, payments) - #1090

Merged
teetangh merged 23 commits into
prodfrom
release/dev-to-prod-2026-08-01
Aug 1, 2026
Merged

release: dev → prod — 2026-08-01 (stream, offerings editor, scheduling, payments)#1090
teetangh merged 23 commits into
prodfrom
release/dev-to-prod-2026-08-01

Conversation

@teetangh

@teetangh teetangh commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Releases eleven first-parent commits already merged to dev and verified there.

What ships

Notes

  • chore(release): merge prod into the release branch is present only to satisfy up-to-date branch protection (prod merge-commit nodes never travel back to dev).
  • Shared Supabase: confirm no pending schema drift before deploy if you rely on prisma migrate diff.

Made with Cursor

teetangh and others added 23 commits August 1, 2026 02:46
…at cannot half-write (#1064)

* fix(reschedule): auto-confirm writes nothing until the allocation commits

A consultee's proposal could leave a booking permanently un-reschedulable.

The old sequence stamped the proposed times onto the released slot rows, ran
the allocator in "requested" mode so it would read them back, and restored the
originals from an in-memory snapshot if validation rejected them. Two ways
that broke.

The finalize step runs in its own transaction. Failing it left the booking
confirmed at the new times while the proposal stayed PENDING_REVIEW with
openForAppointmentId still set — and that nullable-unique then blocked every
future reschedule of the appointment. Nothing surfaced it; the booking just
quietly stopped being movable.

Worse, the originals only ever existed in RAM. A crash between the stamp and
the restore left the rows holding proposed times with no allocation to justify
them and no way back.

Manual mode already accepts explicit times, and on a reschedule
deleteExistingAppointments removes only TENTATIVE slots — exactly the released
ones — so confirmed sessions elsewhere in the booking survive. Handing the
allocator the times directly means nothing is written until it commits, so
neither failure has anywhere to happen.

That deletes the stamp transaction, the restore transaction, the snapshot and
the one-to-one pairing: 97 lines out, 33 in. Pairing only ever made sense
while each proposed time was written onto a specific row; the allocator takes
them as a set, so two non-contiguous proposed cells now fail validation
instead of passing as one moved session.

Manual mode shards its Redis lock by day (#860) so same-consultant
allocations on different days run in parallel, with #440's GiST constraint
backstopping overlap. But GiST sees overlaps, not counts — two sharded
confirmations could each pass a per-week cap on the same stale read and take a
4-session week to 5. These times were not picked per-day by a human, so
auto-confirm asks for the consultant-wide lock via a new `wideLock` flag. The
consultant's own UI keeps its sharding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(reschedule): the initiator can withdraw, and withdrawing restores

There was no way to take back a reschedule you had asked for. The other party
could Decline; the person who opened it could only wait for expiry.

Withdrawal is the initiator's alone, whichever side they are on. The recipient
already has Decline, which ends the same request with a different meaning —
giving them this too would be a second Decline wearing a friendlier word.

The two outcomes differ in what happens to the released slots, which is why
WITHDRAWN is its own status rather than a reuse of DECLINED:

  withdraw  the person who asked no longer wants it, so nothing should have
            moved — the booking returns to its original times
  decline   the consultee still wants to move and the consultant has not
            agreed a time, so the slots stay released for their queue
  expiry    same as decline

Collapsing them would leave the audit trail unable to say who ended it, and
force every consumer to infer intent from resolvedById.

Restoring is cheap for one reason worth stating: a reschedule never rewrites
startsAt. The released rows still carry their original times, so this flips
two flags rather than replaying data from a snapshot.

WITHDRAWN is terminal, which is what releases openForAppointmentId — miss that
and a withdrawn request holds the nullable-unique forever, blocking every
later reschedule of the booking. There is a test for exactly that.

The CAS on the transition is the concurrency guard: if the other party
answered while this was in flight it matches zero rows and throws, rather than
un-releasing slots a concurrent accept has already re-confirmed.

Also adds RescheduleRequest.resolutionNote — the answering party's reply.
`reason` runs consultee to consultant; this is the return leg, and it is the
difference between "your sessions moved" and "moved to Thursdays, I have
blocked Tuesdays from September".

Both schema changes are additive and already applied; migrate diff reports no
drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(reschedule): drop the counter-round, which was never built

MAX_PROPOSAL_ROUNDS and mayCounter existed, and the transition map has a
PENDING_REVIEW <- COUNTERED edge. But nothing anywhere ever wrote COUNTERED:
no route, no component, no job. The round-2 path was specified and never
implemented.

So this removes dead specification rather than a feature. Propose -> accept or
decline is the whole flow, and a decline already falls back to the consultant
allocating, so nothing dead-ends without it.

The enum value stays. Removing it needs a migration for no benefit, and
leaving it documents a path that was considered and rejected rather than
forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scheduling): one SlotPicker on three pages; both dialogs deleted

Every defect found reviewing the reschedule and allocate dialogs traced back
to width: labels overflowing their column, a legend that would not fit, a
selection lost on reload. They were pages wearing modal costumes, the same
shape the four planner dialogs had.

Three routes now run over one component:
  consultee   .../appointments/[appointmentId]/reschedule
  consultant  .../appointments/[appointmentId]/reschedule   (new)
  consultant  .../requests/[requestId]/allocate

The consultant reschedule route is a prerequisite, not an extra. That surface
borrowed the CONSULTEE's modal, and the consultee route 403s a consultant — so
deleting the dialogs without it would have left consultants unable to
reschedule at all.

Differences are data, not flags. A policy carries the rules (lead time,
released slots shown, submit label, whether releasing without a time is
allowed); a separate subject carries what is being placed (ids, durations,
window, counterpart). That split removed two booleans outright: MANAGE_TIMINGS
runs no consultee-conflict check because it has no counterpart, not because a
flag says so, and "is this a fresh allocation" is subject data. SlotPicker
never branches on which surface it is.

EventTimingsCalendar is the fourth caller and now runs the same component
under its own policy rather than being a fifth calendar to keep in sync.

Consultants gained a times picker they never had — previously a confirm-only
dialog. Safe by construction: only a CONSULTEE proposal auto-confirms, so a
consultant's is always an offer the other side must accept.

The sessions-then-times step machine is gone. A page can show the release
picker above the grid, which was the point of moving off modals.

Mobile has two gates answering different questions. CSS decides what is
VISIBLE, so there is no hydration flash and the page is not client-only.
matchMedia decides what is MOUNTED, so below lg the calendar subtree never
mounts and the availability fetch never fires — the CSS-only version hid the
grid while still fetching for it.

The route segment is [requestId], not [appointmentId]: it receives the
consultation/subscription id, and the Appointment row is downstream of it —
it does not exist yet for a request that was never scheduled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scheduling): stop the week-navigation flicker and the org-admin 403

Two pre-existing bugs on dev.

FLICKER. Two causes, of the three suspected. weeklySlotCount was a dependency
of the very effect that fetches the data it derives from, so every navigation
double-fetched: navigate, fetch, state updates, dependency changes, fetch
again. And nothing tracked which window a response belonged to, so a slow
week-N reply could land after the user had moved on and repaint the week they
had left. Requests are now stamped and stale replies dropped.

The third suspected cause did not hold and is worth recording: there was no
clear-to-empty on navigation. The hook keeps the previous week rendered until
new data arrives, and the full-grid spinner only fires on the initial load. No
keep-previous-data shim was needed.

ORG-ADMIN 403. The requests tab mounts the calendar in allocate mode, which
asks for appointment details; the route authorised that on consultant
ownership or isPrivileged — which is platform ADMIN/STAFF, not org admins. An
org admin allocating for a member consultant lost the ENTIRE calendar, because
the route 403s rather than downgrading.

They are now neither authorised nor refused. The detail payload carries plan
titles and participant names, including for the consultant's personal bookings
with unrelated consultees — ADR 20 gives an org metadata, not content. So an
org OWNER or MAINTAINER (checked against an ACTIVE membership in an org where
the consultant is an ACTIVE EXPERT) gets the busy/free grid a buyer gets, and
allocation works. The 403 was costing them the whole calendar over a tooltip
they must not see anyway.

Ordinary org members are deliberately excluded: only the two governance roles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scheduling): org admins were 403'd on the consultee gate

"Forbidden: cannot access other user's calendar" on Allocate Slots.

The org-admin arm was added to the appointment-details gate and not to the
consulteeUserId gate immediately below it, so an org admin cleared the first
check and was refused by the second — which made the allocate surface unusable
for them, the exact case that arm was added to fix.

The parameter belongs to them. It only marks cells BUSY, carrying no titles or
names, so it is metadata rather than content and ADR 20 allows it. Allocation
is wrong without it: the grid paints cells green that validation then rejects.

The check is now resolved once and shared, rather than duplicated and drifting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(styles): Tailwind never scanned lib/ or utils/

This is why slot cells were ABSENT rather than faint, and why the first palette
migration had to be reverted.

Tailwind only emits a utility it has SEEN in a scanned file. `content` listed
components/ and app/ but not lib/ or utils/ — so every class string defined in
lib/scheduling/slot-status-tokens.ts produced NO CSS AT ALL unless the same
class happened to appear under a scanned path too. Cells painted from those
tokens had no fill and no border: not a faint cell, an invisible one. Past
cells kept rendering because their classes were hardcoded inside the component,
which was scanned.

Two rounds of reasoning about opacity and border-colour precedence never found
this, because the CSS was not losing a specificity contest — it did not exist.

The calendar is not the only casualty. Seven files under lib/ and utils/ carry
class strings: appointment status badges, org and session labels, document
icons, auth provider buttons, support ticket UI. All were silently dropping
whichever classes no scanned file happened to duplicate.

Adding these paths means those previously-dead classes now emit, so expect
those surfaces to change appearance — toward what they were always written to
look like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scheduling): one palette, booking identity on each page, expandable notes

THE PALETTE. Cells and legend now render from SLOT_STATUS_TOKENS, so the
legend finally describes the grid. The token shape changed to separate fill /
border / text / hover fields rather than one className string, which makes the
old failure structurally impossible: the base cell class carries `border`
(width only) and exactly one token supplies the colour, so two border-colour
utilities can never land on the same element again.

`unavailable` goes back to slate-200. The reverted attempt used slate-100,
which on a white card is close enough to the background that a sparse week
read as an empty grid. `fullyBooked` moves to slate-300 so the two stay
distinguishable now that unavailable is visible again — "nobody offered this"
and "someone has this" are different answers.

A test asserts the cell and the legend swatch resolve to the SAME utilities
for every state, and scans the calendar source for the retired hardcoded
classes. The existing test only asserted the legend COVERED every state, which
is precisely how the two drifted apart unnoticed.

PAGE IDENTITY. The three pages said only "Allocate slots" or "Reschedule",
with no indication of which booking. They now show the offering title with the
counterpart's name, and carry a generateMetadata tab title. The names come
from data the pages already fetch — no extra query.

REQUEST NOTES. The Note column clamps to three lines, which is right, but had
no way to read the rest. A Read more toggle now appears ONLY when the text is
actually clipped, measured against scrollHeight and re-measured on resize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scheduling): Manage Timings becomes the fourth page; no dialogs left

The last cramped calendar. Its body already ran SlotPicker, but it kept a
dialog wrapper, so the consultant scheduling their own webinar or class was
still doing it at modal width while every other slot surface had a page.

The route resolves two shapes of id, the same convention the appointments list
already used client-side: a real Appointment id when the offering is
scheduled, or the synthetic unscheduled-class-<id> / unscheduled-webinar-<id>
when there is no Appointment row yet — which is exactly the case this page
exists to fix.

Deleting the dialog orphaned two files. utils/unscheduledAppointments.ts built
its input; appointments/utils/appointmentTimingHelpers.ts exported two
predicates nothing else called. Both are gone. Dead state after a deletion
never lives in the file you deleted, it lives in what fed it.

getEventDetails survives as lib/scheduling/manage-timings-subject.ts, now over
a structural type rather than a client-only one, so a server page can call it.

RequestedSlotsDialog was assessed and deliberately left alone. It mounts no
calendar — it is a read-only validate-and-confirm summary of times the
consultee already named, and its height cap exists because that list can be
long, not because it wants a calendar's width. Genuinely dialog-shaped.

ALSO IN THIS COMMIT — two fixes the user hit while testing, folded in rather
than given a message of their own:

An empty array is truthy, so a consultant with no org memberships still got the
full switcher chrome in the sidebar — a chevron opening a menu containing
nothing. The chip now becomes a dropdown only when there is at least one real
destination; labels and separators are not somewhere to switch TO. Fixed in
CollapsibleSidebar so consultee and anything added later inherit it. An
affordance that opens an empty menu is worse than no affordance, which is why
it hides rather than saying "no organizations".

And the requests list no longer refetches on window focus. This went interval
-> focus -> neither, and the last step mattered most: focus fires on every
alt-tab, far MORE often than the timer it replaced for anyone actually
working. It also reached the calendar, because that tab used to host it — a
repaint mid-selection caused by data nobody asked for.

Leaving it stale is safe, and that is the argument: the grid is a hint.
Allocation re-validates server-side under a Redis lock against
SlotValidationService and the btree_gist exclusion constraint, so a stale view
cannot double-book — at worst a submit is rejected with a clear message. The
Refresh button and the "Updated" label carry it instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scheduling): legend above the grid, title into the breadcrumb

The colour key sat below the calendar, so it was below the fold on a
laptop — a consultant saw a grid of colours with no way to learn what
they meant until after scrolling past them.

The header card cost the rest of that space. Each page rendered a
breadcrumb ending in a generic "allocate"/"reschedule"/"timings", then a
card repeating the offering title, then a back-link duplicating the
breadcrumb's own parent — ~200px restating what was already on screen,
and two destinations for one place (ADR 19).

The last crumb now carries the offering name via the existing
BreadcrumbOverrideProvider, which both dashboard layouts already mount
for precisely this ("a label replacing a stripped record-id crumb"). The
counterpart survives as a PanelHeader description, since it is the only
line saying who the booking is for.

Part of #1064

* fix(appointments): one Reschedule gate, not two that drifted

The consultee's menu checked that the booking had slots at all and that
no reschedule was already in flight. The consultant's checked neither,
so it offered Reschedule on an APPROVED booking with nothing allocated
(no time to move, and no earliest released session to derive the
proposal window from) and on one already awaiting a new time — where
openForAppointmentId's nullable-unique guarantees a 409.

Both now call slotsAllowReschedule. It lives in lib/appointments/slots,
which exists for exactly this: predicates that were "duplicated with
different windows and strictness" across the two sides.

The status, role and route checks stay per-adapter — those differ for
real reasons. Only the slot-derived half was ever meant to be the same.

Part of #1064

* fix(reschedule): close four correctness holes found in review

**A 60-minute proposal booked 30 minutes.** The proposal schema only asked
that endsAt follow startsAt, but auto-confirm hands the allocator startsAt
alone and manual mode reads each entry as one 30-minute start — endsAt is
never consulted. The one-for-one count check still passed, which is what
made it invisible: ask to move a 1-hour session, silently get half of one.
Proposed rows are now exactly one atom (ADR B1), rejected at the edge.

**The withdraw route was a membership oracle.** It never checks that the
caller can see the appointment, so 404-vs-403 let any signed-in user walk
appointment ids and learn which bookings had a live reschedule and whose.
"No open request" and "someone else's" now answer identically.

**A lost CAS was reported as a fault.** Another party answering mid-flight
throws IllegalTransitionError, which reached apiError as a 500 and paged
Sentry without expected:true. It is a modelled outcome and now returns
PROPOSAL_NOT_OPEN, matching how auto-confirm treats the same race.

**The allocate page ignored the status it loaded.** The URL is linkable
from a notification and goBack() pushes, so the back button reopens it —
on a request already confirmed, rejected or cancelled. Guarded with
ALLOCATION_APPROVABLE_FROM rather than `=== PENDING`: a partial reschedule
allocates from this same page, and a subscription is deliberately not
flipped back to PENDING when a session is released (#448), so it arrives
still APPROVED.

Also: a partial slot restore no longer reports plain success, and the
consultation flip goes through transitionConsultationRequest so the edge
is owned centrally. 13 tests, including the behavioural coverage the
withdraw module had none of.

Part of #1064

* fix(scheduling): picker defects from review

**"One session" could select a session you cannot move.** Narrowing from
"several" fell back to sessions[0], and the list is earliest-first — so it
picked the session most likely to be inside the lead window. SessionList
disables that row, leaving it ticked and untickable, while the submit
stayed enabled (it counts ids, not eligibility) and the server rejected
a release the user had no way to change. It now falls back to the first
session outside the window.

**A shrink below `lg` silently desynced the selection.** DesktopOnlyNotice
unmounted the subtree on resize, destroying the calendar's own selection
while SlotPicker kept its copy — the footer then claimed "N slots
selected" over a grid showing none, and submitting sent times the user
could no longer see. The mount gate latches; the CSS gate still hides the
subtree, which is what the notice is actually for.

Also: slotCellClassName merges through cn, since Tailwind resolves
same-specificity conflicts by stylesheet position rather than
concatenation order — the exact mechanism behind this PR's own
border-transparent regression, and the open className extension point had
no protection from it. Session rows key off their first slot id rather
than an array index.

Part of #1064

* fix(scheduling): second review round — metadata leak, retry loop, drifted owner check

**A failing endpoint hammered the API and never settled.** fetchData both
writes `error` and listed it as a dependency, and the effect calls
fetchData — so a failure changed its identity, refired it, cleared the
error, changed it again, and looped. The `finally` also read `error` from
the render-time closure rather than the value just set, so a failed fetch
still stamped "Updated" with a timestamp it had not earned. Success is now
tracked locally and `error` is out of the deps.

**generateMetadata ran before every guard on all four pages.** It loads
and formats the offering title with no ownership check of its own, so the
tab `<title>` named the offering — and on two pages the counterparty — for
any appointment or request id a signed-in user cared to try. Each page's
metadata now runs the same predicate its body does and falls back to the
generic title. The consultee check is extracted so the two cannot diverge.

**Two hand-written copies of an ownership check had already drifted.**
manage-timings-target omitted `trialSession`, which the reschedule route
includes — so a consultant opening the timings of their own trial was
refused by one route and admitted by the other. Both now call
resolvePlanOwnerIds. Collaborators stay webinar/class-only; widening that
here would have granted access the other surfaces do not.

**Program progress counted rows, not sessions.** groupTotalSessions read
`scheduled?.length` over sibling Appointment rows, which are real
appointments rather than session placeholders — so an unscheduled sibling
was simply missing and the total shrank to whatever was already placed,
folding completed sessions into "remaining". It reads the plan's
totalSessions.

Part of #1064

* fix(dashboard): the breadcrumb override never fired on a task route

It was applied AFTER the segment loop, gated on a flag that the trailing
task segment cleared on its way past:

  ["appointments", "<id>", "timings"]
   push Appointments   flag=true, skip   flag=false, push "timings"

So it only ever worked when the record id was the last segment. On all
four slot pages the id is followed by a task, which is why the trail still
read "Appointments › timings" after the label was wired up — the hook was
setting a value nothing consumed.

The label now goes in the id's OWN position, which is where it belongs:
that segment IS the record.

  Appointments › Basic Consultation › Timings
  Requests     › Basic Consultation › Allocate
  Appointments › Basic Consultation › Reschedule

It also keeps the id's href, so the offering name links to the detail page
— a level these routes previously could not reach at all.

PAGE_LABELS gains timings/allocate/reschedule, which is the other half of
what was on screen: with no entry the crumb fell through to the raw
lowercase URL segment.

Also folds in three review items from the appointments filter bar: "All"
leads the status tabs to match the "All types" chip; the Trials TAB is
renamed "Trial requests" so it stops colliding with the Trials type chip
one row below (same word, different result — the tab is the TrialSession
queue, the chip filters the bucket); and the date inputs get room for
Chrome's calendar-picker indicator, which was overflowing onto the border
at w-140px with px-3.

Part of #1064

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#1068)

* fix(observability): initialise AND flush Sentry in cron job processes

The 58 files under jobs/** all call Sentry.captureException and none of
them ever reported anything. Sentry.init lives only in
sentry.shared.config.ts, which reaches the runtime through Next's
instrumentation hook, and jobs run as bare Node processes under GitHub
Actions (`npx tsx jobs/<area>/<job>.ts`) where that hook never executes.
captureException on an uninitialised client returns silently, so the
absence of events looked exactly like the absence of problems.

Adding init alone would still have reported nothing. The SDK batches
over HTTP and a cron process exits the moment its work is done, before
the transport drains. lib/observability/job-sentry.ts therefore owns
both halves: runJob() inits before the body and flushes in a finally,
so the drain happens on success, on an early return, and on a throw.
Every job entrypoint now goes through it instead of 58 hand-written
copies that could each forget the second half.

That only holds if nothing tears the process down first, so the ~105
process.exit() calls inside job bodies become process.exitCode plus a
return. They were already a latent bug: exit() skipped the finally
blocks that disconnect Prisma. abortIfMaintenance keeps its exit(0) but
drains before it. reconcile-ledgers drops the flush it hand-rolled in
#837 and inherits the shared one.

Each scheduled workflow now exports NEXT_PUBLIC_SENTRY_DSN (falling
back to the already-provisioned SENTRY_DSN secret) — without a DSN in
the step environment the runner would initialise a disabled client and
the whole change would be inert.

Closes #1066

* refactor(observability): fold the repeated job catch block into runJob

Converting 58 entrypoints in the previous commit left each one carrying
a near-identical tail — the CronLockHeldError skip, the
subsystem/job tagging, and the GITHUB_OUTPUT "success=false" write —
differing only by a job name that runJob already receives. SonarCloud
measured it at 7.4% duplication on new code, which is a fair reading:
it was 58 copies of one decision.

A held cron lock means another replica is already running the job. That
is a clean skip for every job, never a per-job judgement, so
runJobWithSentry now owns it: log, leave the exit code at 0, capture
nothing, and still flush because the skipped run may already have
logged. CronLockUnavailableError is deliberately not folded in — a
fail-closed job that cannot get a real lock must still page.

The step-failure write moves up too. Every job that had one wrote the
same "success=false" output and an ::error:: annotation that differed
only in prose, so markStepFailed emits both from the job name. The 50
jobs that previously wrote neither now get the annotation, which makes
a failure visible in the Actions UI rather than only in the log.

The per-job catch blocks are then just deleted; the error escapes to
runJob and picks all of that up on the way out. Three jobs keep a catch
because they do something genuinely their own — retry-failed-emails,
handle-stuck-payouts and reconcile-ledgers each record a system event
for their subsystem — and those rethrow afterwards, guarding the
side effect behind a CronLockHeldError check so a skip is not recorded
as a crash. Three more had a try with no finally, so the try is
unwrapped rather than left empty.

Tests cover the newly centralised behaviour: a held lock must not set a
non-zero exit code, must not reach Sentry as an error, must not mark
the step failed, and must still flush.

* fix(observability): stop local job runs reaching production Sentry, harden the runner

Independent review of #1068 found that the runner's own gating did not
survive the environment it runs in. sentry.shared.config gates on
NODE_ENV === "development", and a bare `npx tsx jobs/…` process has no
NODE_ENV at all, so the guard passed. Sixteen jobs load dotenv/config,
the local .env carries a real production DSN, and the runbook documents
running jobs locally as normal debugging — so this PR had quietly turned
every local cron run into a shipment to the shared production project,
which is exactly what #901 prevented for `next dev`.

The Sentry environment is the signal that actually separates the two:
the workflows set it to production, a developer's .env sets it to
development. initJobSentry now enables Sentry only for a deployed
environment, and says so loudly when it does not, because a workflow
that forgot the env var looks identical to a laptop from in there. The
gate can only ever NARROW what the shared config decided, so no DSN
still means disabled. initSentry grew an optional overrides bag to carry
it; all three app entrypoints still call it with no arguments and their
behaviour is byte-identical.

The same missing NODE_ENV made tracesSampleRate resolve to 100% while
events reported environment: production. Jobs are pinned to 0, which is
honest as well as safe: init runs after every import has been evaluated,
so OpenTelemetry has nothing left to patch and jobs emit no spans at
all. That limitation is now written into the module doc rather than left
for someone to discover.

Four smaller holes in the same file:

- initJobSentry ran outside the try, so an init failure became an
  unhandled rejection with no capture, no annotation and no flush. It
  now swallows and logs its own failure, and the job still runs its
  actual work uninstrumented rather than dying for a telemetry fault.
- Sentry.flush RESOLVES FALSE on timeout rather than throwing, so a
  dropped batch was silent — the precise failure this PR exists to
  remove. It is now logged.
- The 5s drain is fine for a handful of events but reconcile-ledgers
  fires one fatal event per drifted wallet, so a wide drift could lose
  the P0 wallet-freeze pages. runJob takes an optional per-job budget
  and that job asks for 30s of its 30-minute workflow allowance.
- markStepFailed ran before process.exitCode was set, so a throw from
  appendFileSync would have left the code unset.

Finally, importing CronLockHeldError from with-cron-lock dragged in
lib/redis, which throws at module load without Upstash env — a hard
dependency for what is only an instanceof check, and one that
lib/maintenance-cron had started carrying too. Both lock error classes
move to a leaf module with no imports; with-cron-lock re-exports them so
all ~44 existing call sites and instanceof identity are untouched.

Tests grew from 20 to 29. The gating is now proven end to end against
the real shared config rather than a mock of it, the lock-error tests
use the real classes pulled from the same module registry so that
making Unavailable extend Held would break them, and the drain-timeout
and per-job-budget paths are covered.
Cancelling a paid trial destroyed its Payment row. Both cancel paths
hard-deleted the trial's appointment to free the slot, and
`Payment.appointment` is `onDelete: Cascade`, so once paid trials shipped
(#1046) the delete took the money record with it: no refund, no ledger
entry, nothing left to reconcile against the gateway. `TrialSession.payment`
is SetNull, so the trial survived pointing at nothing.

The delete was never what freed the slot. `buildOccupiedAppointmentFilter`
counts a trial as occupying only while SCHEDULED or AWAITING_PAYMENT, so the
status transition alone releases it — which is exactly what the hourly
expiry job has always relied on. The interactive paths were the outlier.

Both PATCH and DELETE now soft-cancel through a shared helper: the
appointment and its slots get the `deletedAt` tombstone that #676 sanctioned
for anything money rows hang off, the trial keeps its appointment link for
support and reconciliation, and a paid trial refunds through the same
policy-snapshot engine the appointment cancel route uses. The refund runs
after the status write commits and never throws, so a gateway failure leaves
the cancellation standing and surfaces in Sentry rather than rolling back.

Closes #1009

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1079)

Nothing ever wrote schedulingTimezone, so every per-day and per-week cap
on the platform bucketed against Indian calendar days regardless of where
the consultant was — invisible to anyone in India, where the viewer-local
grid and the Asia/Kolkata bucketing agree exactly.

All four Subscription/Class creation paths now resolve the zone from the
owning consultant's User.timezone, validated as a real IANA zone, falling
back to the existing default when none is recorded. Existing rows are
left alone: rewriting the zone would move a live booking's day
boundaries.

Part of #1076

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

* feat(scheduling): open the slot picker on the session you clicked

The picker opened on the current week scrolled to 00:00, so every visit
began by hunting for the right day and scrolling past ten hours of empty
night rows. Derive a target instant from the subject the page already
carries, move the week to contain it and scroll the time axis to it.

One resolver for all four surfaces: they differ only in which sessions
their subject carries. A session awaiting a time outranks the next
upcoming one, since filling gaps is the job on a partly-placed program;
an offering with nothing scheduled falls back to its scheduling period,
clamped out of the past and anchored on first availability.

Fires once per open, held in a ref — re-aiming the grid under a reader
is worse than the rows it replaces, and deps-driven focus is how this
component reached React #185 before.

Closes #1073

* fix(scheduling): make the focus effect fire, and stop it shipping attendees

Review follow-ups on #1073.

The effect keyed on values that settle BEFORE the week grid mounts, and
the grid only renders once consultantDetails arrives. Whenever
availability won that race the effect ran against a null container,
returned, and — a ref cannot wake an effect — never ran again. The
container is state now, via a callback ref, so the grid's own appearance
is what triggers it. The comment claiming `loading` guarded this was
wrong: it starts false, before anything is fetched.

readAppointmentDetail's slots come from an `include`, so spreading them
into the manage-timings subject shipped every attendee and every
recording URL to the client — and on a class those rows are shared by
the whole roster. Routed through one shared allowlist (#946's fix,
reapplied), which reschedule now uses too.

A past session is no longer the target while the scheduling period is
still open: those programs have sessions LEFT to place, and pointing at
a dead week also stretched the allocate-mode availability request from
that week to the end of the period (#997). Released sessions whose time
has passed, soft-deleted slots and inverted windows are handled for the
same reason — never open where nothing can be chosen.

Formatter pinned to hourCycle h23: `hour12: false` resolves to h24 under
an en-US default, which writes midnight against the previous day.

Tests: the effect (fires late, fires once), and both widened payloads.
Both new suites verified to fail against the pre-fix code.

Part of #1073

* fix(scheduling): stop the period clamping what the grid can show

Two independent causes of cells reading as absent, both found while
looking at the picker's opening position.

The availability request was clamped to allowedEnd in allocate mode
while the grid kept drawing a full seven columns, so every cell past
the period had no server row — and because the route filters
appointments by the same window, the BOOKED cells in that range
vanished identically to the available ones, which is what ruled out a
cap or a disabled state. A week further on, endDate fell before
startDate, the server had no range to scan, and the entire grid
blanked until the consultant pressed back. The comment above this
argued the START must not be clamped, so a pre-period week shows real
availability behind an "Outside Period" label rather than blanks; the
same argument was never applied to the end. allowedStart/allowedEnd
govern selectability — handleSlotClick's range guard and that label
already enforce it — never visibility. Each allocate request is now a
week rather than the rest of the period, which also removes the #997
cost this PR's description already flagged.

Separately, SLOT_CELL_BASE_CLASS carried disabled:opacity-50. Every
unavailable cell is disabled, so bg-slate-200 reached the screen at
half strength over a white card — near enough to the slate-100 that
#1064 records as reverted for making a sparse week read as an empty
grid. The Tailwind `content` cause fixed there was real; this was a
second cause cancelling the palette at render time. The legend swatch
is not a disabled control, so the legend showed the true colour while
the grid showed half of it. pointer-events-none stays, and the
explicit opacity-50/60/70 renderTimeCell appends for PAST cells is
untouched — that fade is asked for.

Also on this hook: an unreadable response now sets an error instead of
silently painting an empty grid, since an empty grid is a valid answer
for a quiet week; and the spinner's finally is request-scoped like the
success and error paths already were.

Tests: the base class carries no disabled:opacity-*, which the existing
swatch-equality test compares strings and structurally cannot see; and
the allocate window ends at the end of the visible week, stays forward
for a week past the period, and spans one week rather than a year. All
four verified to fail against the pre-fix code.

Part of #1073
)

* fix(payments): reverse org-funded booking refunds in-ledger, not at the gateway

`refundPayment` calls the gateway unconditionally, and an org-funded booking
carries a synthetic `org_wallet_` / `org_license_` / `org_invoice_` intent
that no gateway resolves. Every org-funded 1:1 cancellation therefore threw
UNKNOWN_GATEWAY in Phase 2 — before the cascade ran — and the cancel route
swallowed it as "refunded 0". Nothing was reversed: the wallet was never
credited back, the invoice accrual never netted down, the program engagement
never returned to the cap, the consultant's earnings stayed payable and no
ledger entry was written.

`refundWholeEventPayments` already split the two funding rails for class and
webinar seats. 1:1 bookings had no equivalent. `refundBookingPayment` is that
split, and it gives the reversal engine's long-documented BOOKING source its
first production caller: the internal path mints its own Refund row, runs the
proven cascade against it and settles it, all inside one Serializable tx.

Removing a paid attendee from a live event had the matching hole: the endpoint
disconnected them from the slots and left the money alone, so an organiser
could bar a paying attendee and keep the fee. A removal is the organiser's
act, so it settles at the policy's consultant-initiated percentage — the same
rule the moderation bulk-cancel has always applied.

The moderation bulk-cancel now goes through the same front door, closing its
own copy of the org-funded gap.

Part of #1003 #1020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bookings): resolve cancellation refunds across the whole booking

Cancelling a paid subscription refunded nothing at all. A subscription is a
slot-less placeholder created at checkout — the row that carries the Payment —
plus one further Appointment per allocated session, none of which carry money.
Both dashboards target the next actionable session, so the cancel route's
`appointment.payment` was always empty, the refund block never ran, and the
transaction still cancelled every slot and the subscription itself.

The refund tier had the matching defect. It read the earliest slot of the one
appointment it was handed, COMPLETED sessions included, so the tier depended on
which session you cancelled from and a live plan whose first session had
already been held always scored 0%.

`resolveBookingRefundContext` answers those questions once against every
appointment of the booking: which payment funds it, whose terms were frozen at
purchase, when the next undelivered session starts, and how much has been
consumed. Group-event lookups can be scoped to one buyer, since every
attendee's payment hangs off the same appointment.

A subscription that has already delivered sessions is escalated rather than
guessed at: the tier alone would hand back the full plan price for sessions the
consultant has held, and there is no agreed proration rule yet (#1006). The
cancellation stands, the response carries `requiresManualReview`, and a Sentry
warning fires with the session counts.

A cancelled class or webinar also notified nobody, because the recipient list
was only assembled for the 1:1 types. The organiser and every paid attendee now
hear about it, deduped, the same way the moderation bulk-cancel does it.

Part of #1003 #1006

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bookings): refund a paid request the consultant rejects

Direct checkout captures the money BEFORE the request row exists —
`createConsultation` and `createSubscription` land it PENDING with a SUCCEEDED
payment already attached. REJECTED is legally reachable from PENDING, so a
consultant declining a direct-checkout booking left the buyer paid-up with
nothing to show for it, and with no way out: REJECTED is not in
CANCELLABLE_FROM, so the cancel route 409s and the money simply stayed
captured. The word "refund" appeared nowhere in either status handler.

A rejection is the consultant's act and nothing has been delivered, so the
policy's consultant-initiated percentage applies to the whole amount — the same
rule trials already use on a consultant reject. It runs after the transition
commits, and the allowed-from guard on that transition is what makes it
at-most-once: a second reject answers 409 before reaching it.

Never throws. The rejection has already committed by then, so a gateway failure
surfaces in Sentry and on the response rather than rolling the rejection back.

Part of #1004

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(booking): correct the cancellation chapter against the current route

Chapter 08 asserted three things that are all false today: that cancellation
deletes the appointment, that no authorization is checked, and that refunds are
never automatic. The third was repeated in eight places, including the
high-level principles and the record-by-record table, so an engineer reading it
would conclude the cancel route does not touch money — next to a route that
refunds through the policy snapshot frozen at checkout.

Corrected in place rather than rewritten: the principles, the authorization
section, the refund-and-earnings section, and every table row that repeated the
"untouched" claim. The walkthrough's line-number references into the route are
still stale, and that full rewrite stays open under #1013.

Part of #1013

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bookings): every consultee cancellation was refunding exactly zero

The cancel transaction stamps every SCHEDULED/RESCHEDULED slot CANCELLED and
commits; resolveBookingRefundContext then ran a FRESH query filtered on those
same two statuses. Nothing came back, hoursUntilNextSession was null, the route
passed `?? -1`, and computeRefundPct returned 0 — for consultations that
refunded correctly on dev, because dev read the start time from the
pre-transaction snapshot. The refund facts are now resolved BEFORE the
transaction, where they were always read from.

A booking with no slot ever allocated scored the same -1, so cancelling earlier
scored worse than cancelling later. It now scores infinite notice, keyed on a
new slotsTotal so that "never scheduled" cannot be confused with "every slot is
terminal".

The tier is also clamped to what is still refundable rather than applied to the
gross, so a payment carrying an earlier partial refund pays out the remainder
instead of throwing AMOUNT_EXCEEDS_REFUNDABLE and paying nothing at all.

The partially-consumed subscription escalation is now a durable SystemEvent via
recordSystemError instead of a Sentry warning nothing drains.

Part of #1003, #1006.

* fix(payments): post the reversal journal for a licence-funded refund

Payment.amount for a LICENSE booking is the full price; only the funding LEG is
zero, because the contract absorbs the per-booking cost. legAmounts filters
amountPaise > 0, so it came back empty, fundingTotal was 0, and the whole
double-entry block was skipped — while Steps 6 and 7 had already flipped the
consultant's and the org's earnings REFUNDED. That left the booking journal's
credits stranded as permanent EARNINGS_LEDGER_DRIFT the reconciler cannot
repair, plus a Refund row for money that never moved.

The booking journal debits DISCOUNT for the gross when no funding leg carries
money, so the reversal credits the same account for the same proportion. The
existing plug then debits the payables, the GST and the platform fee and the
transaction balances by construction.

The rails suite stubs applyReversal wholesale, which is why this passed CI: it
proves routing and nothing about the reversal. Its scope is now stated, and
license-refund-ledger.test.ts runs the real cascade and inspects the postings.

Part of #1003.

* fix(payments): return the program engagement when a checkout is abandoned

Checkout debits BookingUtilization inside the booking transaction, before
capture, so abandoning an org-funded checkout burned a seat against the
contracted cap permanently. cancelPendingCheckout restored referral credits but
never called reverseBookingUtilization, which is idempotent and a no-op for
personal bookings.

Part of #1003.

* fix(events): stop the attendee-seat refund paging ops for a no-op removal

The removal handlers committed an empty transaction when the attendee held no
slots and still called the seat refund, which finds the payment by user + event
rather than by what was actually released. Repeat clicks and stale tabs each
raised an ops page. Both routes now answer 404 when there is nothing to remove.

The catch also treats ALREADY_FULLY_REFUNDED and PAYMENT_NOT_SUCCEEDED as
benign, mirroring the exemption refundWholeEventPayments already makes, so an
idempotent re-drive is not an incident.

notifyRefundProcessed now fires only on the GATEWAY rail. On the internal rail
the value went back to the org's wallet, accrual or licence — the member never
paid, so telling them a refund is coming is false.

Part of #1003.

* fix(payments): scope the dispute guard to the booking, not the row

A subscription's payment — and therefore its dispute — hangs off the slot-less
placeholder appointment created at checkout, never the session row the
dashboards pass in. Matching on payment.appointmentId meant the guard never
fired for a subscription at all. It now matches every appointment of the
booking, falling back to the row itself when the appointment is gone.

Part of #1008.

* fix(bookings): tell the buyer what happened to their money

The cancel route has always returned the outcome on `refund`, and the docs
claimed that made a stuck refund visible rather than silent — but the only
caller read `data.error` and threw the rest away, so a cancellation that
refunded nothing, or failed to refund, looked exactly like one that paid out in
full. The toast now distinguishes a refund on its way, a 0% policy tier, a
failed refund and a case under manual review.

Part of #1003, #1006, #1013.

* fix(security): a paid consultee could grant themselves a 100% refund

The rejection refund added for #1004 settles at the consultant-initiated
percentage, which is 100% and ignores every notice tier. Nothing restricted who
could trigger it. `REJECTED` is legal from `PENDING` and
`APPROVED_PENDING_PAYMENT`; both booking PATCH routes authorize any participant;
and the transition guards enforce only the from-state, never the actor. Direct
checkout captures before the request row exists, so a consultee sitting on a
`PENDING` request with a `SUCCEEDED` payment could PATCH it to `REJECTED` and
collect a full refund on demand, bypassing the entire cancellation policy.

Two locks, because one guard is not enough for a path that hands out money.
Both routes now 403 a non-consultant REJECT, and `refundRejectedRequest` takes
the actor and refuses to run for a consultee, recording a durable SystemEvent if
it ever gets there — that can only mean a caller lost its guard.

Also quotes the CONFIRMED refund amount to the payer rather than the requested
one; nothing forces the two to agree.

Part of #1004.

* fix(payments): clamp every policy refund to the remaining balance

The cancel route learned to clamp a tiered percentage to what is still
refundable; three other paths computing the same percentage off the same gross
did not. On a payment carrying an earlier partial refund or a lost chargeback
each asks for more than is left, `refundPayment` rejects the whole request with
AMOUNT_EXCEEDS_REFUNDABLE, and the caller's catch turns that into "refunded 0" —
so the buyer loses the remainder they were owed rather than receiving it. The
removed-attendee seat refund also paged ops for it.

One implementation now, in `refundable-balance.ts`, used by the cancel scope,
the seat refund and the trial refund. AMOUNT_EXCEEDS_REFUNDABLE joins the
seat-refund benign set too: unreachable through the clamp, but a refund settling
between the read and the write can still race it, and that race is benign.

Two other reads in the same shape were checked and left alone: the support
escalation sizes an exposure rather than a refund, where the gross is the
conservative input, and `lib/trials` already derived its actor correctly.

Also scopes the slot counts to the payer. `payerUserId` scoped only the payment
lookup, so on a class one buyer's sessionsCompleted, sessionsRemaining,
slotsTotal and refund tier were all derived from every other attendee's seats.

Also stops the resolver silently refunding the first of several payments. It
should be unreachable — `@@unique([userId, appointmentId])` allows one payment
per payer per appointment, and the CHARGE_MEMBER overage side-charge is created
with `appointmentId: null` precisely to dodge that clash — which is exactly why
it now escalates rather than quietly under-refunding if the model ever changes.

Part of #1003.

* fix(bookings): say what happened to the money, and tier a platform cancel right

Two problems in the cancel route's refund reporting.

A privileged cancellation was tiered as consultee-initiated. The authorization
block admits admin and staff, but `isConsultantInitiated` required the actor to
BE the consultant, so an operator cancelling in the final hours settled a buyer
who never asked for it at 0%. `cancel-user-engagements.ts` settles the same
platform act at 100%, and the trial route already derives the actor this way, so
the cancel route was the outlier. The question the tier asks is "was this the
buyer's choice", and a platform cancellation is not.

And `{ amountRefundedPaise: 0, refundPct: 100 }` meant three different things —
the gateway refused, the balance was already exhausted, or a human owes a
decision — leaving the client to infer failure from a positive percentage. The
response now carries an explicit status and the toast branches on it, including
a message for the exhausted-balance case that does not claim an alert was
raised. A failed refund is also recorded through `recordSystemError`, so it
drains through the same durable surface as the proration escalation instead of
living only in Sentry.

Group-event notifications no longer label an admin canceller as the consultee: a
class has no consultee at all, and the payload has `system` for exactly this.

Part of #1003, #1006.

* fix(events): make an already-removed participant an idempotent success

The no-slots guard answered 404, and the roster client throws on any non-ok
response — so a repeat click or a stale tab showed "Failed to remove
participant" and never invalidated the roster query, leaving the removed row on
screen. DELETE is idempotent and "this person is off the roster" is the
requested end state either way, so both endpoints answer 200 with
`removed: false`. One contract across both routes and the client.

Part of #1003.

* docs(booking): correct the passages this PR's behaviour contradicts

Four load-bearing claims a reader would act on. The notification section said
the recipient array may be empty for group events, which is the bug #1003 fixed.
The 404 section said a second cancellation gets a 404 because the first deleted
the appointment; the appointment is preserved and the compare-and-set guard
answers 409. The transaction diagram and the "what is gone" list still showed
the appointment and its slots being deleted, next to the corrected row saying
payments are preserved.

The wider walkthrough still carries stale line references and delete-based
prose; that rewrite stays tracked under #1013.

Also asserts the positional argument contract of `reverseCreditsForPayment`,
where an ordering-only assertion would miss a swap that under-restores credits.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… and the two meeting screens (#1067)

* fix(stream): key the video room to the session, not one 30-min slot row

A booking longer than 30 minutes is stored as N consecutive
SlotOfAppointment rows, but the Stream call id was minted from whichever
single row the clicking surface happened to pick — so a one-hour session
had capacity for two different rooms and the two sides of it could each
sit alone in one of them.

The room is now anchored to the run's first row, resolved server-side
(the planner hands the helper a lone slot, so the client cannot see the
run). The join window and the "host ended the call" check are likewise
measured over the whole run.

No schema change: with one MeetingSession per run attached to its first
row, the @unique on MeetingSession.slotOfAppointmentId is still exactly
right, and it is load-bearing for the P2002 concurrent-creation guard.

Closes #1061

* fix(stream): address CodeRabbit review on #1067

- resolveSessionAnchorSlot delegates to findSessionRun instead of
  restating contiguity, dead-row and tentative-split rules by hand. Two
  definitions of "one session" would put the server's room key back out
  of step with the client's join window, which is the defect being fixed.
- The consultee Home tab now carries completionStatus and meetingSession
  through to the session helpers (both were already selected by the
  events read, and were being dropped in toSlotContexts), and its join
  gate calls getSessionJoinState instead of comparing times inline — so
  it can finally see `ended`.
- The planner's webinar join falls back through getCurrentOrNextSession
  rather than an unsorted slotsOfAppointment[0].
- Record why the anchor-lookup fallback in lib/meeting is unsafe but
  still the right trade-off.
- Pin the null-endsAt duration fallback and the anchor id stamped into
  the Stream call's custom data.

Part of #1061

* feat(stream): make the video call describe its session

getOrCreate was sent little more than a call id, which makes a Stream
dashboard row or a recording listing unreadable without joining back to
our DB and leaves each surface to infer who is hosting.

resolveSessionCallProfile now resolves the session once, server-side, at
call-creation time: starts_at is the RUN's start rather than the clicked
row's, members name the participants with roles, and custom data carries
the anchor slot id, the session bounds and duration, and the offering
title. Hosts for webinars and classes come from resolvePlanOwnerIds --
plan owner plus ACCEPTED collaborators -- not from whoever clicked Join.

Group events name their hosts only: an oversized members array on a large
webinar would have Stream reject the request and turn a working join into
a failure. Everything here is additive and individually optional, and the
lookup only runs on the branch that mints a call.

backstage, join_ahead_time_seconds and settings_override.limits
.max_duration_seconds are deliberately NOT sent (deferred, #1070): the
first two let Stream refuse a join, stranding a consultee invisibly, and
the third hard-terminates a call that overruns. CallRequest in the
installed SDK has no ends_at, so the end travels as call metadata.

Part of #1070

* fix(stream): release the camera on every exit, and say what a session is

Media teardown. Leaving a meeting released the camera and microphone by
awaiting camera, then microphone, then screen share inside one try block,
so the FIRST rejection silently skipped the rest -- and "end call for
everyone" ran its cleanup AFTER call.endCall(), so an endCall that threw
took the release down with it and the host left the page still
broadcasting. All three exits (page unmount, leave, end-for-everyone) now
go through one helper that settles each release independently, force-stops
rather than trusting the managers' disableMode, and sweeps any track left
live afterwards. A pagehide listener covers tab close and hard navigation,
where React never runs effect cleanup; it stops tracks synchronously
because anything awaited there may not resume.

The second capture was the leak that survived all of that: the lobby's
audio meter opened its own getUserMedia({audio:true}) and only closed the
AudioContext, which does not stop a track. One per mic toggle, live for
the life of the tab. It now meters the stream the call already owns.

Banner copy. `joinable` is true both before a session starts and
throughout it, so a session 25 minutes in announced itself as "starting
now". The three states now read distinctly, derived from the shared
session helpers rather than a fourth private copy of the join window --
"in progress" is `getSessionJoinState` with no pre-start allowance, which
is the same definition of "under way" the join gate uses. Consecutive slot
rows of one booking collapse into a single session on the way in, so a
two-hour appointment stops reporting its second half as something starting
in 30 minutes.

Join failure. createDbMeetingSession ran its maintenance read, its input
validation and an organization lookup OUTSIDE its try block. Anything they
threw escaped the server action raw: no Sentry event, no slot id, no
legible message -- Next replaces an uncaught server-action error with an
opaque digest, which is exactly the "An error occurred in the Server
Components render" the join toast reported -- and it bypassed the P2002
fallback that makes a concurrent join safe. Everything now runs inside the
guard. A deliberate refusal (maintenance, genuinely invalid input) keeps
its message and stays out of Sentry; the organization lookup stays fatal
rather than degrading, because that column is written once and a null
recorded from a blip would hide the call from its own org permanently.

The two screens. The lobby led with an upper-cased appointment type above
the name of the person being met, wrapped to two lines, and carried no
time at all. It now leads with the counterpart, names the offering under
it, demotes the type to a chip, and states the scheduled time, the booked
duration and whether the session is early, starting or already running.
The green pills came off the video; device state and a labelled mic meter
read underneath it. The tip line only appears when it has something to
say. In the call, the header is sentence case and name-led, a clock shows
elapsed time against the booked end (amber once it overruns), and "end for
everyone" moved out of the control bar into a session menu -- it was the
widest, highest-contrast element on the screen, a thumb's width from the
ordinary hang-up. Leave is now the only red control in the bar.

Both screens read the session data the call already carries (#1070). Host
and guest names are stamped alongside it, because `title` only ever held
the requester -- which showed a consultee their own name as their
counterpart.

Part of #1070

* fix(stream): authorize the meeting resolvers, and refuse an ended room

Security. resolveSessionAnchorSlot and resolveSessionCallProfile are
exported from a "use server" module, so any signed-in client could call
them with any slot id. They validated the id's SHAPE and nothing else,
and the profile answers with the offering title and the user ids and
names on both sides -- session membership for a stranger's booking, one
guessed id at a time. Both now go through readSlotForCaller, which admits
a participant (connected to one of the appointment's slot rows), the plan
owner or an ACCEPTED collaborator (resolvePlanOwnerIds, the predicate the
reschedule and timings routes already authorize with), or ADMIN/STAFF.
An unentitled caller gets null, never a refusal: the join still happens,
just unanchored and undescribed, because entry must not regress.

Performance. Neither resolver checked appointmentId before its sibling
findMany. Prisma renders a null as `appointmentId IS NULL`, which would
scan every appointment-less row rather than one booking's. The column is
required in the schema, so this is defence in depth rather than a live
bug.

Correctness. The planner's webinar join fell back to getCurrentOrNextSession
whenever getJoinableSession returned null -- which it does for countdown,
disabled AND ended -- then joined without looking. A session the host had
closed, or whose time had passed, opened anyway. It now checks the resolved
run and refuses when it is ended; countdown still gets in, since hosts have
always opened the room early. The joinableEventIds memo only recomputed on
data change, so Join stayed lit as the clock passed the window; it now ticks
every 30s. The planner query did not select meetingSession at all, so that
gate could never see an ended call -- two columns added.

Tests. The join_ahead_time_seconds assertion sat inside `custom`, where
nothing could ever put it, so it passed for any implementation; the
deferral is now pinned on the settings surface and by the exact key set
of the getOrCreate payload.

Part of #1061

* fix(stream): refuse a join before minting the call, and let the planner join a class

#1077 — `getOrCreateAppointmentMeeting` minted the Stream call and only then
called `createDbMeetingSession`, whose first act was the maintenance gate. A
blocked join therefore left a live call that no MeetingSession row pointed at,
stamped with the bounds and members computed at the blocked moment and never
corrected, because only the mint branch writes them.

The maintenance read and the slot shape check now live in one
`refuseMeetingCreation` helper, hoisted ahead of `call.getOrCreate` through a
`getMeetingCreationRefusal` server action and still enforced inside
`createDbMeetingSession` for direct callers of the "use server" module. The
organization lookup stays where it is: it cannot refuse a join on policy, only
fail transiently, and hoisting it would run the same query twice.

#1080 — `classInclude` was `appointments: true`, so the slot rows the planner
derives a class's joinable session from never left the database and every class
reported "No joinable session found" at every hour of every day. It now selects
the six fields the join path reads, including the `meetingSession` the ended
guard needs, bounded to a day either side of now — wide enough that no run the
join path can pick is ever truncated.

Closes #1077
Closes #1080

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(stream): stop showing users raw error text on the join path

A consultant testing the preview deploy was shown, verbatim: "Loading chunk
99515 failed." That is a stale-deploy artefact — we shipped, Netlify swapped
the bundle, and their open tab's next import() asked for a hash that no longer
exists. A refresh fixes it outright, but all four join catch blocks ended with
`error.message`, so they got a bundler internal and no way forward. The same
catch had earlier shown them Next's "An error occurred in the Server
Components render…", which says nothing and implies something unfixable.

`lib/errors/classification/client-failure.ts` splits the two audiences. The
toast gets a short human sentence, and the original error — with its digest
and the slot/appointment context — goes to Sentry. A stale deploy is detected
by shape rather than by one string, because every bundler and engine words it
differently, and it is reported at info with expected:"true" instead of paging
anyone. Its toast offers a Refresh, since that is the actual fix.

Refusals we authored, such as the maintenance block, keep their message: the
generic copy would otherwise have swallowed the one sentence on this path
worth reading.

The client-not-ready path now reports too, with how long it waited, how long
the page has been open and whether the provider ever connected — the three
things that tell a cold serverless start from a genuine failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(appointments): a session is a run of slot rows, not one row

A 4-hour consultation (12:30–16:30 IST, eight rows) rendered in the
appointments list as "2:00 PM – 2:30 PM", and the 8-hour booking as
"5:00 PM – 5:30 PM", while the Manage Timings grid drew the same booking
correctly as one continuous run. The two surfaces disagreed and the list was
the wrong one.

All THREE mappers carried their own `sessionsOf`, each emitting one SessionVM
per stored 30-minute row, so every consumer downstream — the row's time range,
the session count, the timeline, the anchor, group progress — inherited it.
They now share `sessionsOfAppointment`, which groups rows into contiguous runs
through `groupSlotsIntoRuns`: the same definition of one session the room key
and the join gate already use. Grouping happens per appointment before the
sibling flatten, so two sittings on different days cannot merge.

A run takes its bounds from its first and last rows and everything else from
the anchor, where the MeetingSession hangs. Cancelled and rescheduled rows are
bucketed by status and grouped separately, so a cancelled session still
appears on the timeline and a dead row can never bridge two live runs.

SessionTimeline stopped re-grouping by appointment id. That was compensating
for the per-row VMs and was wrong in the other direction: two sittings under
one appointment fused into a single row spanning both.

docs/booking/03-slot-math-and-calculations.md now states the invariant, names
`groupSlotsIntoRuns` as its single definition, and records why the contiguity
walk cannot be reduced to an appointment id — the seed data and #1071 both
violate one-appointment-per-session today, and keying on it would put
unrelated sessions in one shared video room.

Part of #1061
Refs #1071

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(stream): make the lobby's mic meter move, and the preview the subject

The meter DID detect speech — the caption flipped to "We can hear you" — but
the bar never visibly moved. Not a missing stream and not a stalled loop: the
measurement was wrong. It averaged `getByteFrequencyData` across all 128 bins
of a 24 kHz spectrum and divided by 128, and speech occupies the first handful
of bins, so ~122 near-zero bins dragged the mean to roughly 0.06–0.20. That is
a 6–20% bar with a 2% floor under it: it moved, but only within a sliver at
the far left.

`lib/stream/audio-level.ts` measures RMS over the TIME-domain waveform and
maps it through decibels, so the range people actually speak in occupies most
of the meter's travel, with asymmetric smoothing that rises fast and falls
gently. It observes the stream Stream's MicrophoneManager already owns and
never stops it, so the original second-getUserMedia leak cannot come back. The
bar is written through a ref rather than state, which removes ~60 whole-lobby
re-renders per second.

The blue border was Stream's own decoration, not a focus ring:
`.str-video__video-preview-container` ships a 4px #005fff border on all four
sides and a fixed 500x375 box. The border read as a debug outline, and the
fixed box is 4:3, so inside a 16:9 frame `object-fit: cover` cropped the
sitter's face. Both are overridden under a scoping class so the rule outranks
the SDK's whatever order the stylesheets land in. No :focus style is touched,
and the toggles gained explicit focus-visible rings and aria labels.

The lobby is now two columns on lg — a full-width 16:9 preview with its own
controls beside the session details and Join — collapsing to one column below
that. The on/off chips are gone: the toggles now sit next to the preview and
go red when off, and the preview says "Camera is off" itself.

Layout is structural only; it has not been verified in a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(stream): label the in-call clock, give the lobby an exit, contain its settings

The in-call pill read "● 2:41:12   1 hr 19 min left": two durations side by
side, only one labelled, so nothing said whether 2:41:12 was elapsed or
remaining and the natural reading was that they contradicted each other. What
remains now leads, because that is what a consultant acts on, and the clock
since the start is secondary and says "2:41:12 elapsed". Overrun already read
"20 min over" rather than counting into negatives — nothing sends
max_duration_seconds (#1070), so sessions do run past their booked end — and
that wording is kept and now tested.

The lobby had no way out but the browser Back button. ExitMeetingButton runs
`leaveCallAndReleaseMedia` BEFORE navigating, through the same teardown every
other exit uses, so a new way out cannot become a new way to leave the camera
light on. Its guard is a ref, since state does not update in time to stop a
second click. Browser Back was already covered by the route's unmount cleanup
and pagehide handler; that is unchanged.

Where it goes back to is derived, not hardcoded: `resolveAppointmentsHref`
sits beside the existing personal-dashboard resolver and deliberately inverts
its priority — an org workspace wins there but has no appointments surface, so
it must not win here — and always resolves somewhere.

The device settings opened as an inline block below the controls and ran off
the bottom of the viewport, so the lower options were unreachable. It is now a
Radix Dialog from components/ui, which is portalled, caps at 90dvh, scrolls
inside itself and brings Esc, outside-click and a focus trap. Device
enumeration and selection are untouched.

Visual result is unverified; there is no browser here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(stream): gate the meeting writer, and stop guards that only cover the happy path

CodeRabbit review on #1067.

CRITICAL — `createDbMeetingSession` is exported from a `"use server"` module,
so any client reaches it with arguments of its choosing, and it checked only
maintenance and slot shape. An unauthorised caller could write the
MeetingSession row for any slot with a streamCallId they chose; that row is
unique per slot, never updated, and reused by every later join, so both
legitimate parties would then be routed into a call the attacker controls. We
gated the resolvers two rounds ago and missed the writer. It now takes the
same entitlement check.

The audit of the rest of that module found three more gaps.
`getOrCreateMeetingSession` and `updateMeetingSessionCallId` had no callers at
all, and the second rewrote an existing session's streamCallId outright — a
more direct hijack than the one that prompted the audit. Both deleted rather
than gated. `findDbMeetingSessionBySlot` is left ungated on purpose: the only
thing it returns is `slot-<anchorSlotId>`, derivable from the id the caller
supplied, so a gate buys no confidentiality while putting a hard refusal on
the read every join makes. `getMeetingCreationRefusal` reads no row.

Failing closed changed the contract for a stranger's join: it is now refused
rather than degrading to an unanchored room. Four tests updated to match.

The other two findings share the Critical's shape — a guard over the happy
path only. `Promise.allSettled` isolates a rejected promise but not a
synchronous throw from `disable()`, so teardown could skip `stopLocalTracks`
and strand the user with the camera live; each disable is now an async arrow
and both exit paths navigate in `finally`. In `createMicLevelMeter` only the
context factory was guarded, while `createMediaStreamSource` throws mid
device-switch and took the lobby down and leaked the context; the whole graph
is inside the guard.

Also: pagehide fired for bfcache and stopped tracks on a frozen page; group
events fetched attendee rows they discard; host derivation existed in three
copies and gated a destructive action; the 1Hz clock re-rendered the whole
video layout; the planner payload test passed only on the day it was written;
the hold-to-end control was unreachable by keyboard; header z-index painted
over the participants panel; a zero duration rendered a bare "0".

Judged stale, not fixed: seven findings already addressed in earlier rounds
(findSessionRun reuse, orphan-row guards, planner fallback, the ended guard,
Home-tab slot fields, the anchor-id assertions, the fallback comment).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chat and video clients were two independent useStates set by two
async connects that race. The element in the provider's wrapper slot
therefore changed TYPE between renders — children, then <StreamVideo>,
then <Chat>, in whichever order the sockets settled — and React cannot
reconcile a type change in place, so it remounted the entire dashboard
subtree each time. An in-flight join was torn down underneath the user.

Both connects now resolve to their client instead of setting state, and
Promise.allSettled commits the pair at once, so the tree shape is a pure
function of one settled value and changes exactly once in the normal
case. allSettled also stops a chat failure from discarding a good video
client, which Promise.all did by rejecting on the first failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
Every directive allow-listed *.getstream.io, which is Stream's marketing
domain. At runtime the SDKs talk to *.stream-io-api.com (REST and both
websockets), *.stream-io-video.com (the edge-latency hint fetched before
a call, then the SFU) and *.stream-io-cdn.com (recordings, attachments).
None of those match, so a dashboard load filed violations for traffic
the app cannot work without — and would have broken outright under
ENABLE_CSP_ENFORCE. Confirmed against a deploy-preview network log.

/api/csp-report was on spamLimiter's 5/hr, a budget sized for a human
filing a support ticket. Browsers emit a report per violated directive
per navigation, so a few page loads exhausted the hour and the rest were
dropped — the report-only rollout was blind exactly when it had
something to say. It now has its own limiter with a ceiling sized for
browser-generated volume.

Co-authored-by: Cursor <cursoragent@cursor.com>
…events a real Unschedule (#1083)

* fix(appointments): gate Manage Timings on whether anyone has committed

Manage Timings writes new times with no notice requirement and no
acceptance, which is honest only while nobody else holds the time. The
consultant menu offered it for any non-cancelled, non-past booking, so a
consultation or subscription session a consultee had paid for could be
moved out from under them.

Adds `allowsManageTimings` next to `slotsAllowReschedule`: group events
and anything unallocated keep the surface, a 1:1 with a confirmed slot
loses it and gets the negotiated Reschedule instead. The two are now
complements, so a booking never shows both. The page enforces the same
rule after its ownership check, because the URL is linkable.

Closes #1082

* feat(appointments): give group events a proper Unschedule action

Gating Manage Timings on whether anyone has committed to a time also took
Reschedule off webinars and classes. That was right — for a group event
that route never opened a proposal, it only marked every slot tentative
and handed the instance back to the allocate queue — but it removed a
real capability along with the wrong label.

Adds `allowsUnschedule` beside the other two predicates. It is orthogonal
to them, not a third branch: a confirmed webinar offers Timings AND
Unschedule, a 1:1 offers Reschedule and never Unschedule, and the "never
both, never neither" property still holds within the Timings/Reschedule
pair. The action routes at the existing reschedule endpoint rather than a
parallel implementation, so the behaviour is unchanged and only its name,
its gate and its confirm copy are new.

Unschedule is emphatically not Cancel. The booking stays sold, attendees
stay enrolled, no refund is issued and no earnings, ledger row or
utilisation figure moves; the only thing withdrawn is the date. The
confirm dialog says all of that plainly and points at Cancel booking for
the other outcome, because for a webinar with thirty paid attendees the
difference is a new date versus refunding all of them.

Attendee notification was already correct on this path: the route's
post-transaction fan-out adds every user connected to the event's slots
(#624), and a group buyer is connected to every slot of every session, so
enrolled attendees are told the date has been withdrawn.

Part of #1082

* fix(notifications): a reschedule with no new time no longer says "from  to"

The consultant's inbox rendered "rescheduled ... from&nbsp;&nbsp;to". The
payload type declared `oldDateTime?` and `newDateTime?`, the reschedule route
passed neither, and the template interpolated both anyway.

Passing two values would not have fixed it, because the sentence is wrong for
the common case. A plain release has no destination — that is the entire point
of "Any time works": the slots go back to the consultant's queue and no new
time exists yet. Only an auto-confirmed proposal actually moved anything, and
between those two sits a third case the route already distinguishes in its own
response message, a proposal that has been made and is waiting to be answered.

So the payload now carries `outcome`, one of MOVED, PROPOSED or RELEASED, and
the two arms are a union rather than optional fields: MOVED and PROPOSED cannot
be constructed without both times, which makes the blank-blank payload a
compile error rather than a rendering accident. This is the idiom the org
workflows already use — `reminderStage` on the dunning notice and `kind` on the
payout failure both drive their copy from one workflow id.

`rescheduleNotificationVariant` in the policy module derives the variant, so the
route stays a caller and the rule is unit-testable. The released time is
captured inside the transaction because an auto-confirm deletes those slot rows
and writes new ones — by the time the notification is assembled the time being
given up exists nowhere else.

The template itself lives in Novu's dashboard and cannot be changed from here.
Until it branches on `outcome`, the moved case renders real times where it
rendered blanks, and the released case still renders the wrong sentence. The
Novu-side change is spelled out in the PR body.

An audit of every optional field on every payload in lib/novu found the same
shape once more: `SupportTicketPayload.respondedBy` is declared and never
passed, so a template naming the responder rendered an empty attribution. It is
passed now. Three more are declared and never passed but need a query or a loop
change to supply, and are listed in the PR body rather than fixed here.

* fix(notifications): stop the inbox panel parking over the slot grid

The notification panel covered the Friday and Saturday columns of the calendar
at a normal laptop width. Two things put it there. It was Novu's own bundled
popover, fixed at 400px on a bespoke z-index of 9999 with no collision
handling; and its open state belonged to Novu, so clicking a notification
routed the user to the page the notification was about and then stayed open on
top of it. The panel a consultant saw over their calendar was usually the one
that had just sent them there.

Given children, `Inbox` drops to being a provider — `Bell` and `InboxContent`
are the composition parts — so the panel becomes ours to place. It is now the
repo's Radix popover from components/ui, which brings collision-aware
placement, a viewport-capped size, Escape and outside-click dismissal, and the
shared z-layer instead of a number picked to beat everything else on the page.
Because the open state is ours, the panel closes before routing.

It is also narrower: 22rem against the old 25rem, capped at the viewport on
both axes, which leaves more of the grid legible while it is open.

Presentational only. The subscriber, the ADR 23 scope tabs, the appearance
variables and the click-to-route behaviour are unchanged; the two appearance
keys that styled Novu's popover are dropped because that popover no longer
renders, and `bellContainer` with them, since a custom `renderBell` has always
bypassed the container it styled.

* fix(appointments): check every upcoming slot, not just the earliest

A partial reschedule releases one session of a multi-session booking and
leaves the rest confirmed. The released slot can sort first, so reading
only `slots[0].isTentative` saw "tentative", concluded nobody had
committed, and handed the consultant Manage Timings — the unilateral
surface — for a booking whose later sessions the consultee still holds.

The existing in-flight test happened to put the released slot second,
which is why it passed. The new case puts it first, and fails against
the old predicate.

Part of #1082

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Editing either type always 404'd. The edit page built its candidate list
from the planner query, but that payload carries only webinars, classes
and participant counts — consultationPlans/subscriptionPlans were never
on it, so notFound() fired unconditionally for those two. Each type now
reads the source that actually owns it, and only that one runs.

Also in the authoring path:

- The planner's Edit control no longer navigates with a missing id. Both
  spellings produced an unreachable URL (`id ?? ""` collapsed to a double
  slash, a bare id stringified "undefined"), and the card now disables
  Edit and Delete for a row that has no id to address.
- Breadcrumbs stop linking segments that own no page. `offerings` and
  `participants` only have dynamic children, and a dynamic param's VALUE
  is never a URL of its own, so Next was prefetching routes that 404.
- ClassPlanSchema's start date is renamed to the field the manifest
  authors (`schedulingStartDate`); it was `startDate` here, so the
  resolver stripped the value and no class ever sent one. The adapter now
  maps it to the API's ISO `startDate` and hydrates it back from the Class
  row's schedulingPeriodStartsAt.
- SubscriptionPlanSchema gains `subscriptionContents`. Absent, the
  resolver stripped the roadmap and every save posted an empty list,
  which the PUT treats as "replace with nothing".
- The consultation and subscription list reads now include `faqs`. The
  editor hydrates from them and PUTs the whole array back, so omitting
  them meant saving an empty set over a plan's FAQ.

Co-authored-by: Cursor <cursoragent@cursor.com>
The billing page is a server component that imported
workspaceBillingQueryKey from useWorkspaceBilling.ts to SSR-prefetch the
roll-up. That module is "use client", so every export of it is a client
reference and calling one from the server threw "Attempted to call
workspaceBillingQueryKey() from the server but workspaceBillingQueryKey
is on the client" — taking the live page down.

The key moves to a module with no directive, callable from either side.
The operator home shell had the identical import and the same latent
crash, so it moves too.

Co-authored-by: Cursor <cursoragent@cursor.com>
The provider doc described the two-independent-useState pattern that
caused the dashboard remount, including a render tree nested in the
opposite order to the code, a spinner gate removed in #248, and an
unmount disconnect the provider deliberately does not do. Someone
following it would have rebuilt the bug.

The security-headers doc asserted that *.getstream.io covers Stream's
traffic. It does not, and that claim is why the allow-list was wrong;
the three real domains are now named with what each carries.

Adds the two symptoms to the Stream troubleshooting guide, since that is
where anyone hitting them will look first.

Co-authored-by: Cursor <cursoragent@cursor.com>
…1069)

* feat(scheduling): preference-scored auto-allocation, never filtered

A consultee releasing a session without naming a time can now say how they
would like the replacement placed. The allocator SCORES candidates on that
preference and never removes one, so an unsatisfiable preference costs a
less-liked time rather than the allocation.

Deletes the dead client-side filterSlotsByPreferences, which did the
opposite: prefer-mornings against a consultant with no morning availability
answered "no slots available" with the whole afternoon free, and read the
hour off the server's clock.

Closes #1065

* fix(scheduling): resolve the preference by released slots, not appointment

The preference was written against the appointment the reschedule URL
resolved to and read from the appointments carrying the released slots.
On any multi-session booking those differ, so the row was stored and never
read and the allocation fell back to first-fit — silently, and only for
sessions other than the next actionable one.

Both sides now key on the released slots. That also scopes the lookup in
time: a consumed reschedule's slot rows are deleted and replaced, so a stale
preference cannot leak into a later reschedule of the same session. The
allocator closes the request it answers, so preference-only rows can claim
openForAppointmentId again and "at most one live reschedule per appointment"
stays true for the four readers that assume it.

Also: gate the control off group events, where the server drops it; surface
the preference on the consultant's card; rank late night below a real
evening; and skip block-building on days a preferred-only sweep cannot use.

Part of #1065
The dashboard shell let tall pages grow the document, so window-scroll
carried the context bar and sidebar off-screen with the form. Clip the
shell to the viewport and scroll only <main>.

Pin the offering title + section tabs (Basics / Pricing / …) under that
bar with sticky positioning and scroll-margin on each section so the
tabs stay clickable and land the right block. Offerings breadcrumbs now
link to the Event Planner listings instead of a pathless URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
…t date

The right-panel overflow-hidden created a second scrollport so Basics /
Pricing / FAQ never stuck under the context bar. Clip only the shell;
sticky chrome stays on <main>.

Edit fetches /api/plans/{type}/{id} instead of a paginated marketplace
list, so ORG_ONLY and off-page plans no longer 404. Cleared class start
dates serialize as null so PATCH can null schedulingPeriodStartsAt.

Part of #1088.

Co-authored-by: Cursor <cursoragent@cursor.com>
…croll

Drop the 500px calendar cap and size Timings/Allocate/Reschedule to the
dashboard content column so the week grid owns the only scrollport.

Co-authored-by: Cursor <cursoragent@cursor.com>
fix(stream): one settled client state, and a CSP that matches Stream's real origins
fix(offerings): open the editor for consultation and subscription plans
GitHub reports the head as BEHIND because prod carries merge-commit nodes that
never travel back to dev. Content-wise dev is already a strict superset; this
only satisfies the up-to-date branch protection, as on prior release PRs.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@netlify

netlify Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit 2ed1d91
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a6dfa5ebacffa0008c65447
😎 Deploy Preview https://deploy-preview-1090--familiarise.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 69 (🟢 up 11 from production)
Accessibility: 99 (🟢 up 3 from production)
Best Practices: 92 (🟢 up 9 from production)
SEO: 99 (no change from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1ba8d9a3-5871-48d9-88de-6e8567198515

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dosubot

dosubot Bot commented Aug 1, 2026

Copy link
Copy Markdown

📄 Knowledge review

Dosu skipped reviewing this PR because your organization has used its 200 included credits for the month. Your usage will reset on 2026-09-01. To have Dosu review this PR before then, ask your organization admin to upgrade to a pro account.


Leave Feedback Ask Dosu about familiarise_web Add Dosu to your team

@sonarqubecloud

sonarqubecloud Bot commented Aug 1, 2026

Copy link
Copy Markdown

@teetangh
teetangh merged commit a62da8f into prod Aug 1, 2026
8 checks passed
@teetangh
teetangh deleted the release/dev-to-prod-2026-08-01 branch August 26, 2026 07:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant