Skip to content

fix(booking): Pre-MVP algorithm integrity — #1071 #1012 #1005 - #1091

Merged
teetangh merged 6 commits into
devfrom
fix/booking-algorithm
Aug 1, 2026
Merged

fix(booking): Pre-MVP algorithm integrity — #1071 #1012 #1005#1091
teetangh merged 6 commits into
devfrom
fix/booking-algorithm

Conversation

@teetangh

@teetangh teetangh commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #1071
Closes #1012
Closes #1005
Closes #1003
Closes #1004
Closes #834
Part of #676
Part of #837
Part of #997
Part of #1072

Test plan

  • __tests__/booking-algorithm/contiguous-slot-run.test.ts
  • __tests__/booking-algorithm/expected-tentative-count.test.ts
  • __tests__/booking-algorithm/consultee-affordances.test.ts
  • __tests__/booking-algorithm/server-status-grid.test.ts
  • Planner: create 2h webinar → 4 slot rows; reschedule → all four move as one run
  • Reschedule allocate from two tabs → second gets 409 after first completes
  • Consultee webinar card: Leave event (no Reschedule/Cancel booking); trial Cancel hits /api/trials
  • Existing cancel/reject refund suites still green in CI

Made with Cursor

Summary by CodeRabbit

  • New Features
    • Consultees can leave eligible classes and webinars directly, with clearer confirmation dialogs.
    • Rescheduling is available for consultations and subscriptions, with actions tailored by appointment type.
    • Class and webinar bookings now use reliable contiguous time slots.
  • Bug Fixes
    • Prevented stale rescheduling pages from overwriting newer booking changes.
    • Improved weekly subscription-limit calculations and slot-update participant preservation.
    • Added attendee-aware refund handling and improved notification display.
    • Enforced 30-minute increments for class durations.
  • Documentation
    • Updated booking, rescheduling, cancellation, and troubleshooting guidance.

