release: dev → prod — 2026-08-01 (stream, offerings editor, scheduling, payments) - #1090
Conversation
…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 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.
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
✅ 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: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
📄 Knowledge reviewDosu skipped reviewing this PR because your organization has used its |
|




Releases eleven first-parent commits already merged to
devand verified there.What ships
Notes
chore(release): merge prod into the release branchis present only to satisfy up-to-date branch protection (prod merge-commit nodes never travel back todev).prisma migrate diff.Made with Cursor