release: dev → prod 2026-09-06 (booking closure train: availability day semantics, typed cancellation policy, reschedule confirm, dashboard fixes, cron gates, E2E fixes) - #1523
Conversation
…n, webinar and class plans (#1507) * feat(plans): sole-owner consultants can archive and restore their own offerings The only existing archivedAt writer was the org-catalog bulk archive endpoint, so a consultant without an organization behind their plan (the majority) had no way to stop selling a consultation, subscription, webinar or class. Adds a PATCH { archived: boolean } to each plan family's [id] route, sharing one owner-check/idempotent-toggle helper in lib/api/plans/archive.ts so the four routes cannot drift, plus an Archive/Restore action on the consultant planner dashboard and a table-driven test pinning owner-archives/non-owner-403/restore per family. Closes #1494 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(plans): the archive routes answer a bad body with 400, and the planner reads one archive state Review round 1 on #1507. A malformed or empty PATCH body rejected inside `request.json()`, reached the outer catch, and was reported to Sentry before answering a client fault with a 5xx while the zod branch beside it answered 400 for the same class of fault; `parsePlanArchiveBody` in the shared helper now covers both and every family returns 400. The planner kept a second copy of `getPlanArchivedAt`, so the "Archived" badge and the value the toggle PATCHes could drift apart; both components now import one `lib/planner/archive-state` (which also absorbs the type guards and `getPlanId` that were duplicated between them). The failed-response handler in `patchPlanArchived` parsed a body that a 502 HTML page or an empty 401 does not have, replacing the consultant's message with a SyntaxError. Also clears the two SonarCloud blockers: the archive helper is single-intent instead of boolean-switched (S2301) and the toggle icon is named rather than a nested ternary (S3358). Part of #1494 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(plans): an org-governed plan is refused by the sole-owner archive routes The four PATCH routes authorized on the plan's consultantProfile.userId alone, so a consultant could withdraw a plan their organisation governs with no org authorization at all — the org catalog endpoint reaches the identical archivedAt write only through requireOrgAccess and an organizationId-scoped update. A plan carrying an organizationId now answers 403 PLAN_ORG_GOVERNED before the write, after the ownership check so only the owning consultant learns why, pointing them at their organisation admin. One case per family pins it (#1494). Part of #1494 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…check (#1480) * chore(ci): stop the Claude review/mention workflows failing loudly on the expired OAuth token CodeRabbit is already the reviewer of record on every PR, and claude-code-review.yml duplicated it on every finance PR the train orchestrator tags. Since CLAUDE_CODE_OAUTH_TOKEN expired, that workflow has failed 401 on every run, leaving a permanent red non-required check. - claude-code-review.yml: drop the automatic pull_request(labeled) trigger, run only via workflow_dispatch with a pr_number input (checked out with gh pr checkout), keep a concurrency group per #1448 hygiene, and pin to the current major tag (@v1) instead of @beta. The claude-review label is now a plain tag with no workflow attached. - claude.yml: gate the @claude-mention job behind a CLAUDE_ACTION_ENABLED repository variable so a stray "@claude" mention doesn't trip the same expired-token failure, and pin to @v1. - docs/authentication/betterauth/06-ci-deployment.md: correct the stale "AI-assisted code review on PR" line to reflect the on-demand workflow and CodeRabbit as reviewer of record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(ci): isolate fork PR checkout from the trusted workspace root, rename direct_prompt to prompt The on-demand review job checked the PR out over the workspace root before claude-code-action ran with id-token: write, letting a fork PR's files sit where the authenticated action reads from. Check the PR out into pr/ instead and hand that path to the action via claude_args: "--add-dir pr". v1 of claude-code-action renamed direct_prompt to prompt; the old input name meant this dispatched run silently waited for an @claude mention instead of reviewing. claude.yml doesn't set either input, so it needs no change. SHA-pinning claude-code-action was declined as policy on #1393 and is left at @v1 per that decision. Part of #1480 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… domain hierarchy (#1483) * chore(skills,agents): reorganise Claude Code skills and agents into a domain hierarchy Twelve flat project skills and a flat agents folder had grown past the point where a description alone could route a request to the right one, and the finance/enterprise domains had no skill at all despite the 2026-09-03 finance train shipping seven ADRs and a compliance model with no doc home. This groups skills into six domains (booking, finance, enterprise, stream, maintenance, workflow), each with an index SKILL.md and references/ for the detail, and renames the four finance-tier agents to domain-neutral names so the same tiering (opus-high builder, opus-medium triager, sonnet-medium sweeper, sonnet-low mechanic) can be dispatched for any subsystem, not just money. New content (finance/references/doctrine.md, verification.md, compliance.md; enterprise/references/money-seam.md, org-payouts-and-tds.md; maintenance/references/cron-ticker.md) is written from the repo's own ADRs and compliance docs, cited at the foot of each page. Every internal cross-reference to an old skill or agent path was grepped and fixed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(skills): the reorganisation's dangling cross-references resolve, and the drift guard scans the moved bundle (#1483) Round-1 review triage. Five link and boundary defects the domain move left behind: - `references/concurrency.md` and `references/money-boundary.md` cited the booking doctrine as `SKILL.md`, which resolves inside `references/` where no such file exists; both now say `../SKILL.md`. - `booking/references/verification.md` still pointed at the retired `.claude/skills/razorpay/` path for the worktree-jest claim it contradicts. - `stream/SKILL.md` carried `name: stream-sdk` while living in `stream/`. The folder is what Claude Code keys discovery on, so the mismatched name was a trap for anyone reading the frontmatter as authoritative. - `check-doc-drift.sh` derived ROOT from a pre-move depth, so both TARGETS pointed at directories that no longer exist and grep's suppressed error left it printing OK while scanning nothing. It now resolves the repo root, fails loudly on a missing target, and no longer swallows grep's errors. - The `razorpay-subscription` agent sent the reader to a `not-used-here/` reference while still opening as a build-a-subscription-flow brief. It now states up front that recurring billing is in-house and that reversing that is a product decision, not one the agent makes. Also refreshes the stale skill names in `docs/stream/README.md` and `docs/booking/05-troubleshooting-and-changelog.md`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * docs(compliance,skills): the TDS portal claim is hedged to its source, and verification states what a run leaves behind (#1483) Round-2 review triage, resolving the two decisions held open in round 1. The TDS filing note asserted flatly that the income-tax portal rejects an upload carrying a legacy section number. The CBDT "Updated FAQs on Interplay & Transitions" supports the weaker claim: the portal validates section codes against the Income-tax Act 2025 numbering, so a legacy number is expected to fail that validation. All three sites that carried the absolute wording now carry the hedged, sourced one, so the skill cannot drift from its sources. The payout-pipeline gap note also credited a translation it went on to describe as missing; the quarterly return draft does translate (`lib/compliance/tds-return.ts` carries a per-line `paymentCode` and refuses to serialize without one, #1389), and it is the challan and form generators that remain open. The money-verification recipe keeps its premise — one Supabase project, no branches, by decision — and now says plainly what a run leaves in it, why that is acceptable while the dataset is mock and a reset precedes launch, the four disciplines that keep the residue harmless (record the ids, use recognisable fixture names, never delete a ledger row, repair caches to the journal), and the escalation to a dedicated project once real customers exist. Three secret-hygiene and correctness fixes in files this PR authored: - The stream env audit piped `netlify env:list --json` straight to output, putting every value in the transcript; it now projects keys only, with a jq expression that tolerates either shape the CLI returns. - The env-sync examples carried plaintext values on the command line, which a shell history and this transcript both capture. Placeholders now, with the reason stated. - The thread-listing GraphQL snippet interpolated the PR number into the query instead of binding it; it now declares `$pr: Int!` and passes `-F`, matching the resolve mutation beside it. Verified against this PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… in the payment-success notification (#1484, #1485) (#1489) * fix(notifications): the payment-success message names the plan, not its UUID (#1484) The PAYMENT_SUCCESS and APPOINTMENT_BOOKED payloads were built with `planTitle: metadata.planId || planTitle`. `metadata.planId` is the plan's id and sat on the LEFT of the `||`, so on every normal capture the buyer's confirmation told them they had bought a UUID. The fallback it shadowed was broken independently: both branches of its ternary returned `metadata.appointmentType`, so it could only ever have read "CONSULTATION", never a plan name. The title is now resolved from the appointment Phase 2 already fetches for these notifications. That read's select is widened by exactly one scalar per shape — the plan's own `title` — so there is no extra database round trip inside a block that runs post-commit on a single-connection pool. A TRIAL is labelled "Trial session" rather than taking its parent subscription plan's title, which names the paid programme and not the free session, and anything unresolvable degrades to a humanised appointment type. No id can reach the payload on any path. Refs #1484 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(content): the public stats show real figures or none at all (#1485) /explore/experts fell back to "10K+" active experts and a "4.9" rating whenever the real numbers were zero — which is the entire pre-launch state — and rendered "50K+ Sessions Completed" that was read from nothing at all. The landing page carried a hardcoded 4.9 "Average Rating" counter and the trust badge "4.9★ average session rating". Fabricated numbers on a public marketing surface are a misleading-advertisement exposure under the Consumer Protection Act 2019, not a positioning question. The experts hero is now built by `buildExpertsHeroStats`, which emits an entry only when its figure means something: consultants above zero, a rating only once at least five published reviews stand behind it, and sessions above zero. When nothing qualifies the grid is replaced by a line of copy that states no number. The metadata loader behind it gained the two figures that gate makes necessary — completed sessions, counted as held meetings (COMPLETED slots, since an Appointment carries no status of its own and a subscription spans many meetings), and the published-review denominator — and its average now reads `publishedRating` rather than the raw `rating` mean, which every unreviewed profile drags towards its zero default. The landing counter and the trust-badge copy are removed rather than wired to the same loader: that loader is cached on a five-minute window, and Next resolves a route's revalidate to the minimum of the segment value and every data-cache entry read during a render, so reading it on `/` would silently cut that page's one-hour ISR window to five minutes on the surface where LCP matters most. Refs #1485 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(content): every remaining public figure is derived from data or absent (#1490) The sibling of #1485 that its text did not name. The landing hero counted "10,000+ Active Users", "500+ Expert Mentors" and "50,000+ Sessions Completed" and badged "Trusted by 10,000+ professionals worldwide"; each category card claimed a count like "150+ experts"; and /explore/programs fell back to "500+ Classes Available", "200+ Live Webinars" and "25K+ Students Enrolled" — that last one on the data path too, so it was fabricated even when its neighbours were real. None of these were read from anything. The gate that #1485 introduced for /explore/experts now lives in lib/data/public-stats.ts and is shared by all three heroes, so "is this number real?" has one answer rather than one per page. A figure is rendered only when it is derived and meaningful; below that the stat is dropped and the surface says something true without a number. The landing page is wired to its own loader rather than to getExpertsMetadata. That loader is cached for five minutes to match /explore/experts, and Next resolves a route's revalidate to the minimum of the segment value and every data-cache entry read during a render, so reading it on `/` would have cut that page's one-hour ISR window to five minutes. Rather than shorten `/`, the counts it needs are cached at 3600 to match the segment, exactly as every sibling loader in lib/data/home.ts already does, and tagged "experts" so the existing purge at the verify/edit/delete write sites still clears them on demand. `/` is prerendered, so awaiting that one read in the page component costs the build, not the visitor. Programs now counts PUBLIC plans rather than every row including the org-only ones a visitor cannot see, and counts enrolled learners as distinct people holding a CONFIRMED or ATTENDED seat, so a held-but-unpaid seat is not a student and one person across four webinars is one learner. The hero-stats pin is extended to both builders and to every literal removed here. Refs #1490 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(public-stats): empty states drop the publication-cadence claim and the rating gate counts only the reviews behind the number The two hero empty states rendered "New classes and webinars are published every week" / "New experts are joining every week" precisely when there were zero published programs or zero derived stats, which is an unverifiable cadence claim standing in for the number the PR just removed — the same misleading-advertisement exposure, one layer down. Neutral copy instead. The rating aggregate summed `reviewCount` across every verified profile while `_avg` skipped the NULL `publishedRating` of profiles suppressed under #705, so reviews that contributed nothing to the displayed average could still clear the 5-review gate that licenses displaying it. Scoping the aggregate to `publishedRating: { not: null }` makes the denominator the same population as the numerator; it can only withhold the figure, never inflate it. Also extends the fallback-title assertion to both notification payloads, which read the same `resolvedPlanTitle` (#1484). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(public-stats): the public "Average Rating" is the mean over reviews, not over consultants `_avg.publishedRating` gave every consultant one vote, so a five-review newcomer at 5.0 could pull the directory figure as hard as an expert with a hundred reviews at 4.0 — that pair reads 4.5 unweighted and 4.05 weighted, and only the second is a number the platform could defend to a visitor who read the label as "what a session here scores". It is also the same population the 5-review display gate counts, so the figure and its threshold now describe the same thing. Prisma's `_avg` cannot express a weighted mean, so both loaders fetch the gated rows and call one shared `deriveDirectoryRating` in public-stats.ts — the landing hero and the directory cannot drift, which they could while each held its own copy of the aggregate. The row set is the verified directory and both loaders stay cached at their existing windows, so the landing page keeps its 1-hour ISR interval. The maths stays exact; rounding remains a display concern. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…o-show detector instead of closing it first (#1504) (#1505) * fix(booking): auto-complete hands an unattended consultation to the no-show detector instead of closing it first (#1504) The platform's promised full refund for a consultant who never joined could not fire in production. `auto-complete-appointments` runs at :07 with a one-hour buffer and moved every APPROVED/SCHEDULED consultation whose slots had ended to COMPLETED; `detect-consultant-no-shows` runs at :57, reads the same two statuses, and only looks at bookings past a two-hour grace window. The completing job always got there first, so by the time the only job that can cancel and refund a no-show looked, the row was COMPLETED and excluded — the consultee's money stayed with the consultant's earnings. Option (a) from the issue: the completing job now skips a consultation in the no-show shape and leaves it for the detector. The shape itself moves to lib/booking/attendance.ts so the two jobs cannot hold different opinions about the same booking; the detector's own predicate is unchanged and now reads from there, as does NO_SHOW_GRACE_MINUTES. The deferral is bounded by NO_SHOW_HANDOFF_MINUTES (grace plus two hours) so a candidate the detector declined — Stream contradicted our attendance rows, or nobody joined at all — completes rather than sitting live forever: the two sets partition the candidates. Both jobs keep their CAS transitions, lock keys, workflow names and schedules. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(maintenance): the consultant no-show detector counts as a financial job Review triage on #1505. The job cancels and refunds through refundBookingPayment, yet it was missing from FINANCIAL_JOB_NAMES, so DEGRADED maintenance held every other refunding job and let this one keep moving money. The cron reference row now says so too. Closes #1506 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ke the card below it, class cards show their first session, and a trial's Pay Now lands on the branded checkout (#1345, #1346, #1429) (#1514) ## Summary ### #1345 — the Home badge, "Needs you" and the mini list were three scopes on one screen The Pending Requests badge on consultant Home counted every `PENDING` consultation and subscription with no org filter at all, while the "Needs you" card immediately below it and the mini request list on the same screen counted personal (B2C) rows. A consultant who also delivers through an organisation therefore saw up to three different totals for what reads as one cohort. `pendingConsultationWhere` and `pendingSubscriptionWhere` are now exported from `lib/data/needs-you.ts`, and the two badge counts in `lib/data/consultant-dashboard.ts` call them with the personal scope — the same scope `PERSONAL_ORG_PIN` pins the earnings, completion-rate and active-book reads on that page to. Sharing the builder rather than re-typing the filter is what keeps the badge and the card identical. `RequestSlotAllocationTabMini.tsx` now sends `orgScope=personal` explicitly instead of relying on the list API's implicit default, which resolves to personal only for non-privileged callers and left an ADMIN or STAFF consultant looking at an unfiltered list. ### #1346 — a class card showed its authoring window, not its first session `getEventStartDate` in `components/planner/components/EventCard.tsx` read `schedulingPeriodStartsAt` for a class while the webinar arm beside it read the allocated slot. The class arm now reads the earliest live slot on the run's appointments and keeps `schedulingPeriodStartsAt` only as the fallback for a class with no sessions yet. A class fans out over many appointments and the include carries no ordering, so the earliest slot is computed rather than read off position 0. The planner's class query did not actually carry those slots — the consultant arm of `GET /api/bookings/classes` included `appointments: true` — so the include was added there. The issue's "heatmap" half is a **false premise** and was not touched: the only `schedulingPeriodStartsAt` reads in `RequestSlotAllocationTab.tsx` are the subscription window bounds. ### #1429 F2 — a trial's "Pay Now" skipped the branded checkout A priced trial is paid for on our own `/checkout/plans/trial/[trialId]` page, which names the amount and the hold deadline before handing off to the gateway (#1167). That branch existed inline in `AppointmentSheet.tsx`, but the second "Pay Now" entry point added to the appointment detail page by #1428 shipped without it and sent buyers straight to the raw gateway link. The decision now lives once, in `lib/appointments/trial-checkout-href.ts` (`trialCheckoutHref(vm): string | null`), and all three call sites use it: both raw entry points in `AppointmentDetailClient.tsx` (`openPendingPayment` and the "Pay Now to Confirm" button, which now simply calls it) and the sheet. `createHandleCheckoutSuccess` in `app/checkout/plans/utils.ts` gained the `"TRIAL"` arm its message lookup needs, so a trial can never read `undefined.successTitle` once that page mints its own order. ## Files touched | File | Why | | --- | --- | | `lib/data/needs-you.ts` | Export the two pending-request predicate builders. | | `lib/data/consultant-dashboard.ts` | Badge counts call those builders in personal scope. | | `components/dashboard/shared/requests/RequestSlotAllocationTabMini.tsx` | Explicit `orgScope=personal`. | | `components/planner/components/EventCard.tsx` | Class arm reads the first allocated slot. | | `app/api/bookings/classes/route.ts` | Consultant arm selects `appointments.slotsOfAppointment`. | | `lib/appointments/trial-checkout-href.ts` | New shared helper (#1429 F2). | | `components/appointments/AppointmentSheet.tsx` | Uses the helper instead of the inline branch. | | `components/appointments/detail/AppointmentDetailClient.tsx` | Both Pay Now entry points use the helper. | | `app/checkout/plans/utils.ts` | `"TRIAL"` arm on the success toast. | | `__tests__/dashboards/consultant-home-read-shape.test.ts` | The PR's single pin. | | `docs/dashboard/dashboard-architecture-assessment.md` | New "Scope of the Home Counts" section. | | `docs/booking/09-trial-sessions.md` | New "Paying for a trial" section. | ## Verification - `npx jest __tests__/dashboards __tests__/appointments __tests__/plans --coverage=false` → exit 0, **22 suites / 242 tests passed**. - The new pin is non-vacuous: with the badge's pre-fix predicate restored it fails with `Expected: 5, Received: 9`, and passes on the fix. It gives the consultant 3 personal + 5 org-funded pending consultations and 2 personal + 4 org-funded pending subscriptions, then asserts the badge (5) equals the personal "Needs you" context while `needsYou.total` is still 14. - `npx eslint` over all ten changed code files → exit 0, **0 problems**. - `rm tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit` → exit 0, **0 errors** (run again after rebasing onto `a0d6c093b`). - `npx prettier --check` passes on every changed code file. The two docs files were already Prettier-dirty on `dev`, so they were left as they are rather than reformatted wholesale; the added prose is itself Prettier-stable. One unrelated three-line reformat rides in `RequestSlotAllocationTabMini.tsx` (a `Sentry.captureException` call that was over-long on `dev`), because that file has to be Prettier-clean now that it is changed. - No `next dev` / `next build` / `prisma generate` / `db push` was run. ## Not done - **#1429 F1** (the `isTentative: false` mapper) and **#1429 F3** (the slot-picker unions) are out of scope for this PR and stay open on the issue. - **#1346's "heatmap" half** is a false premise, as described above; the issue body should be amended when it is closed. `RequestSlotAllocationTab.tsx` is deliberately untouched. - `docs/booking/05-troubleshooting-and-changelog.md` is deliberately untouched — PR-D of this train writes one consolidated section there. Closes #1345 Closes #1346 Part of #1429 Part of #1433 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…y everywhere, and day segments are half-open (#1343, #1342, #1326, #1348, #1415, #1416) (#1512) ## Summary **The one rule this PR establishes:** `SlotOfAvailabilityWeekly.startDay` is the day the **consultant** published, in their own local calendar, and the UTC weekday is always derived from it through the row's own frozen `utcOffsetMinutes`. Four surfaces had each answered that question for themselves, and the disagreements are the six issues below. | Issue | What was wrong | Fix | | --- | --- | --- | | **#1343** | The settings save path shifted the day forward or back to the UTC day the converted instant landed on, while onboarding stored the local day. An Asia/Kolkata row starting before 05:30 local walked back one weekday on every save — Monday 01:00 saved as Sunday, reloaded into Sunday's form row, saved again as Saturday. | `shiftDayOfWeek` and the `startDayOffset` block are gone. `utils/schedule/formatting.ts` now has one builder, `weeklySlotForSave`, that both save paths use; `formatWeeklySlot` is a thin adapter over it and keeps the #1125 all-or-nothing throw contract. | | **#1342** | The calendar grid bucketed rows on the **viewer's** weekday and did not even select `utcOffsetMinutes`, so an overseas customer was shown an IST pre-05:30 row a day away from the day checkout would accept. | New `utils/schedule/weekly-projection.ts` owns `utcStartDayIndex`, `weeklyRowDurationMinutes` and `weeklyRowOccurrencesInRange` — the single generator the grid, `isMinuteWithinWeeklySlot`, `getNextOccurrenceWeekly` and `matchWeeklySlotToDay` all share. The grid endpoint carries the stored columns through instead of flattening them onto a 1970 reference date, and `processWeeklySlots` loses its `timezone` parameter because display zoning belongs downstream in `splitSlotsByDay`. | | **#1326**, **#1348 item 1** | Onboarding and the per-row PATCH defaulted a consultant with no profile timezone to UTC 0 rather than to the launch offset, and nothing checked a caller who supplied an offset of their own. | New `lib/scheduling/weeklyUtcOffset.ts` is the only answer: derived from `User.timezone`, 330 when the profile carries no usable zone (ADR 17), `WeeklyOffsetConflictError` → 400 `UTC_OFFSET_CONFLICT` when a caller contradicts it, and one Sentry warning per write (never per row) when the consultant publishes from outside Asia/Kolkata. `PIN_TO_LAUNCH_OFFSET` is the one-line switch to hard-pin every row to IST. Onboarding also stamped the offset *after* `mergeAdjacentWeeklyRows`, so that fold's cross-offset guard was comparing `undefined` with `undefined`; it is stamped before the fold now. | | **#1415** | `splitSlotsByDay` cut day segments at `endOfDay` (23:59:59.999). A 23:30–23:59:59.999 remainder is not a thirty-minute atom, so a block published up to local midnight silently lost its last bookable slot on every surface. | Segments are half-open: a segment ends at the next local day's midnight. | | **#1416** | The expert page's display merge carried a 60-second tolerance where booking requires exact adjacency, so the availability card advertised a window whose seam no row publishes and checkout's per-atom union coverage then refused the booking. | `mergeConsecutiveSlotsForDisplay` requires exact adjacency, and its docblock now states the truth: the only difference from `mergeConsecutiveSlots` is which atoms are eligible (same-status versus available-only). | Per **user decision 6 / #872**, all four weekly write paths additionally **dual-write** the five DST columns (`timezone`, `localStartMinutes`, `localEndMinutes`, `localStartDay`, `localEndDay`) from the same resolver, computed after the merge so they describe the row actually stored. Nothing reads them. `coalesceConsultantWeeklyRows` deletes and recreates rows, so it recomputes them for the merged row rather than losing them on the next coalesce. The `schema.prisma` doc-comment changes from "unwritten" to "written from 2026-09-05, read by nothing until the reader flip" — comment only, **no schema change, no `db push` needed**. **No data repair.** The rows the old settings path wrote are pre-MVP mock data; the no-backfill rule applies and the pre-MVP reset wipes them. ### Rebands worth recording - **#1415's headline is false.** `availability-window-merge.test.ts:240-245` already proves a two-hour block yields one two-hour window across rows. The real residual was the `endOfDay` bound described above, and that is what this PR fixes. - **#1348 is a three-item bucket.** Item 1 (the zero-offset default) is fixed here, together with the regression test across the write paths that the issue asks for. Item 2 is explicitly scope-hygiene only and defers to #1206, which is now built. Item 3 asks for three tests, of which only the `mergeAdjacentWeeklyRows` cross-offset bullet is addressed (by the ordering fix above); see follow-ups. ## Files touched **New:** `utils/schedule/weekly-projection.ts`, `lib/scheduling/weeklyUtcOffset.ts`, `__tests__/booking-algorithm/weekly-day-semantics.test.ts`. **Changed:** `utils/schedule/formatting.ts`, `utils/timeSlotsProcessing.ts`, `utils/slotAllocation/slotTimeUtils.ts`, `utils/slotAllocation/SlotAllocationService.ts`, `utils/slotAllocation/mergeAdjacentWeeklyRows.ts`, `utils/onboarding-server.ts`, `app/api/slots/availability-with-allocation/[consultantId]/route.ts`, `app/api/slots/availability/weekly/route.ts`, `app/api/slots/availability/weekly/[id]/route.ts`, `app/api/user/consultants/[id]/route.ts`, `app/explore/experts/[consultantId]/utils/mergeSlots.ts`, `prisma/schema.prisma` (comment only), `__tests__/schedule/format-slots-for-api-throws.test.ts`. **Docs:** `docs/booking/00-architecture-decisions.md` (ADR B4 rewritten, register row and status updated), `docs/booking/02-event-types-and-validation.md`, `docs/booking/03-slot-math-and-calculations.md` (new section "Projecting a weekly row onto real dates"), `docs/booking/19-dst-and-timezone-posture.md` (rules 1–4 corrected — the local columns are written now and the drift warning exists), `.claude/skills/booking/references/availability.md` §1/§2/§4. `docs/booking/05-troubleshooting-and-changelog.md` is deliberately untouched; PR-D writes one consolidated section. ## Verification | Check | Command | Result | | --- | --- | --- | | Types | `rm tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit` | Clean, zero diagnostics (run twice: before and after the Prettier pass). `npx prisma generate` deliberately not run — a sibling worktree owns the shared client. | | Lint | `npx eslint` over all 15 changed/new TypeScript files | **0 errors, 2 warnings**, both pre-existing `no-explicit-any` at `SlotAllocationService.ts:3445` and `:3498` (verified identical on `origin/dev` at lines 3446/3499, only shifted by edits above them). Nothing new introduced. | | Format | `npx prettier --check` over all 21 changed files | All matched files use Prettier code style. | | Tests | `npx jest __tests__/booking-algorithm __tests__/booking __tests__/schedule` | **84 suites passed, 1294 tests passed**, exit 0. | | A12.1 grid ⇔ validator walk | Throwaway script, not committed: every 30-minute atom of the week 2026-09-06 → 2026-09-13 for the IST Monday 01:00–05:00 row (`{MONDAY, 1170–1410, offset 330}`), asserting `weeklyRowOccurrencesInRange` coverage ⇔ `isMinuteWithinWeeklySlot`. | `ATOMS=336 GRID=8 VALIDATOR=8 MISMATCHES=0` — the eight atoms are the four hours the row publishes, and the grid and the validator agree on every one of the 336. | The committed pin, `__tests__/booking-algorithm/weekly-day-semantics.test.ts`, is table-driven, node-environment and Prisma-free, and covers all six required areas: the two save paths agreeing on `startDay` (including re-save idempotency), the offset resolver's table plus a source assertion that the four write paths call `resolveWeeklyUtcOffsetMinutes` and no longer call `getTimezoneOffsetMinutes` directly, grid-equals-validator for the IST pre-dawn row seen from both an `Asia/Kolkata` and an `America/New_York` viewer, the midnight block keeping its 23:30 window, display-merge-equals-booking-merge over gaps of 0/1,000/60,000 ms, and the dual-written local columns. `slotTimeUtils.test.ts`, `availability-window-merge.test.ts`, `availability-grid-conditional-get.test.ts`, `availability-window-scan.test.ts` and `slot-session-fix-pins.test.ts` were run and are green, and were not edited. ## Not done / follow-ups - **Server-side `validateTimeIncrements`** is out of scope. It is a private helper on a separate rejection surface, so the server write paths still do not run it and a minute-level seam remains creatable through the API even though neither merge function will now advertise one. - **#1229** (draft, `fix/explore-booking-responsive`) overlaps this area textually only. Per the guardrail, `ConsultationPricingToggle.tsx`, `ExpertPricing.tsx`, `WeeklyAvailability.tsx` and `CustomAvailability.tsx` are untouched; the only explore file changed here is `utils/mergeSlots.ts`. - **No data repair** for rows already written by the old settings path — pre-MVP mock data, no-backfill rule, the reset wipes them. - **#1348 item 3** still wants two tests this PR does not add: preference-scoring tie-break determinism under equal scores, and confirmation that a manual allocation is not silently overwritten by a later auto pass. Neither is a behaviour change, and both are test-budget items that belong with the allocator work rather than here. - **#872 reader flip** remains post-MVP. This PR only makes the dual-write correct and documents the reader-flip approach in `19-dst-and-timezone-posture.md`. Closes #1343 Closes #1342 Closes #1326 Closes #1348 Closes #1415 Closes #1416 Part of #872 Part of #1433 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…e CAS helpers, and cancelled slots are tombstoned (#1383) ## What Wave-6 PR E (umbrella #1319). Found by the 2026-09-03 production cleanup: a user cancel succeeded and wrote no `BookingStatusHistory` row, and the cancelled slots kept `deletedAt = null`. The two most-used lifecycle routes were moving request, slot and reschedule status with raw `updateMany` calls (status re-checked in the WHERE, so a CAS, but outside the helpers), which is why `BookingStatusHistory` was empty on production. - **Cancel route.** The four request/event writes go through `transitionConsultationRequest` / `transitionSubscriptionRequest` / `transitionWebinarEvent` / `transitionClassEvent` with the route's own from-sets as `fromIn` and the session user as actor; `IllegalTransitionError` maps to the unchanged 409 `NOT_CANCELLABLE`. The three slot writes collapse to one `transitionSlotCompletion` to CANCELLED with `deletedAt`, scoped through `fromIn` (never `completionStatus` in the `where`). The open reschedule proposal is read in the transaction and each is CAS-closed to DECLINED. - **Reschedule route.** The four slot writes go through `transitionSlotCompletion` to RESCHEDULED with `isTentative: true` and deliberately no `deletedAt` (rule 2: a reschedule keeps its rows so a withdrawal can restore them); the four request/event writes go through the helpers; the shared catch answers the unchanged 409 `NOT_RESCHEDULABLE`. `proposedSlots` and `rescheduleRequest.create` are untouched. - **`reschedule-withdraw.ts`.** Its one raw write (RESCHEDULED → SCHEDULED) is now the helper with `allowZero`, which is load-bearing there; its existing helper calls gain the actor. - Slot sweeps pass no `appointmentId` on purpose: a whole-subscription cancel would otherwise file sibling appointments' slot history under the appointment the request arrived on; the request/event transitions pass it explicitly (the override #1378 documents). Raw status writes: cancel route 8 → 0, reschedule route 8 → 0, withdraw 1 → 0. `lib/booking/transitions.ts` is untouched. ## Verification Cold `tsc` clean; `__tests__/booking-algorithm` + `__tests__/booking` + `__tests__/payments` + `__tests__/maintenance`: 129 suites / 1,724 tests green; prettier clean; eslint per-file counts identical to `dev`. Pin extended in the existing `__tests__/payments/cancel-route-refund.test.ts`: a successful consultation cancel writes one CONSULTATION history row (actor, reason, appointment id) plus one per moved slot with `deletedAt`; a lost CAS still answers 409 with zero history rows. Both fail on pre-change code. Rebased onto `dev` at b713e48 (#1378 included); five route assertions loosened to `objectContaining` because #1378 changed the helper's `select`. Commit 1 is format-only (`reschedule-withdraw.ts` was prettier-dirty on `dev`). ## Out of scope, found on the way `transitionSlotCompletion` appends history rows in a sequential loop, one INSERT per slot inside the transaction; a whole-class cancel with many attendees would benefit from a `createMany`. Left for a follow-up on the helper file. Part of #1319. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…l it is confirming, and accept serialises on the appointment lock (#1340) (#1515) ## Summary Issue #1340 is titled as a double-booking race in the reschedule auto-confirm path. That race cannot open: `manualAllocate` writes every slot inside one `prisma.$transaction` under `lockAutoAllocate` plus the consultee lock, with the `slot_no_confirmed_overlap` GiST constraint as the backstop, and `openForAppointmentId` cannot dangle through this path because `DECLINED` is terminal and `transitionRescheduleRequest` clears the reservation on every terminal target. What is live at those exact lines is a different and worse defect, and this PR fixes that. **The real defect.** `SlotAllocationService.resolveConsumedPreferenceRequests` ends every allocating transaction with a supersede sweep that `DECLINE`s each open `RescheduleRequest` whose `releasedSlotIds` intersect the slots being placed. That sweep is right for a consultant who answers a proposal by placing *different* times by hand. It was wrong for the two confirmation callers, which place the proposal's *own* times: - `lib/booking/reschedule-auto-confirm.ts:68` and `lib/booking/reschedule-respond.ts:77` both call `SlotAllocationService.allocate({ mode: "manual", slots: <the proposed times>, wideLock: true })`, and only afterwards CAS the proposal `PENDING_REVIEW → AUTO_ACCEPTED` / `→ ACCEPTED`. - By the time that CAS runs, the sweep has already moved the row to `DECLINED` inside the allocator's transaction, so the `updateMany` matches zero rows and `transitionRescheduleRequest` throws `IllegalTransitionError`. - The booking has moved either way. `reschedule/route.ts:~727` swallows the error and answers `autoConfirmed: false` to a consultee whose session was in fact rescheduled; the explicit accept rethrows, so the consultee gets a **409 on a booking that moved**, and the MOVED notification in `reschedule-respond.ts` is never reached. The audit trail records a refusal for a request that was granted. **The fix.** `AllocationRequest` gains an opt-in `excludeRescheduleRequestId`, threaded through `dispatch` into `manualAllocate` and `autoAllocate` and down to the sweep, whose supersede `WHERE` gains `...(excludeId ? { id: { not: excludeId } } : {})`. Both confirmation callers pass their own proposal id. Nothing else in the allocator moves: locks, TTLs and transaction boundaries are untouched, and an allocation that is not confirming a specific proposal still supersedes every open proposal on those slots exactly as before. **Secondary (the same issue's real concurrency gap).** The accept path took no appointment lock at all. Accept is a lifecycle mutation that moves this appointment's slots, but the allocator's locks are keyed by consultant and by consultee, never by appointment, so an accept and a concurrent cancel of the same booking never contended for anything. `respond/route.ts` now wraps `acceptProposal` in `withAppointmentLock`, the same `appointment-lock:` atom the cancel and reschedule routes take, and maps `AppointmentBusyError` to 423 and `BookingLockUnavailableError` to 503 exactly as `reschedule/route.ts` does. Lock order is unchanged — the appointment atom is the coarsest key and is taken before the allocator acquires its own. Decline is deliberately left outside the lock: it moves nothing. ## Files touched | File | Change | |---|---| | `utils/slotAllocation/types.ts` | `AllocationRequest.excludeRescheduleRequestId?: string` with the why-comment. | | `utils/slotAllocation/SlotAllocationService.ts` | Threads the id through `dispatch` → `autoAllocate` / `manualAllocate` → `resolveConsumedPreferenceRequests`; the supersede `WHERE` gains `id: { not: … }`. | | `lib/booking/reschedule-auto-confirm.ts` | Passes `excludeRescheduleRequestId: request.id`. | | `lib/booking/reschedule-respond.ts` | Passes `excludeRescheduleRequestId: request.id`. | | `app/api/appointments/[appointmentId]/reschedule/respond/route.ts` | Accept runs inside `withAppointmentLock`; 423 / 503 lock answers. | | `__tests__/booking-algorithm/reschedule-respond.test.ts` | The pin's caller half (+ the lock case). | | `__tests__/booking-algorithm/preference-scored-allocation.test.ts` | The pin's allocator half. | | `docs/booking/07-rescheduling-flow.md`, `docs/booking/18-state-machines.md` | The confirmation sequence and the exclusion rule. | ## Verification | Check | Result | |---|---| | `npx prettier --check` on every changed file | The five code files and `reschedule-respond.test.ts` are clean. `lib/booking/reschedule-respond.ts`, `preference-scored-allocation.test.ts` and both docs still warn, and every remaining hunk is byte-identical to `origin/dev` (pre-existing wrapping in the big `select` payloads, the two markdown tables and unrelated object literals); no added line is unformatted. | | `npx eslint` on the changed files | 0 errors, 3 warnings — all three are the pre-existing `no-explicit-any` at `SlotAllocationService.ts` dev-lines 3446/3499 and `types.ts` dev-line 147, shifted by this diff and otherwise identical. | | Cold `tsc --noEmit` (`tsconfig.tsbuildinfo` deleted, 8 GB heap) | exit 0, after the rebase onto `73c0d9f2c`. | | `npx jest __tests__/booking-algorithm __tests__/booking __tests__/payments/cancel-route-refund.test.ts` | exit 0 — **72 suites, 1132 tests passed**, re-run after the rebase. | **The pin fails without the fix.** Reverting the five production files to `origin/dev` and re-running the two suites fails 5 assertions: ``` ● #1340 — resolveConsumedPreferenceRequests and the confirming proposal › declines a different stale proposal on the same slots but never the excluded one ● accept re-validates through the allocator before anything is written › sends the proposed times through manual allocation under the wide lock ● #1340 — a confirmation keeps the proposal it is confirming › accept names its own proposal to the allocator and then closes it ACCEPTED ● #1340 — a confirmation keeps the proposal it is confirming › auto-confirm names its own proposal to the allocator and then closes it AUTO_ACCEPTED ● the respond route drives the loop for the counterparty › serializes the accept on the appointment lock and answers 423 while it is held Tests: 5 failed, 55 passed, 60 total ``` Reverting only the supersede `WHERE` (the one-line heart of the fix) fails the allocator case on its own. That case drives the real `resolveConsumedPreferenceRequests` against a transaction stub that answers `findMany` the way Postgres would, so the assertion rides on the `WHERE` the sweep actually issues rather than on a stubbed outcome: with an exclusion in flight only the *other* stale proposal is declined, and with no exclusion both still are. ## Not done - `app/api/appointments/[appointmentId]/reschedule/route.ts` is untouched: PR #1383 owns that file. Its auto-confirm call at `:~717` therefore still runs **outside** `withAppointmentLock`, so the lock-scope half of the auto-confirm path stays as it is. Worth a follow-up once #1383 lands; the correctness fix above does not depend on it. - The #1497 limiter rider was not taken. - No `db push`, no schema change, no notification-workflow change. Closes #1340 Part of #1433 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…rg tiers, and a credit-funded partial cancel restores the credit in full (#1499, #1500, #1372) (#1513) ## Summary **The model (#1499).** Refund terms move out of the `Appointment.cancellationPolicySnapshot` Json column and into typed, versioned rows. `CancellationPolicy` is one published version of a ladder — its scope, version, status, and the percentage a consultant-initiated cancellation settles at — and `CancellationPolicyTier` holds its rungs. `Appointment.cancellationPolicyId` points at the exact row that governed the sale. A published version is **immutable**: editing a ladder archives the current `ACTIVE` row and inserts a new one at the next version, so the freeze the old snapshot provided by convention is now structural, and no code path can rewrite terms a buyer already agreed to. **Resolution.** Checkout resolves the governing version exactly once, inside the booking transaction, through `resolveCheckoutCancellationPolicyId()`. An organisation's ladder governs the bookings the organisation **funds** — on a refund it is the organisation's money that moves — so the organisation id is passed only on the sponsored path; a personal booking merely tagged to an organisation keeps the platform ladder, as does an organisation that has never published. Sessions allocated later against a subscription **inherit** the version from the row checkout created rather than resolving afresh, which is the whole point of versioning. The platform default lives at a fixed id, is seeded, and is also provisioned idempotently by `ensurePlatformCancellationPolicy()` so a database nobody seeded cannot fail a checkout. **The credit rule (#1500).** The credits rail cannot pay a fraction — `refundBookingPayment` refuses an `amountPaise` on a `free_` intent — so a partial tier previously had nothing it could pay and escalated to a human. That escalation is replaced by a rule with two halves: - **Any tier above 0% restores the credit IN FULL**, via `refundBookingPayment` with no `amountPaise`. The buyer gave the notice the ladder rewards, and the rail's inability to divide should not cost them the refund. - **A 0% tier restores NOTHING**, falling through to the existing `POLICY_ZERO` arm. A late cancel bites a credit buyer exactly as it bites a card buyer; paying a full credit back for a cancellation that earns a card buyer nothing would make free credit strictly better than money and delete the late-cancel deterrent. The whole rule is one predicate in `quoteBookingRefund` — `isFreeCreditFunded && refundPct > 0` — surfaced as `creditRestoresInFull`. `MANUAL_REVIEW` is gone from the route, the response union, the client and the docs. ## Schema Additive only. - **`CancellationPolicy`** — `organizationId` (null = the platform default), `version`, `status` (`CancellationPolicyStatus`: `ACTIVE` | `ARCHIVED`), `consultantInitiatedBps`, `policyText`, `publishedByUserId`, `createdAt`, `archivedAt`. `@@unique([organizationId, version])`, `@@index([organizationId, status])`. - **`CancellationPolicyTier`** — `policyId`, `hoursBefore`, `refundBps`. `@@unique([policyId, hoursBefore])`, `@@index([policyId])`. - **`Appointment.cancellationPolicyId String?`** — FK at `onDelete: SetNull`, plus `@@index([cancellationPolicyId])`. `SetNull` because a booking must survive its organisation being torn down; losing the pointer degrades to the platform ladder rather than deleting money history. Null and "sold before this change" are deliberately indistinguishable. - **`Appointment.cancellationPolicySnapshot Json?`** stays, **frozen**: never written, never read, annotated as such, dropped at the pre-MVP reset. A column a running deploy still reads must not be dropped under it, and this repo writes no backfill migrations. - Back-relations on `Organization` and `User`; enums declared below the models that use them. - **Staged, commented** in `prisma/sql/check-constraints.sql`: the `NULLS NOT DISTINCT` partial unique for "one ACTIVE version per scope" and for `(organizationId, version)`. Postgres treats null keys as distinct, so the platform row escapes the Prisma `@@unique` entirely. These stay commented because `check-db-sidecars` strips comments and would demand an unapplied index. Until they land, one active version per scope is enforced by the Serializable rotation in `publishOrgCancellationPolicy()`, and readers order `version desc` and take one so a slip degrades to "newest wins". **This PR requires `npm run db:push` by the orchestrator after merge**, followed by `npm run db:assert-sidecars`. CI's `check-db-drift` skips new enums and tables, so CI is green before the push; the seed and manual QA need the push first. ## Files touched **Schema and seed** — `prisma/schema.prisma`, `prisma/sql/check-constraints.sql`, `prisma/seed.ts`, `prisma/seedFiles/16b-create-cancellation-policy.ts` (new). **Money core** — `lib/payments/operations/cancellation-policy.ts` (stays Prisma-free: `RefundTier`, `CancellationPolicyTerms`, `PLATFORM_DEFAULT_TERMS`, `tiersFromBps`, `validateTierLadder`, `computeRefundPct`, `quoteBookingRefund`), `lib/payments/operations/cancellation-policy-store.ts` (new: the one select shape, the platform provisioner, the resolver, the publish routine), `lib/payments/operations/checkout.ts`, `utils/slotAllocation/SlotAllocationService.ts`. **The six readers** — `lib/booking/cancellation-scope.ts`, `lib/booking/rejection-refund.ts`, `lib/trials/cancellation.ts`, `lib/payments/operations/event-refunds.ts`, `lib/support/context.ts`, and the two cancel routes (`app/api/appointments/[appointmentId]/cancel/route.ts`, `.../cancel/preview/route.ts`). `parsePolicySnapshot` and `resolveCancellationPolicySnapshot` are deleted; no reader consults the Json column. **Org editor** — `app/api/organizations/[orgId]/cancellation-policy/route.ts` (new; GET gated on `settings.manage`, PUT on `minimumRole: "OWNER"`, no PATCH or DELETE because a version is immutable), `app/dashboard/organization/[orgId]/settings/CancellationPolicyCard.tsx` (new) wired from `GeneralPanel.tsx` behind `isAtLeast("OWNER")`, `lib/enterprise/audit-actions.ts`. **Client** — `components/appointments/consultee/useEventActions.ts`. **Tests** — `__tests__/enterprise/multi-engagement-cap.test.ts` (#1372's three cases, in the existing file), `__tests__/booking/cancellation-policy.test.ts`, `__tests__/payments/{cancel-route-refund,refund-preview-parity,attendee-removal-refund,trial-cancellation-refund,rejection-refund,consultation-atom-parity,checkout-open-order-reuse,allocation-utilization-integrity}.test.ts`, `__tests__/booking-algorithm/{cancellation-scope,allocation-top-up,row-walk-truncation,collaborator-availability-modes,slotAllocationService}.test.ts`. **Docs** — `docs/booking/08-cancellation-flow.md`, `docs/booking/17-org-funded-checkout.md`, `docs/enterprise/70-design-decisions/28-typed-versioned-cancellation-policy.md` (new) + its index row in `00-README.md`, `.claude/skills/booking/references/money-boundary.md`. ## Verification | Check | Result | | --- | --- | | `npx prisma generate` then cold `npx tsc --noEmit` (`tsconfig.tsbuildinfo` deleted, 8 GB heap) | **exit 0**, no errors | | `npx eslint` on all 33 changed code files | **0 errors, 0 warnings introduced**. 1 error + 44 warnings remain, all byte-identical at `origin/dev`: `jest/no-mocks-import` and 32 `no-explicit-any` in `slotAllocationService.test.ts`, 9 `no-explicit-any` in `checkout-open-order-reuse.test.ts`, 1 `eqeqeq` in `checkout.ts`, 2 `no-explicit-any` in `SlotAllocationService.ts`. The other 29 files are clean. | | `npx prettier --check` on every changed file | clean. The 10 files my edits drifted were verified clean at baseline first, then formatted. | | `npx prisma format` | no change; schema already formatted | | `npx jest __tests__/booking __tests__/payments __tests__/enterprise __tests__/booking-algorithm/cancellation-scope.test.ts` | **exit 0** — 227 suites passed, 2440 tests passed | The five allocation suites initially failed (60 tests) because `createAppointments` now reads `tx.appointment.findFirst` to inherit the policy and their transaction mocks did not define it. Fixed by adding `findFirst: jest.fn().mockResolvedValue(null)` to those five mocks — mock plumbing, not a semantic change. ## Limitations / not done - **Org-funded event seats fall back to the platform ladder.** One shared `Appointment` row serves every registrant of a webinar or class, across every funding organisation, so it cannot carry one buyer's terms; its FK stays null. Whole-event refunds already assumed the platform ladder, so this is consistent, but organisation tiers genuinely do not reach event seats. Reaching them means moving the terms onto the participant row — worth a follow-up issue if it is wanted. - **A `free_` intent with a non-zero amount is out of scope.** That is a mixed payment; it takes the money arm and is refused `INVALID_AMOUNT`, exactly as today. Both halves of the `isFreeCreditFunded` predicate are load-bearing for this reason. - **Merge order: land #1383 first.** It rewrites the status writes in the cancel transaction body (~`:39-447`). This PR touches only the import block and the post-transaction refund block, so expect at most one import-block conflict. - **`docs/booking/05-troubleshooting-and-changelog.md` is deliberately untouched.** Its line about "the only surviving MANUAL_REVIEW path" is now stale and is amended by PR-D's consolidated changelog pass. - No end-to-end run: that is the orchestrator-announced round, and it needs the schema pushed first. Closes #1499 Closes #1500 Closes #1372 Part of #1503 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…nance, no-show cancels write history, and the doctrine text matches the sweeps (#1506) (#1516) ## Summary 1. **`expire-stale-requests` joins `FINANCIAL_JOB_NAMES`.** Its `expirePaymentPendingRequests`/`expireApprovedUnallocatedSubscriptions` passes call `refundPaymentsForExpired`, a refund front-door caller like every other job already in the set, so DEGRADED maintenance now holds it with the rest. `detect-consultant-no-shows` was already there via #1505. 2. **A registry pin gates every future refund-front-door caller, not just today's two.** `__tests__/maintenance/cron-lock-registry.test.ts` gains one assertion that greps `scripts/**/*.ts` for callers of `refundBookingPayment(`, `refundWholeEventPayments(`, `refundRemovedAttendeeSeat(`, and `refundPaymentsForExpired(`, and asserts each caller's `withCronLock` name is in `FINANCIAL_JOB_NAMES`. 3. **Two more money-twin routes drop their `status: () => 200` override.** `app/api/cleanup/process-payouts/route.ts` and `.../sweep-abandoned-overage-charges/route.ts` now fall through to `cleanup-route.ts`'s default `statusFor`, which reads `result.success`, mirroring `release-earnings`/`sync-payment-earnings` under #1390. 4. **The no-show cancel writes a `BookingStatusHistory` row.** `claimConsultantNoShow` in `scripts/appointments/detect-consultant-no-shows.ts` now runs the CANCELLED transition through `transitionConsultationRequest` inside `prisma.$transaction`, instead of a bare `consultation.updateMany`. The zero-row "someone else moved it" outcome is preserved via `IllegalTransitionError` catch. Candidate query, grace/handoff constants, refund call and notifications are untouched. 5. **Doctrine text corrected in `.claude/skills/booking/SKILL.md`.** Rule 2 no longer claims `expire-stale-requests.ts`/`cleanup-tentative-slots.ts` hard-delete tentative holds (fixed by #1380/#1424's soft-cancel via `transitionSlotCompletion`). Rule 5 no longer names `expirePaymentPendingRequests` as the doctrine's counter-example (fixed by #1423's CAS + money-predicate rewrite). 6. **`docs/booking/18-state-machines.md`'s reschedule section corrected.** `COUNTERED` is noted as an unreachable enum edge with no writer (the counter-round was removed per `lib/booking/reschedule-proposals.ts`), and `AUTO_ACCEPTED` is documented as the second terminal-acceptance state. 7. **Glossary linked.** `docs/booking/README.md` links `docs/enterprise/00-foundations/07-slots-sessions-glossary.md` under Core Concepts. 8. **DEGRADED gate noted in both cron references.** `docs/booking/13-cron-jobs-and-background-tasks.md`'s Safety paragraphs and `docs/maintenance/04-cron-jobs-reference.md`'s table rows for both jobs now say they are held during DEGRADED as well as OFFLINE. 9. **Consolidated train changelog.** One new `## Changelog: 2026-09-05 — booking closure train` section in `docs/booking/05-troubleshooting-and-changelog.md`, with one subsection per train PR (#1512, #1513, #1514, #1515, this PR), written from each PR's merged/open body. Also fixes the stale "only surviving `MANUAL_REVIEW` path" sentence that #1513 obsoletes. ## Files - `lib/maintenance-cron.ts` - `app/api/cleanup/process-payouts/route.ts` - `app/api/cleanup/sweep-abandoned-overage-charges/route.ts` - `scripts/appointments/detect-consultant-no-shows.ts` - `__tests__/maintenance/cron-lock-registry.test.ts` - `__tests__/booking/no-show-refund-front-door.test.ts` - `__tests__/maintenance/no-show-auto-complete-handoff.test.ts` - `.claude/skills/booking/SKILL.md` - `docs/booking/18-state-machines.md` - `docs/booking/README.md` - `docs/booking/13-cron-jobs-and-background-tasks.md` - `docs/maintenance/04-cron-jobs-reference.md` - `docs/booking/05-troubleshooting-and-changelog.md` ## Verification | Check | Result | | --- | --- | | `rm tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit` (after rebase onto `01a377342`) | exit 0, no errors | | `npx eslint` on all 7 changed/new code files | 0 problems | | `npx prettier --check` on all 7 changed/new code files | clean | | `npx prettier --check` on the 6 changed docs files | 5 clean; `docs/booking/18-state-machines.md` was already Prettier-dirty on `origin/dev` (unpadded tables and a wrapped bullet outside the section I touched) and is left as-is per the existing project pattern (see #1514's note on the same posture); the lines I added are themselves Prettier-clean | | `npx jest __tests__/maintenance __tests__/booking __tests__/appointments` (after rebase) | exit 0 — **87 suites, 1348 tests passed** | | Two suites mocked Prisma without `$transaction` (`__tests__/booking/no-show-refund-front-door.test.ts`, `__tests__/maintenance/no-show-auto-complete-handoff.test.ts`) | extended the mocks with `$transaction`, `consultation.findUnique`, and `bookingStatusHistory.create` rather than weakening any assertion | ## Not done - None of the six numbered spec items were skipped. Closes #1506 Part of #1338 Part of #1493 Part of #1420 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…uteRaw so nothing deserialises a void column (#1518) (#1519) `guardInitialAllocationInTx` took the per-event lock with `tx.$queryRaw`SELECT pg_advisory_xact_lock(...)``. That function returns `void`, and under the Prisma 7 driver adapter the result row carries a column of Postgres type `void` that the adapter cannot deserialise, so the call threw `Failed to deserialize column of type 'void'` before the allocation began. Every path through the guard failed: manual allocation from the request calendar, the requested/approve path, and the reschedule allocation. Sentry FAMILIARISE_WEB-2W caught it on the dev deploy and the same code is on prod. `$executeRaw` reads only the affected-row count, so the void column is never deserialised. The key expression, the transaction scope and the idempotency replay immediately after the lock are unchanged. A repo-wide grep for other raw statements selecting a void-returning function (`pg_advisory_lock`, `pg_advisory_xact_lock`, `pg_notify`, functions declared `RETURNS void`) found no other occurrence: the only other `RETURNS void` function, `assert_payment_legs_ok`, is invoked by triggers rather than from application code. The allocation suites mock the transaction client, so the six tx mocks that stubbed `$queryRaw` for this lock now stub `$executeRaw`, and one new pin asserts the guard issues the lock through `$executeRaw` with the `initial-allocation:<type>:<id>` key, never touches `$queryRaw`, and carries on to the confirmed-slot count. Refs #1518 Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…m diagnostics, Razorpay CSP, planner class dates (#1520) ## Summary > **Update 2026-09-06:** Fix 1 (the `$executeRaw` advisory lock) landed on dev independently as #1519 while this PR was in review; this branch now carries dev's version of that change, and the PR's own contribution is fixes 2–4 (auto-confirm reason in the response, CSP frame host, planner `firstSessionAt`). **Fix 1 — P0: initial-allocation advisory lock throws under Prisma 7.** `utils/slotAllocation/SlotAllocationService.ts` (`guardInitialAllocationInTx`) called `tx.$queryRaw` on `pg_advisory_xact_lock(...)`, which returns `void`. Prisma 7's driver-adapter client fails to deserialize a void column, so every fresh manual allocation and every reschedule auto-confirm/accept died there (Sentry FAMILIARISE_WEB-2W, seen on `POST /api/appointments/[appointmentId]/reschedule` → `manualAllocate` → `guardInitialAllocationInTx`). Switched to `$executeRaw`, which does not attempt to deserialize result columns — same SQL, same parameter, the transaction-scoped lock is still taken. Grepped `app lib utils scripts jobs` for other `$queryRaw` calls whose SELECT list is only a void-returning function (`pg_advisory_*`, `set_config`, `pg_notify`): none found. The other `$queryRaw` hits (`scripts/ci/check-db-drift.ts`, `check-db-sidecars.ts`, `assert-sidecar-applied.ts`) all select real columns, and the `$executeRawUnsafe` hits (`scripts/db/apply-*.ts`) already use the correct method for DDL. **Fix 2 — surface the swallowed auto-confirm reason.** `app/api/appointments/[appointmentId]/reschedule/route.ts` discarded `outcome.reason` (e.g. `VALIDATION_FAILED`, `APPOINTMENT_BUSY`, an allocator `errorCode`) when `tryAutoConfirmProposal` declined, which made this incident un-diagnosable from the response. Now captures `autoConfirmReason`, includes it in the JSON response next to `autoConfirmed`, logs one `console.info({ event: "reschedule_auto_confirm", ... })` per request, and sets `autoConfirmReason` to `"LOST_RACE"`/`"ERROR"` in the catch arm so a thrown error is visible too. This is exactly the failure mode found during the E2E pass: reschedule auto-confirm returned `false` on green slots for a CUSTOM-schedule consultant, the offline allocator validator passed independently, and Sentry showed the void-deserialization error — the two fixes together make that failure both non-fatal and diagnosable. **Fix 3 — CSP `frame-src` host.** `next.config.mjs` was missing `https://api.razorpay.com` in `frame-src`; the live Razorpay checkout iframe is served from that host, and a report-only violation was observed on the dev deploy during a test checkout. Added the host and a line to the gateway-rationale comment block. The policy is report-only until `ENABLE_CSP_ENFORCE`, so this is a no-risk correction. **Fix 4 — planner class cards show their first session (#1346 follow-through).** The planner's `classInclude` windows `appointments.slotsOfAppointment` to ±24h of now for the Join affordance, so a class whose sessions all fall outside that window arrived with zero slots and the card fell back to `schedulingPeriodStartsAt`. Added one batched, unwindowed `slotOfAppointment.groupBy` query after the class rows load, attaching `firstSessionAt` (earliest live slot across all of a class's appointments) to each class event. `EventCard.tsx`'s `getEventStartDate` now reads `firstSessionAt` first, falling back to the existing earliest-live-slot reduction (still valid for the `/api/bookings/classes` consumer, which is unwindowed) and then `schedulingPeriodStartsAt`. The window itself is untouched — it still exists to bound the Join payload. ## Files - `utils/slotAllocation/SlotAllocationService.ts` - `app/api/appointments/[appointmentId]/reschedule/route.ts` - `next.config.mjs` - `app/api/dashboard/consultant/[consultantId]/planner/route.ts` - `types/planner-events.ts` - `components/planner/components/EventCard.tsx` - `__tests__/booking-algorithm/{allocation-top-up,collaborator-availability-modes,expected-tentative-count,initial-allocation-guard,row-walk-truncation,slotAllocationService}.test.ts` - `__tests__/dashboards/planner-class-join-payload.test.ts` ## Verification - `rm tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit` → clean, 0 errors. - `npx eslint <changed files>` → 0 problems on every file except `__tests__/booking-algorithm/slotAllocationService.test.ts`, whose single pre-existing `jest/no-mocks-import` error was confirmed byte-identical to `origin/dev` (only the touched `$queryRaw`→`$executeRaw` line differs in that file). - `npx prettier --check <changed files>` → clean (2 files auto-formatted before commit). - `npx jest __tests__/booking-algorithm __tests__/booking __tests__/payments/cancel-route-refund.test.ts __tests__/dashboards/planner-class-join-payload.test.ts` → **74 suites / 1181 tests, all passed.** - `npx jest __tests__/booking-algorithm/reschedule-preference-route.test.ts` re-run after adding `autoConfirmReason` to the response → 6/6 passed. Fixes FAMILIARISE_WEB-2W Part of #1346 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
✅ 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: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
|
|
SonarCloud gate on this release PR: accepted as red. The gate fails only on |



Summary
Release of the 2026-09-05/06 booking backlog closure train plus the fixes found by the end-to-end pass on the dev deploy. Base: prod at 576b97c; head: dev at 9b45a82.
Schema
Additive only, already pushed to the shared database on 2026-09-06 with sidecars applied and asserted (45 constraints / 6 indexes / 3 triggers):
CancellationPolicy,CancellationPolicyTier,Appointment.cancellationPolicyId(+ index), enumCancellationPolicyStatus. The platform default policy row is provisioned. Backup:familiarise-pre-schema-push-20260906-0423.dump. No push is needed for this release.Commits (dev not in prod)
Verification
firstSessionAt2026-09-10T10:00Z, card 3:30 PM IST); a consultee proposal on a green slot auto-confirms (autoConfirmed: true, requestAUTO_ACCEPTED, slots moved, history rows PENDING → RESCHEDULED ×2 → APPROVED → AUTO_ACCEPTED).Owner items unchanged by this release
🤖 Generated with Claude Code
https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1