From 08754ecfc3dd9bd49816521befe6a172bd0530bd Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:32:19 +0530 Subject: [PATCH 1/2] =?UTF-8?q?fix(booking):=20lifecycle=20correctness=20?= =?UTF-8?q?=E2=80=94=20cancelled-event=20CAS,=20class=20partial=20reschedu?= =?UTF-8?q?le,=20cleanup=20guards,=20utilization=20re-key,=20reminder=20sc?= =?UTF-8?q?heduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2: webinar/class allocation now rides WHERE-guarded transitions (EVENT_ALLOWED_FROM) so a cancel racing an allocation can no longer resurrect a CANCELLED event. R1: the slotIds reschedule branch covers CLASS, ending the silent escalation of a per-session class reschedule to the whole class. R3: tentative-slot cleanup measures grace from the last write (rescheduled slots had zero grace) and skips SCHEDULED/ IN_PROGRESS webinars and classes mid-reschedule. M4: BookingUtilization substitutes re-created appointment ids one-for-one instead of re-debiting every re-allocation. The appointment-reminder job finally gets a scheduler (hourly GH Actions workflow; all three layers existed with nothing firing them). Findings C2/R1/R3/M4 + reminder gap from the 2026-07-17 booking audit. Co-Authored-By: Claude Fable 5 --- .../workflows/send-appointment-reminders.yml | 49 +++++++++++++++++++ .../rescheduleCancel.test.ts | 4 ++ .../slotAllocationService.test.ts | 45 ++++++++++------- .../[appointmentId]/reschedule/route.ts | 12 +++-- lib/booking/transitions.ts | 38 ++++++++++++++ .../appointments/cleanup-tentative-slots.ts | 34 ++++++++++++- utils/slotAllocation/SlotAllocationService.ts | 48 +++++++++++++++--- 7 files changed, 198 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/send-appointment-reminders.yml diff --git a/.github/workflows/send-appointment-reminders.yml b/.github/workflows/send-appointment-reminders.yml new file mode 100644 index 000000000..47ce36a95 --- /dev/null +++ b/.github/workflows/send-appointment-reminders.yml @@ -0,0 +1,49 @@ +name: Send Appointment Reminders + +on: + schedule: + # Hourly — the 1-hour reminder window (45–75 min before start) assumes + # at-least-hourly firing; Redis SET-NX in the script dedupes overlaps. + - cron: "12 * * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + send-appointment-reminders: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + # Database connection (required for Prisma) + DATABASE_URL: ${{ secrets.DATABASE_URL }} + # #476 cron locks load lib/redis at import — every job entry needs these + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # Reminder notifications fan out through Novu; links built via getAppUrl + NOVU_SECRET_KEY: ${{ secrets.NOVU_SECRET_KEY }} + NEXT_PUBLIC_APP_URL: ${{ secrets.NEXT_PUBLIC_APP_URL }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npx prisma generate + + - name: Send appointment reminders + run: npx tsx jobs/appointments/send-appointment-reminders.ts + + - name: Notify on failure + if: failure() + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "send-appointment-reminders" diff --git a/__tests__/booking-algorithm/rescheduleCancel.test.ts b/__tests__/booking-algorithm/rescheduleCancel.test.ts index 447de4088..654a04751 100644 --- a/__tests__/booking-algorithm/rescheduleCancel.test.ts +++ b/__tests__/booking-algorithm/rescheduleCancel.test.ts @@ -1170,6 +1170,7 @@ describe("cleanupTentativeSlots", () => { endsAt: new Date("2025-01-01T10:30:00.000Z"), isTentative: true, createdAt: new Date("2024-12-01T00:00:00.000Z"), + updatedAt: new Date("2024-12-01T00:00:00.000Z"), appointment: { payment: [], consultation: { @@ -1205,6 +1206,7 @@ describe("cleanupTentativeSlots", () => { endsAt: new Date(), isTentative: true, createdAt: new Date("2024-12-01"), + updatedAt: new Date("2024-12-01"), appointment: { payment: [], consultation: null, subscription: null }, }, { @@ -1214,6 +1216,7 @@ describe("cleanupTentativeSlots", () => { endsAt: new Date(), isTentative: true, createdAt: new Date("2024-12-01"), + updatedAt: new Date("2024-12-01"), appointment: { payment: [], consultation: null, subscription: null }, }, { @@ -1223,6 +1226,7 @@ describe("cleanupTentativeSlots", () => { endsAt: new Date(), isTentative: true, createdAt: new Date("2024-12-01"), + updatedAt: new Date("2024-12-01"), appointment: { payment: [], consultation: null, subscription: null }, }, ]; diff --git a/__tests__/booking-algorithm/slotAllocationService.test.ts b/__tests__/booking-algorithm/slotAllocationService.test.ts index 346294591..f3a345fb0 100644 --- a/__tests__/booking-algorithm/slotAllocationService.test.ts +++ b/__tests__/booking-algorithm/slotAllocationService.test.ts @@ -91,8 +91,17 @@ function makeMockTx() { update: jest.fn(), updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, - webinar: { findUnique: jest.fn(), update: jest.fn() }, - class: { findUnique: jest.fn(), update: jest.fn() }, + webinar: { + findUnique: jest.fn(), + update: jest.fn(), + // Guarded transitions (transitionWebinarEvent) use WHERE-guarded updateMany + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + class: { + findUnique: jest.fn(), + update: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, // #440 — createAppointments denormalizes the consultant onto each slot. consultantProfile: { findFirst: jest.fn().mockResolvedValue({ id: "consultant-profile-1" }), @@ -1128,9 +1137,14 @@ describe("Auto allocation", () => { mode: "auto", }); - expect(mockTx.webinar.update).toHaveBeenCalledWith( + // Guarded transition: WHERE-guarded updateMany (EVENT_ALLOWED_FROM), + // so a CANCELLED/COMPLETED webinar can no longer be resurrected. + expect(mockTx.webinar.updateMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: "webinar-1" }, + where: expect.objectContaining({ + id: "webinar-1", + status: expect.objectContaining({ in: expect.any(Array) }), + }), data: expect.objectContaining({ status: "SCHEDULED" }), }), ); @@ -1382,7 +1396,7 @@ describe("updateEventStatus", () => { slots: ["2025-01-06T10:00:00Z", "2025-01-06T10:30:00Z"], }); - const updateData = mockTx.webinar.update.mock.calls[0][0].data; + const updateData = mockTx.webinar.updateMany.mock.calls[0][0].data; expect(updateData.status).toBe("SCHEDULED"); // Webinar should NOT have scheduling period fields expect(updateData.schedulingPeriodStartsAt).toBeUndefined(); @@ -1406,10 +1420,11 @@ describe("updateEventStatus", () => { slots: ["2025-01-06T10:00:00Z", "2025-01-06T10:30:00Z"], }); - const updateData = mockTx.class.update.mock.calls[0][0].data; - expect(updateData.status).toBe("SCHEDULED"); - expect(updateData.schedulingPeriodStartsAt).toBeDefined(); - expect(updateData.schedulingPeriodEndsAt).toBeDefined(); + // Guarded transition: status rides transitionClassEvent's updateMany + const updateCall = mockTx.class.updateMany.mock.calls[0][0]; + expect(updateCall.data.status).toBe("SCHEDULED"); + expect(updateCall.data.schedulingPeriodStartsAt).toBeDefined(); + expect(updateCall.data.schedulingPeriodEndsAt).toBeDefined(); }); }); @@ -2183,14 +2198,10 @@ describe("Edge cases", () => { // Correct model was queried (read runs on the base client now) expect((prisma as any)[eventType].findUnique).toHaveBeenCalled(); - // Correct model was updated — consultation/subscription go through - // the #836 CAS transition helpers (updateMany); webinar/class still - // use a plain update in updateEventStatus. - const mutator = - eventType === "consultation" || eventType === "subscription" - ? freshTx[eventType].updateMany - : freshTx[eventType].update; - expect(mutator).toHaveBeenCalled(); + // Correct model was updated — ALL four types now go through + // WHERE-guarded CAS transitions (updateMany): #836 for + // consultation/subscription, EVENT_ALLOWED_FROM for webinar/class. + expect(freshTx[eventType].updateMany).toHaveBeenCalled(); } }); }); diff --git a/app/api/appointments/[appointmentId]/reschedule/route.ts b/app/api/appointments/[appointmentId]/reschedule/route.ts index ac571eda9..f7dde1139 100644 --- a/app/api/appointments/[appointmentId]/reschedule/route.ts +++ b/app/api/appointments/[appointmentId]/reschedule/route.ts @@ -206,12 +206,14 @@ export async function POST( ? allSubscriptionSlots : appointment.slotsOfAppointment; - // For SUBSCRIPTION with slotIds, only reschedule the specific slots + // For SUBSCRIPTION/CLASS with slotIds, only reschedule the specific + // slots. CLASS previously fell through to the whole-class branch, so + // a per-session class reschedule silently escalated to every session. if ( - derivedType === "SUBSCRIPTION" && slotIds && slotIds.length > 0 && - appointment.subscription + ((derivedType === "SUBSCRIPTION" && appointment.subscription) || + (derivedType === "CLASS" && appointment.class)) ) { // Filter to only the requested slots from ALL subscription slots slotsToReschedule = allSubscriptionSlots.filter((s) => @@ -243,10 +245,10 @@ export async function POST( // Mark the appropriate slots as tentative if ( - derivedType === "SUBSCRIPTION" && slotIds && slotIds.length > 0 && - appointment.subscription + ((derivedType === "SUBSCRIPTION" && appointment.subscription) || + (derivedType === "CLASS" && appointment.class)) ) { // Individual/multiple session reschedule - mark ALL slots of the affected appointments // (e.g. a 1.5h session has 3 consecutive slots; all must be marked tentative together) diff --git a/lib/booking/transitions.ts b/lib/booking/transitions.ts index f5c09aee9..d8739b3a8 100644 --- a/lib/booking/transitions.ts +++ b/lib/booking/transitions.ts @@ -117,6 +117,44 @@ export const EVENT_ALLOWED_FROM: Record = { export const CLASS_EVENT_ALLOWED_FROM: Record = EVENT_ALLOWED_FROM; +export async function transitionWebinarEvent( + tx: Pick, + args: { + where: { id: string }; + to: WebinarStatus; + data?: Omit; + fromIn?: WebinarStatus[]; + }, +): Promise { + const res = await tx.webinar.updateMany({ + where: { + ...args.where, + status: { in: args.fromIn ?? EVENT_ALLOWED_FROM[args.to] }, + }, + data: { status: args.to, ...args.data }, + }); + if (res.count === 0) throw new IllegalTransitionError("Webinar", args.to); +} + +export async function transitionClassEvent( + tx: Pick, + args: { + where: { id: string }; + to: ClassStatus; + data?: Omit; + fromIn?: ClassStatus[]; + }, +): Promise { + const res = await tx.class.updateMany({ + where: { + ...args.where, + status: { in: args.fromIn ?? CLASS_EVENT_ALLOWED_FROM[args.to] }, + }, + data: { status: args.to, ...args.data }, + }); + if (res.count === 0) throw new IllegalTransitionError("Class", args.to); +} + //////////////////////////////////////////////// SlotOfAppointment //////////////////////////////////////////////// // A reschedule may re-mark a SCHEDULED or already-RESCHEDULED slot tentative, diff --git a/scripts/appointments/cleanup-tentative-slots.ts b/scripts/appointments/cleanup-tentative-slots.ts index 908e665da..e4ce83af1 100644 --- a/scripts/appointments/cleanup-tentative-slots.ts +++ b/scripts/appointments/cleanup-tentative-slots.ts @@ -65,7 +65,10 @@ async function cleanupTentativeSlotsUnlocked(): Promise a.id)); + const trackedLive = existingUtil.appointmentIds.filter((id) => + liveIds.has(id), + ); + const staleCount = existingUtil.appointmentIds.length - trackedLive.length; + if (staleCount > 0) { + const alreadyTracked = new Set(trackedLive); + const incomingNew = newAppointmentIds.filter( + (id) => !alreadyTracked.has(id), + ); + const substituted = incomingNew.slice(0, staleCount); + idsToDebit = incomingNew.slice(staleCount); + await tx.bookingUtilization.update({ + where: { id: existingUtil.id }, + data: { appointmentIds: [...trackedLive, ...substituted] }, + }); + if (idsToDebit.length === 0) return; + } + } + try { await recordBookingUtilization(tx, { programAssignmentId: assignment.id, paymentId: orgPayment.id, - engagementsConsumed: newAppointmentIds.length, + engagementsConsumed: idsToDebit.length, priceAtBookingPaise, // PR-1e (G3): pass the appointment ids so re-allocation // (delete+recreate of the same slot) can't double-debit. The // helper computes the set diff against // BookingUtilization.appointmentIds and increments only by the // genuinely-new ids. - appointmentIds: newAppointmentIds, + appointmentIds: idsToDebit, }); } catch (err) { if (err instanceof ProgramAssignmentLimitError) { @@ -2432,10 +2461,12 @@ export class SlotAllocationService { case "webinar": // Webinar model does NOT have startDate/endDate fields - // Start date is stored in the Appointment's slots - await tx.webinar.update({ + // Start date is stored in the Appointment's slots. + // Guarded transition — an unguarded update let allocation racing a + // cancel resurrect a CANCELLED (or re-open a COMPLETED) webinar. + await transitionWebinarEvent(tx, { where: { id: eventId }, - data: { status: "SCHEDULED" }, + to: "SCHEDULED", }); break; @@ -2444,10 +2475,11 @@ export class SlotAllocationService { // FIX: Only set schedulingPeriod if not already configured — same guard as SUBSCRIPTION. // Overwriting an explicitly-set period on re-allocation shifts the window, allowing // slots outside the original range to pass the scheduling-period validation check. - await tx.class.update({ + // Guarded transition — same resurrection hazard as WEBINAR above. + await transitionClassEvent(tx, { where: { id: eventId }, + to: "SCHEDULED", data: { - status: "SCHEDULED", ...(!config.schedulingPeriodStartsAt || !config.schedulingPeriodEndsAt ? { From c3d2dbf0f524c9bb46c8958dcc8089c552547179 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:06:48 +0530 Subject: [PATCH 2/2] test(booking): add updatedAt to the cleanup-guard fixture The sweep now measures grace from updatedAt; the #829 guard fixture predated the field and threw before the delete ran. Part of #1002. Co-Authored-By: Claude Fable 5 --- __tests__/booking/cleanup-tentative-guard.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/__tests__/booking/cleanup-tentative-guard.test.ts b/__tests__/booking/cleanup-tentative-guard.test.ts index a744999b9..09cb6e9fa 100644 --- a/__tests__/booking/cleanup-tentative-guard.test.ts +++ b/__tests__/booking/cleanup-tentative-guard.test.ts @@ -43,6 +43,7 @@ describe("#829 — cleanup delete re-states the tentative + unpaid guards", () = id: "slot-1", appointmentId: "appt-1", createdAt: new Date("2026-05-01T00:00:00Z"), + updatedAt: new Date("2026-05-01T00:00:00Z"), startsAt: new Date("2026-05-02T10:00:00Z"), endsAt: new Date("2026-05-02T11:00:00Z"), appointment: { payment: [], consultation: null, subscription: null },