Canonical N×30min planner runs (#1071), expectedTentativeSlotCount stale-tab
guard (#1012), consultee kind-gates/self-leave/trial cancel (#1005), and

Co-authored-by: Cursor <cursoragent@cursor.com>
#997 Phase 3 weekly-limit parity. Closes already-fixed money paths via PR keywords.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

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

@netlify

netlify Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit 717e55e
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a6e2a06d7e2180008ab49bb
😎 Deploy Preview https://deploy-preview-1091--familiarise.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

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

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

@dosubot

dosubot Bot commented Aug 1, 2026

Copy link
Copy Markdown

📄 Knowledge review

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


Leave Feedback Ask Dosu about familiarise_web Add Dosu to your team

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@teetangh, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 08cfed78-be15-4948-9dd3-5960383474f3

📥 Commits

Reviewing files that changed from the base of the PR and between d5c0bf7 and 717e55e.

📒 Files selected for processing (11)
  • __tests__/booking-algorithm/contiguous-slot-run.test.ts
  • __tests__/booking-algorithm/expected-tentative-count.test.ts
  • app/api/bookings/classes/crud-with-plan/route.ts
  • app/api/bookings/webinars/crud-with-plan/route.ts
  • app/api/participants/class/[classId]/route.ts
  • app/api/participants/webinar/[webinarId]/route.ts
  • components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx
  • docs/booking/05-troubleshooting-and-changelog.md
  • lib/appointments/live-event-slot.ts
  • lib/payments/operations/event-refunds.ts
  • utils/slotAllocation/SlotAllocationService.ts
📝 Walkthrough

Walkthrough

The pull request adds contiguous slot-run persistence, stale-tab allocation protection, server-confirmed subscription counts, appointment-kind consultee actions, group-event self-leave, initiator-specific refunds, and notification inbox layout updates.

Changes

Booking lifecycle

Layer / File(s) Summary
Contiguous slot run persistence
lib/appointments/contiguous-slot-run.ts, app/api/bookings/classes/..., app/api/bookings/webinars/..., lib/appointments/slots.ts, schemas/plans.ts, __tests__/booking-algorithm/contiguous-slot-run.test.ts, docs/booking/03-slot-math-and-calculations.md
Class and webinar appointments now use validated contiguous 30-minute slot atoms. Webinar PATCH reconciles the complete live run and preserves overlapping slot records and users.
Stale allocation protection
schemas/slotAllocation/validationSchemas.ts, lib/scheduling/..., utils/slotAllocation/..., hooks/scheduling/useSlotAllocation.ts, components/scheduling/..., app/api/bookings/*/allocate/route.ts, components/dashboard/shared/requests/RequestSlotAllocationTab.tsx, __tests__/booking-algorithm/expected-tentative-count.test.ts
Allocation requests can include expectedTentativeSlotCount. Services compare it with the current tentative-slot count before mutation. Subscription checks also include server-confirmed weekly call counts.
Consultee actions, self-leave, and refunds
lib/appointments/consultee-affordances.ts, components/appointments/consultee/..., app/api/participants/..., lib/payments/operations/event-refunds.ts, __tests__/payments/attendee-removal-refund.test.ts, __tests__/booking-algorithm/consultee-affordances.test.ts
Rescheduling and destructive actions are gated by appointment kind. Trials can be cancelled. Webinar and class consultees can leave themselves before the event starts. Refunds identify attendee-initiated departures.
Booking documentation and notification layout
docs/booking/..., components/notifications/NotificationInbox.tsx
Documentation describes slot runs, stale-tab conflicts, action restrictions, self-leave refunds, and the August 2026 changes. The notification inbox uses a fixed flex layout with separate scrolling areas.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SlotPicker
  participant AllocationRoute
  participant SlotAllocationService
  participant BookingDatabase
  SlotPicker->>AllocationRoute: submit expectedTentativeSlotCount
  AllocationRoute->>SlotAllocationService: allocate with precondition
  SlotAllocationService->>BookingDatabase: count tentative slots
  BookingDatabase-->>SlotAllocationService: return current count
  SlotAllocationService-->>AllocationRoute: continue or return 409 conflict
Loading
sequenceDiagram
  participant ConsulteeAppointmentsAdapter
  participant ParticipantRoute
  participant BookingDatabase
  ConsulteeAppointmentsAdapter->>ParticipantRoute: submit authenticated self-leave
  ParticipantRoute->>BookingDatabase: remove consultee from event roster
  ParticipantRoute->>BookingDatabase: calculate attendee-initiated refund
  ParticipantRoute-->>ConsulteeAppointmentsAdapter: return result and invalidate queries
Loading

Possibly related issues

Possibly related PRs

Poem

A rabbit checks each slot in line,
Half-hour atoms now align.
Stale tabs pause before they stray,
Consultees leave before event day.
Refunds record who chose to part,
Booking runs keep every chart.

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Core objectives are addressed [#1071] [#1012] [#1005], but refund, rejection, and waitlist requirements are not shown [#1003] [#1004] [#834]. Implement group-event refunds and notifications, consultant-rejection refunds, and transactional or CAS waitlist assignment with atomic enrollment, or unlink these issues.
Out of Scope Changes check ⚠️ Warning The NotificationInbox popover styling change is unrelated to the linked booking objectives. Remove the NotificationInbox changes or move them to a separate pull request.
Docstring Coverage ⚠️ Warning Docstring coverage is 38.10% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the booking algorithm integrity work and references the primary related issues.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/booking-algorithm

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

❤️ Share

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

Co-authored-by: Cursor <cursoragent@cursor.com>
@teetangh teetangh self-assigned this Aug 1, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/participants/class/[classId]/route.ts (1)

136-160: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Extract the self-leave/organiser authorization pattern into a shared helper.

Both routes implement the same two-stage authorization: a coarse self-leave-or-organiser gate, then a Prisma lookup that applies an ownership filter only for non-self-leave, non-privileged callers. Because this logic is security-relevant, keeping it in two files risks divergence if one route is fixed or extended later without updating the other.

  • app/api/participants/class/[classId]/route.ts#L136-L160: replace the inline isSelfLeave/isOrganiser gate and classEvent ownership lookup with a shared helper, for example authorizeParticipantRemoval({ session, userId, planOwnerFilter: { consultantProfileId: ... } }), returning either a forbidden response or the resolved authorization mode.
  • app/api/participants/webinar/[webinarId]/route.ts#L131-L155: call the same shared helper with the webinar plan's ownership filter instead of duplicating the gate and lookup logic.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/participants/class/`[classId]/route.ts around lines 136 - 160, The
duplicated self-leave/organiser authorization should be centralized in a shared
authorizeParticipantRemoval helper. In
app/api/participants/class/[classId]/route.ts lines 136-160, replace the
isSelfLeave/isOrganiser gate and classEvent ownership lookup with the helper
using the class plan owner filter; in
app/api/participants/webinar/[webinarId]/route.ts lines 131-155, make the
equivalent helper call with the webinar plan owner filter. Preserve the helper’s
forbidden response and resolved authorization mode for each route.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@__tests__/booking-algorithm/contiguous-slot-run.test.ts`:
- Around line 78-155: Extend the contiguous-slot-run test suite with coverage
for replaceContiguousSlotRun using a stub transaction client. Assert that
CANCELLED and RESCHEDULED rows are preserved, live rows are deleted, and every
recreated atom retains the previously connected user IDs; include data that
exercises the notIn NULL case in replaceContiguousSlotRun.
- Around line 35-42: Remove the conditional adjacency assertion from the
existing atom timing loop. Add a separate loop beginning at index 1 to assert
each atom’s startsAt matches the previous atom’s endsAt, while preserving the
existing per-atom start and end assertions.

In `@app/api/bookings/webinars/crud-with-plan/route.ts`:
- Around line 276-298: Export a shared buildSlotCreatePayloads helper from
contiguous-slot-run.ts that converts buildContiguousSlotAtoms results into
Prisma slot-create payloads, stripping user and assigning the owner
consultantProfileId. Replace the duplicated inline mappings at
app/api/bookings/webinars/crud-with-plan/route.ts lines 276-298 and 819-836, and
app/api/bookings/classes/crud-with-plan/route.ts lines 274-295, with this helper
while preserving each site’s existing inputs.
- Around line 568-573: Exclude dead slots identified by isDeadSlot from all
slot-run reads in app/api/bookings/webinars/crud-with-plan/route.ts: filter
slotsOfAppointment before selecting the first slot for the duration-only update,
compute runStart and runEnd from live rows only, and add the equivalent live-row
completionStatus filter to both nested selections at lines 478-479 and 512-512.
Apply these changes to the listed sites while preserving ordering and existing
behavior for live slots.
- Around line 790-794: Update the duration validation near
effectiveDurationForSlots to reject non-number and non-positive values, using
TypeError for the type check and the existing invalid-duration error type for
invalid numeric values. In the route handler’s catch block, map
InvalidDurationError to a 400 response alongside CapacityBelowEnrollmentError so
invalid durations do not become 500 responses.

In `@components/appointments/consultee/CancelConfirmationDialog.tsx`:
- Around line 53-58: Extract the nested ternary used by
CancelConfirmationDialog’s AlertDialogTitle into a named value such as title
before the JSX. Keep the existing isLeave, isPendingPayment, and default title
outcomes unchanged, then render the named value in AlertDialogTitle.

In `@components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx`:
- Around line 279-343: Reduce the cognitive complexity of confirmDestructive by
extracting the cancel-trial and leave-event branches into separate helpers, such
as cancelTrial and leaveEvent. Each helper should perform its own validation,
fetch, response handling, and success toast, while confirmDestructive only
dispatches by destructive action and manages the shared loading, error,
dialog-close, invalidation, and finally behavior.

In `@hooks/scheduling/useSlotAllocation.ts`:
- Around line 584-587: Update the later per-day consecutiveness and
progress-feedback block in toggleSlot, specifically the
existingWeeklyConfirmedCallCounts calculation, to seed its targetWeekKey total
from options.weeklyConfirmedCallCounts just as the earlier completeCallsThisWeek
block does. Preserve the currentSlots-derived count while incorporating the
server-confirmed count so that the block’s weekly-limit check and completedCalls
toast progress reflect the same total.

In `@lib/appointments/contiguous-slot-run.ts`:
- Around line 152-163: Update the slot recreation loop in the contiguous-slot
run flow to preserve each existing SlotOfAppointment’s lifecycle fields and
related Recordings, including its id, completionStatus, completedAt, updatedAt,
and other required data; alternatively, explicitly reset lifecycle state and
handle stream-room rekeying. Ensure recreated slots retain correct appointment
status and recording reporting.

In `@utils/slotAllocation/SlotAllocationService.ts`:
- Around line 413-432: Move the expected tentative-slot validation from the
pre-transaction reads in autoAllocate and manualAllocate into their Prisma
transaction callbacks. Inside each transaction, re-read the appointment’s
tentative slots with tx.appointment.findMany, then call
assertExpectedTentativeSlotCount before the delete/recreate allocation path,
including reschedules and partial reschedules rather than only isFreshAllocation
cases.

---

Outside diff comments:
In `@app/api/participants/class/`[classId]/route.ts:
- Around line 136-160: The duplicated self-leave/organiser authorization should
be centralized in a shared authorizeParticipantRemoval helper. In
app/api/participants/class/[classId]/route.ts lines 136-160, replace the
isSelfLeave/isOrganiser gate and classEvent ownership lookup with the helper
using the class plan owner filter; in
app/api/participants/webinar/[webinarId]/route.ts lines 131-155, make the
equivalent helper call with the webinar plan owner filter. Preserve the helper’s
forbidden response and resolved authorization mode for each route.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 02a3a99e-2a65-45b0-aae8-66d4bfe85871

📥 Commits

Reviewing files that changed from the base of the PR and between 546592f and 0c2cacc.

📒 Files selected for processing (28)
  • __tests__/booking-algorithm/consultee-affordances.test.ts
  • __tests__/booking-algorithm/contiguous-slot-run.test.ts
  • __tests__/booking-algorithm/expected-tentative-count.test.ts
  • app/api/bookings/classes/[classId]/allocate/route.ts
  • app/api/bookings/classes/crud-with-plan/route.ts
  • app/api/bookings/consultations/[consultationId]/allocate/route.ts
  • app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts
  • app/api/bookings/webinars/[webinarId]/allocate/route.ts
  • app/api/bookings/webinars/crud-with-plan/route.ts
  • app/api/participants/class/[classId]/route.ts
  • app/api/participants/webinar/[webinarId]/route.ts
  • components/appointments/consultee/CancelConfirmationDialog.tsx
  • components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx
  • components/dashboard/shared/requests/RequestSlotAllocationTab.tsx
  • components/scheduling/SlotPicker.tsx
  • components/scheduling/UnifiedCalendar.tsx
  • docs/booking/03-slot-math-and-calculations.md
  • docs/booking/05-troubleshooting-and-changelog.md
  • docs/booking/07-rescheduling-flow.md
  • docs/booking/08-cancellation-flow.md
  • hooks/scheduling/useSlotAllocation.ts
  • lib/appointments/consultee-affordances.ts
  • lib/appointments/contiguous-slot-run.ts
  • lib/scheduling/allocationAlgorithms.ts
  • lib/scheduling/allocationService.ts
  • schemas/slotAllocation/validationSchemas.ts
  • utils/slotAllocation/SlotAllocationService.ts
  • utils/slotAllocation/types.ts

Comment thread __tests__/booking-algorithm/contiguous-slot-run.test.ts
Comment thread __tests__/booking-algorithm/contiguous-slot-run.test.ts
Comment thread app/api/bookings/webinars/crud-with-plan/route.ts Outdated
Comment thread app/api/bookings/webinars/crud-with-plan/route.ts Outdated
Comment thread app/api/bookings/webinars/crud-with-plan/route.ts Outdated
Comment thread components/appointments/consultee/CancelConfirmationDialog.tsx Outdated
Comment thread components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx
Comment thread hooks/scheduling/useSlotAllocation.ts
Comment thread lib/appointments/contiguous-slot-run.ts Outdated
Comment thread utils/slotAllocation/SlotAllocationService.ts
@teetangh

teetangh commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Review — #1071 / #1012 / #1005

Read the full diff and traced each change against the surrounding code. The three new suites pass locally (13/13). The direction is right — canonicalising planner CRUD onto the allocator's N×30min shape is the correct fix for #1071, and the expectedTentativeSlotCount precondition sits under the Redis locks where it belongs. Findings below, worst first.


1. replaceContiguousSlotRun hard-deletes slots, which cascades MeetingSession and Recording away — Critical

lib/appointments/contiguous-slot-run.ts:117 does deleteMany + recreate. Both children cascade:

  • prisma/schema.prisma:3923MeetingSession.slotOfAppointment … onDelete: Cascade
  • prisma/schema.prisma:4036Recording.meetingSession … onDelete: Cascade

The code this replaces did tx.slotOfAppointment.update({ where: { id: slot.id } }), which preserved the slot id and therefore the Stream session and every recording row hanging off it.

The activePayments > 0 guard narrows this (a paid webinar 400s before reaching the transaction), but it does not close it: a free webinar, or one whose payments all landed in FAILED/EXPIRED, still reaches replaceContiguousSlotRun, and a MeetingSession exists as soon as the host opens the room once (getOrCreateAppointmentMeeting). A duration-only edit is enough to trigger it.

Suggested shape: reconcile rather than replace — update the first min(existing, N) live rows in place, create/delete only the delta. Failing that, refuse the rewrite (or detach) when any live slot carries a meetingSession.

2. Duration-only edits can snap a webinar back to a dead slot's time — High

app/api/bookings/webinars/crud-with-plan/route.ts:571

const existingSlot = webinarToUpdate.appointment.slotsOfAppointment[0];
startTime = existingSlot.startsAt;

The PR added orderBy: { startsAt: "asc" } to the include, but the include has no whereCANCELLED/RESCHEDULED rows are still in the array. app/api/appointments/[appointmentId]/reschedule/route.ts:348 stamps replaced slots { isTentative: true, completionStatus: "RESCHEDULED" } and leaves them in place, so on any webinar that has been rescheduled forward, [0] is now deterministically the old, earlier, dead slot. A subsequent duration-only edit rewrites the live run back to the cancelled time.

The timeChanged guard immediately below (runStart = existingSlots[0]?.startsAt, runEnd = existingSlots.at(-1)?.endsAt) reads the same unfiltered array and has the same problem in both directions.

Filter to live rows (completionStatus: { notIn: ["CANCELLED", "RESCHEDULED"] }, and deletedAt: null — see #8) in the include, or run the array through isDeadSlot before indexing.

3. Consultee self-leave issues a full consultant-initiated refund, with no notice window and no past-event check — High (money)

app/api/participants/{webinar,class}/[id]/route.ts now lets a consultee DELETE themselves, and the handler unconditionally calls refundRemovedAttendeeSeat. That helper computes:

computeRefundPct(policy, /* hoursUntilStart */ -1, /* isConsultantInitiated */ true)

and lib/payments/operations/cancellation-policy.ts:64 short-circuits on isConsultantInitiated — it returns policy.consultantInitiatedPct and never looks at the tiers. That was correct while only organisers could reach the route. It is not correct for self-service: the consultee gets the organiser-fault percentage regardless of notice, the route has no startsAt or status guard, and the refund reason is literally written as "removed from webinar … by the organiser".

Concretely: attend the full webinar, hit Leave event, get the money back.

The dialog copy ("a refund is issued under the event's cancellation policy") describes the behaviour we want, not the behaviour we have.

Fix: thread initiatedBy: "attendee" | "organiser" into refundRemovedAttendeeSeat; on the attendee branch pass the real hoursUntilStart so the tiers apply, and reject self-leave once the first live slot has started.

4. "Leave event" and "Cancel trial" never render on the appointment detail page — High

ConsulteeAppointmentsAdapter.tsx gates both new branches on sourceId(vm), which reads vm.raw.source.id. That holds on the list page (map-consultee.ts sets source: w / source: c / source: t), but the same adapter is mounted by all three detail clients, and lib/appointments/map-detail.ts:187 sets source: detail where detail is TAppointmentDetail = { appointment, siblings } — no id field.

So sourceId returns null and neither else if fires. On /appointments/[appointmentId] a webinar, class or trial now offers no destructive action at all; previously it offered Cancel. That's a silent regression rather than the intended honest gating.

Derive the event id from vm.raw.appointment (webinarId / classId / trialSession.id) or add an explicit eventId to AppointmentVM so both mappers are forced to supply it.

5. Class durations that aren't 30-minute multiples get silently inflated — Medium

buildContiguousSlotAtoms uses SlotCalculationService.getSlotsPerCall = Math.ceil(h / 0.5). WebinarPlanSchema refines duration to 30-minute increments (schemas/plans.ts:516), but ClassPlanSchema.sessionDurationInHours (schemas/plans.ts:547) only enforces min(0.5).max(4). A 0.75h class previously wrote one 45-minute row; it now writes 2×30 = 60 minutes of the consultant's calendar.

It also breaks the webinar timeChanged comparison for such durations — runEnd (ceil-rounded) can never equal endTime (start + duration), so every edit on a paid event would 400.

Either add the increment refine to ClassPlanSchema, or clamp the final atom's endsAt to startsAt + durationInHours.

6. The riskiest function in the PR has no test coverage — Medium

Coverage from the new suites: contiguous-slot-run.ts lines 108–180 uncovered — i.e. all of replaceContiguousSlotRun. Only the pure builder and the assert are exercised. The delete-and-recreate ordering, user preservation, dead-row handling and the post-write assert are exactly the parts where #1 and #8 live. Worth a fake-tx unit test at minimum.

7. expectedTentativeSlotCount rides on a caller-dependent slot array — Medium

SlotPicker.tsx:225 derives the count from subject.slots.filter(s => s.isTentative). That's correct for AllocateClient, whose subject.slots comes from readAllocationRequest unfiltered. It is not correct for the other two subject builders: buildRescheduleSubject and buildManageTimingsSubject both use liveFutureSlots(), which drops completionStatus === "RESCHEDULED" — precisely the rows the reschedule route marks isTentative: true.

Today that's latent: the reschedule policies run in calendarMode: "select" (no allocate), and ManageTimingsClient runs in "allocate" mode but never sets hasReleasedSlots, so the field stays undefined and the guard silently never fires there. The moment anyone sets hasReleasedSlots on a reschedule/timings subject, every allocate 409s permanently with "Reschedule state changed in another session".

Two things worth doing: give Manage Timings the guard it currently doesn't have, and make the count come from something with one definition (a server-supplied field on the subject) rather than from a slot array whose filtering differs per builder.

8. isDeadSlot ignores the A10 tombstone — Low

lib/appointments/slots.ts:53 checks only completionStatus. SlotOfAppointment.deletedAt (schema.prisma:3875) is a real soft-delete marker. In replaceContiguousSlotRun a tombstoned row therefore counts as live: its users get re-attached to the new run, and the notIn deleteMany hard-deletes the tombstone. Add deletedAt: null to the live filter (and to the deleteMany where).

9. Kind-gates still offer two actions the server rejects — Low

The stated goal is "only offer actions the server will honour", but !inactive doesn't cover:

  • COMPLETED trialvalidTransitions.COMPLETED = ["CONVERTED"], so "Cancel trial" returns 400 Cannot transition from COMPLETED to CANCELLED.
  • Past webinar/class — "Leave event" returns 200 and refunds (see (feat) - ZegoCloud video conferencing #3).

10. N× writes per checkout — Low (perf)

handleWebinarCheckout / handleClassCheckout connect the buyer with one slotOfAppointment.update per slot, inside the checkout transaction. A 2h webinar goes 1 → 4 updates; a 12-session 2h class goes 12 → 48. replaceContiguousSlotRun similarly does N sequential creates. createMany plus a single connect pass would keep these transactions short.

11. The .map() in both crud-with-plan POSTs is dead code — Nit

buildContiguousSlotAtoms({...}).map(
  ({ startsAt, endsAt, isTentative, consultantProfileId: ownerId }) => ({
    startsAt, endsAt, isTentative, consultantProfileId: ownerId,
  }),
)

This is an identity map — the rename is to the same key. Its only effect is dropping user, which is never present because userIds isn't passed. ~30 lines in two files that can just be buildContiguousSlotAtoms({...}).


Docs note. docs/booking/03-slot-math-and-calculations.md now says the planner path "always writes a contiguous N×30min run", but the class crud-with-plan PATCH doesn't touch slot times at all — a class whose sessionDurationInHours changes keeps its old run length, unlike a webinar. Worth either handling it or saying so explicitly, since the sentence currently reads as covering both.

Blocking for me: #1, #2, #3, #4. The rest can follow up.

Reconcile contiguous slot rewrites in place to avoid cascading MeetingSession/Recording deletes, filter dead slots on webinar PATCH, use attendee notice tiers for self-leave refunds, restore detail-page Leave/Cancel trial via sourceId fallback, and fix the notification popover scroll. Part of #1072 #1005 #1071.

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

teetangh commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Review round 2 — 30ba4d7 (review-blocker fixes)

Went through the fix commit against the original findings. The four blockers are addressed at the design level and the reasoning in the new doc comments is right: reconciling in place instead of delete+recreate is the correct answer to the cascade problem, filtering dead rows out of LIVE_SLOTS_INCLUDE closes the snap-back, initiatedBy fixes the refund tier, and the sourceId FK fallback does restore the detail page (verified — readAppointmentDetail uses include throughout, so the scalar FKs and trialSession.id are all present).

Not saturated yet, though. Three blockers below, one of which stops the branch building at all.


1. The branch does not compile — 17 tsc errors, all from this commit — Blocking

npm run db:generate && rm -f tsconfig.tsbuildinfo && npx tsc --noEmit -p tsconfig.json

(the regenerate matters — a stale client adds unrelated noise). Two root causes:

(a) { completionStatus: null } — 5 sites. SlotOfAppointment.completionStatus is SlotCompletionStatus @default(SCHEDULED), i.e. NOT NULL, so Prisma's filter type is EnumSlotCompletionStatusFilter | SlotCompletionStatus with no | null:

app/api/participants/class/[classId]/route.ts(174,15):   error TS2322: Type 'null' is not assignable …
app/api/participants/webinar/[webinarId]/route.ts(171,15): error TS2322: …
lib/appointments/contiguous-slot-run.ts(218,11):         error TS2322: …
lib/payments/operations/event-refunds.ts(262,15):        error TS2322: …
app/api/bookings/webinars/crud-with-plan/route.ts(490,17)

The comment (SQL NOT IN (...) excludes NULL completionStatus, so we OR-null explicitly) states a true general fact that doesn't apply to this column — there is no NULL row to exclude. A plain completionStatus: { notIn: ["CANCELLED", "RESCHEDULED"] } is both correct and sufficient.

(b) LIVE_SLOTS_INCLUDE loses its literal types. Hoisting it into a const with no contextual type widens notIn: ["CANCELLED", "RESCHEDULED"] to string[] instead of SlotCompletionStatus[]. An inline object literal is contextually typed by Prisma's arg type; a hoisted one is not. That single error poisons the inferred type of webinarToUpdate / existingPlan and produces 11 further errors in the same file that look unrelated:

route.ts(507,21): Property 'consultantProfile' does not exist …
route.ts(528,22): Property 'webinars' does not exist …
route.ts(648,60): Property 'topics' does not exist …
route.ts(583,10): Parameter 's' implicitly has an 'any' type.

Verified end to end: dropping the null branch at all five sites and adding satisfies Prisma.Appointment$slotsOfAppointmentArgs to LIVE_SLOTS_INCLUDE takes the whole project to zero tsc errors. Nothing else in the commit is wrong type-wise. (eslint on the changed files is already clean.)

2. The reconcile loop can collide with itself against slot_no_confirmed_overlap — Blocking

prisma/sql/check-constraints.sql:68:

ALTER TABLE "SlotOfAppointment" ADD CONSTRAINT "slot_no_confirmed_overlap"
  EXCLUDE USING gist ("consultantProfileId" WITH =, tstzrange("startsAt","endsAt") WITH &&)
  WHERE ("consultantProfileId" IS NOT NULL AND NOT "isTentative");

Not DEFERRABLE, so it is enforced at the end of every statement. replaceContiguousSlotRun now issues one update per row while the siblings still hold their old times, and planner webinar rows sit squarely in the constrained set (isTentative: false, consultantProfileId set from existingPlan).

Move a 2 h webinar from 10:00 to 11:00 — targets 11:00 / 11:30 / 12:00 / 12:30:

step statement state
i=0 UPDATE s0 SET startsAt=11:00, endsAt=11:30 s2 still occupies [11:00,11:30), same consultant, both non-tentative → 23P01

isExclusionViolation catches it and returns 409 "That time conflicts with another confirmed session on your calendar." The webinar collides with itself, and the message blames a booking that doesn't exist.

Reachable on any shift smaller than the run length when activePayments === 0 — a free webinar, or one not yet sold. "Push it an hour later" is an ordinary planner action. Shift-and-shrink is worse: the surplus rows are only retired after the update loop, so they're still live non-tentative while the loop runs.

Neither the pre-#1071 single-row [0] update nor the interim delete+recreate could hit this — it is specific to the new shape.

Cheapest robust fix is two-phase: one updateMany flipping every live row to isTentative: true (drops them out of the partial index), then the per-row writes restoring the real isTentative. Since the target atoms are contiguous [) ranges they can't conflict with each other, so the second pass is safe in any order. Ordering the writes by direction (descending when moving forward) fixes the pure shift but not shift-plus-shrink; making the constraint DEFERRABLE INITIALLY DEFERRED also works but is a wider change.

Worth noting the two new replaceContiguousSlotRun tests move to a different day and to the same start — neither exercises an overlapping shift, and the stub tx has no constraint to violate, so this class of bug can't surface there.

3. Class self-leave is permanently blocked after the first session — Blocking

Both DELETE gates use the earliest live slot of the whole event:

const earliestLive = await prisma.slotOfAppointment.findFirst({
  where: { appointment: { classId }, deletedAt: null,},
  orderBy: { startsAt: "asc" },
});
if (earliestLive && earliestLive.startsAt.getTime() <= Date.now()) return 400;

Correct for a single-session webinar. For a class it's the wrong row: past sessions are stamped COMPLETED / UNVERIFIED, and DEAD_COMPLETION_STATUSES is only {CANCELLED, RESCHEDULED} — so session 1 stays "live" forever. From the moment week 1 starts, every self-leave on a months-long class returns 400 Cannot leave an event that has already started, while the UI keeps offering Leave event the whole time. That's precisely the offer-what-the-server-rejects problem #1005 set out to remove, reintroduced on the other side.

The same orderBy: asc query drives hoursUntilStart in refundRemovedAttendeeSeat, so even with the gate lifted a mid-program leave computes a negative notice and lands at 0 %. The local is already named nextLive — make it that: startsAt: { gte: now } for the notice window, and test "has the program ended" against the last live slot rather than the first.


Non-blocking

  1. isDeadSlot gained a deletedAt check — right call, wide blast radius. Every consumer changes behaviour: groupSlotsIntoRuns, getSlotJoinState, slotsAllowReschedule, and the session/run math on both dashboards. It degrades safely (callers that don't select deletedAt get undefined), but a tombstoned slot that used to render as a joinable session now vanishes. That's outside Rescheduling a webinar longer than 30 minutes moves only its first slot, splitting one appointment across two days #1071's stated scope — worth a changelog line so it isn't discovered as a regression.

  2. Retired surplus rows keep their attendee connections. countWebinarParticipants / countUniqueParticipants don't filter dead slots, so capacity is now partly counted off rows deliberately excluded from the run. Harmless today (same people are on the live rows), but it stops being harmless the first time a shrink and a roster change interleave.

  3. ClassPlanSchema refine also blocks edits to existing plans. Any class already authored at 0.75 h / 1.25 h now fails validation on its next PATCH, not just on create. Fine given the pre-MVP reset, but it's a silent edit-blocker rather than a create-time one.

  4. Scope creep. components/notifications/NotificationInbox.tsx (Novu popover scroll) has nothing to do with Rescheduling a webinar longer than 30 minutes moves only its first slot, splitting one appointment across two days #1071/Stale-tab reschedule can delete+recreate a completed allocation — needs allocation precondition (audit R2, design with #997) #1012/Consultee self-leave for group events + UI kind-gates for impossible actions (audit R4/R5/C4/C6) #1005 and appears in neither the PR description nor the changelog table. Easy to lose at merge.

  5. createdCount still returns createdLive.length — the total live-row count, not how many were created. Only tests read it today; the name will mislead the first real caller.


Verification run: jest __tests__/booking-algorithm/ __tests__/payments/attendee-removal-refund.test.ts → 764 passed / 36 suites. eslint on the 10 changed files → clean. tsc --noEmit → 17 errors, all traced to #1 above.

Blocking: #1, #2, #3.

Drop invalid null completionStatus filters (tsc), tentative-flip before reconcile to avoid exclusion self-collisions, gate class self-leave on the last session with next-slot notice refunds, and document the planner reconcile / leave / dead-slot behaviour. Part of #1071 #1005 #1072.

Co-authored-by: Cursor <cursoragent@cursor.com>
Move #1012's stale-tab check into the auto/manual write transactions so
reschedule races are caught before delete/recreate, matching the
requested-slots path. Part of #1012.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@__tests__/booking-algorithm/contiguous-slot-run.test.ts`:
- Line 271: Update the assertions for rows "s0" and the corresponding row at the
other marked test location to verify the expected written startsAt value, rather
than only checking that an update entry with the id exists. Reuse the entries in
updates and assert the final in-place update result so a tentative pre-pass
alone cannot satisfy the tests.

In `@app/api/bookings/webinars/crud-with-plan/route.ts`:
- Around line 582-591: Update the liveSlots lookup in the duration-only change
block to use Array.find with the existing !isDeadSlot predicate, assigning the
first matching slot directly to existingSlot; preserve the subsequent startTime
and endTime behavior.
- Around line 822-827: Update the slot-rewrite logic around
existingPlan.consultantProfileId to resolve the owner from the PATCH request’s
requested consultantProfile first, falling back to the existing plan owner when
no transfer is requested. Use this resolved owner ID for slot rewriting and
retain the missing-owner validation so transferred plans protect the new
consultant’s calendar.

In `@components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx`:
- Around line 340-361: Update leaveEvent’s endpoint selection to use an explicit
switch over AppointmentVM["kind"], preserving the webinar and class endpoints
while throwing in the default branch for unsupported kinds instead of routing
them to the class endpoint.

In `@docs/booking/05-troubleshooting-and-changelog.md`:
- Line 116: Update the ClassPlan validation in schemas/plans.ts to migrate or
grandfather existing rows whose sessionDurationInHours is not a multiple of 0.5,
so unrelated PATCH requests continue to succeed. Add a regression test covering
an unrelated PATCH on a legacy non-aligned plan, and remove the documented
limitation from the changelog.

In `@lib/payments/operations/event-refunds.ts`:
- Around line 253-282: Extract the shared live-slot query into a helper such as
findLiveSlot, centralizing deletedAt and completionStatus filtering while
supporting an optional startsAt lower bound and ascending or descending
ordering. In lib/payments/operations/event-refunds.ts:253-282, replace the
nextLive query with the helper using order "asc" and the current time filter; in
app/api/participants/class/[classId]/route.ts:166-189, use it for lastLive with
order "desc"; and in app/api/participants/webinar/[webinarId]/route.ts:161-185,
use it for earliestLive with order "asc".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6c7cc6d2-2dd2-4aef-b36a-103fe7dbe5e7

📥 Commits

Reviewing files that changed from the base of the PR and between 0c2cacc and d5c0bf7.

📒 Files selected for processing (18)
  • __tests__/booking-algorithm/contiguous-slot-run.test.ts
  • __tests__/payments/attendee-removal-refund.test.ts
  • app/api/bookings/classes/crud-with-plan/route.ts
  • app/api/bookings/webinars/crud-with-plan/route.ts
  • app/api/participants/class/[classId]/route.ts
  • app/api/participants/webinar/[webinarId]/route.ts
  • components/appointments/consultee/CancelConfirmationDialog.tsx
  • components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx
  • components/notifications/NotificationInbox.tsx
  • docs/booking/03-slot-math-and-calculations.md
  • docs/booking/05-troubleshooting-and-changelog.md
  • docs/booking/07-rescheduling-flow.md
  • docs/booking/08-cancellation-flow.md
  • hooks/scheduling/useSlotAllocation.ts
  • lib/appointments/contiguous-slot-run.ts
  • lib/appointments/slots.ts
  • lib/payments/operations/event-refunds.ts
  • schemas/plans.ts

Comment thread __tests__/booking-algorithm/contiguous-slot-run.test.ts Outdated
Comment thread app/api/bookings/webinars/crud-with-plan/route.ts
Comment thread app/api/bookings/webinars/crud-with-plan/route.ts Outdated
Comment thread components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx
Comment thread docs/booking/05-troubleshooting-and-changelog.md Outdated
Comment thread lib/payments/operations/event-refunds.ts
Share live-event slot lookup (mutable Prisma notIn types), use PATCH owner
for webinar slot rewrites, grandfather unchanged legacy class durations,
tighten replace-run assertions, and harden leave-event path selection.
Part of #1071 #1005.

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

sonarqubecloud Bot commented Aug 1, 2026

Copy link
Copy Markdown

@teetangh

teetangh commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Review round 3 — d5c0bf7, 43bc1c1b, 717e55e

All three round-2 blockers are genuinely fixed, and each one landed with a targeted regression test rather than just a code change. Verification below, then one real finding and three notes. This is a much cleaner round — close to saturation.

Round-2 blockers: verified fixed

Typecheck. npm run db:generate && rm -f tsconfig.tsbuildinfo && npx tsc --noEmit -p tsconfig.jsonzero errors, cold. All five { completionStatus: null } sites are gone and the comments now say the right thing (completionStatus defaults to SCHEDULED and is never NULL). LIVE_SLOTS_INCLUDE was dissolved into the shared findLiveEventSlot helper, which types its own where as Prisma.SlotOfAppointmentWhereInput — that sidesteps the literal-widening problem rather than papering over it, which is the better fix.

Exclusion-constraint self-collision. The two-phase flip in replaceContiguousSlotRun is correct. Flipping every live row tentative in one updateMany drops them out of the partial index (WHERE … AND NOT "isTentative"), so a row being written to its new time cannot collide with a sibling that still holds its old one; and because the target atoms are contiguous [) ranges they cannot collide with each other as each is restored to non-tentative. I walked the three orderings — pure forward shift, shift-and-shrink, shift-and-grow — and none can trip 23P01 against itself now. The surplus-retire loop correctly relies on rows already being tentative from the pre-pass. tentative-flips the whole live run before an overlapping forward shift covers exactly the case I described.

Class self-leave. Right fix, and the asymmetry is now deliberate and documented: webinar keys on the earliest live atom (order: "asc"), class on the last (order: "desc"), because a months-long class keeps past sessions live for run math. The refund side is separately correct — startsAtGte: now makes hoursUntilStart the next session rather than a past one, so a mid-program leave no longer collapses to 0%. Extracting all three call sites into lib/appointments/live-event-slot.ts is the right call; that filter had already drifted once.

Tests: 1003 passing across booking-algorithm and payments. Lint clean on the changed files — the two no-explicit-any warnings in SlotAllocationService.ts:2504,2557 are pre-existing (blame: 2025-10-03 and 2026-06-15), not from this PR.


1. The in-transaction re-assert doesn't serialize on the partial-reschedule path — Medium

43bc1c1b is a genuine improvement, but it doesn't close the race on the case that matters most, and the reason is subtle enough to be worth spelling out.

assertExpectedTentativeSlotCountInTx is a bare COUNT-then-compare inside the write transaction, and the allocate transactions specify only maxWait / timeoutno isolationLevel (SlotAllocationService.ts:1015, :1429, :1691), so they run at Read Committed. Two concurrent transactions both read the same tentative count, both pass the assert, both proceed to delete-and-recreate.

What saves the full reschedule is incidental: when every slot has been released, existingNonTentativeSlotCount === 0, so isFreshAllocation is true, guardInitialAllocationInTx runs, and it takes pg_advisory_xact_lock — which serializes the whole transaction per event. The new assert sits after that lock and inherits its protection.

On a partial reschedule — a subscription where some sessions are released and others stay confirmed — existingNonTentativeSlotCount > 0, so isFreshAllocation is false, guardInitialAllocationInTx is skipped, no advisory lock is taken, and the new assert runs unserialized. That is precisely the release-specific-sessions flow that SlotPicker's partial mode drives, and precisely where expectedTentativeSlotCount is most load-bearing.

The neighbouring doc comment already describes this exact failure and its solution:

Under Read Committed, two concurrent transactions could BOTH count zero confirmed slots before either commits … so the count alone is not atomic. The advisory xact lock serializes the guarded transactions per event.

Proposed fix. Take the advisory lock whenever the guard needs to be atomic, not only when the allocation is fresh — either by hoisting the pg_advisory_xact_lock line out of guardInitialAllocationInTx and running it unconditionally at the top of the write transaction, or by extending the if to isFreshAllocation || expectedTentativeSlotCount !== undefined. The lock is keyed per event and released at commit, so making it unconditional costs one extra round trip on paths that currently skip it and removes a conditional nobody has to reason about.

The commit message says the check now catches reschedule races "before delete/recreate", which is true for full reschedules and not yet true for partial ones — worth amending so the next reader doesn't over-trust it.

2. The in-txn re-read runs inside the pinned transaction — Note

assertExpectedTentativeSlotCountInTx issues a findMany with include: { slotsOfAppointment: true } inside the interactive transaction. SlotAllocationService.ts:907 is explicit that reads were deliberately moved out of the write transaction under #908, because an interactive transaction pins its pooled connection for its whole duration and that was producing "Unable to start a transaction in the given time" against Supavisor.

This only fires on reschedules and it is one indexed query, so the impact is small and the correctness benefit is worth it. But if the advisory lock from finding 1 gets added, a count on slotOfAppointment filtered by isTentative would do the same job without loading every slot row through the relation.

3. Consultation plans are now the odd one out on 30-minute alignment — Note

Adding the increment refine to ClassPlanSchema was right, and the grandfather clause in the class PATCH is a nice touch — dropping an unchanged sessionDurationInHours before validation means editing the title on a legacy 0.75h plan no longer 400s. Good instinct.

But ConsultationPlanSchema.durationInHours (schemas/plans.ts:235) still has only min(0.5).max(8) with no alignment refine, while webinars (:516) and now classes (:547) both have one. Consultations feed the same getSlotsPerCall = ceil(hours / 0.5), so a 0.75h consultation still books 60 minutes of the consultant's calendar. That behaviour predates this PR and is not a regression — but the rule is now inconsistent across three plan types that share one slot-expansion function, which is the kind of gap that gets rediscovered as a bug later. Either add the refine or leave a comment on ConsultationPlanSchema saying why consultations are exempt.

4. The grandfather re-read could ride along with an existing query — Nit

The classPlan.findUnique added before validation runs on every class PATCH carrying a duration, and the route fetches the plan again for the ownership check moments later. Authentication does happen first (getSession() at the top of PATCH), so there is no unauthenticated-read concern — it is purely a redundant round trip that could fold into the fetch that follows.


Verification run: tsc --noEmit cold → 0 errors. jest __tests__/booking-algorithm/ __tests__/payments/ → 1003 passed / 62 suites. eslint on the changed files → 2 warnings, both pre-existing.

Blocking: nothing. Finding 1 is worth fixing before merge since it is three lines and the partial-reschedule flow is the one #1012 was filed for, but it is strictly better than dev either way.

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