diff --git a/__tests__/booking-algorithm/authorization.test.ts b/__tests__/booking-algorithm/authorization.test.ts index 6cf444bfa..10129220d 100644 --- a/__tests__/booking-algorithm/authorization.test.ts +++ b/__tests__/booking-algorithm/authorization.test.ts @@ -308,7 +308,13 @@ function makeMockTx(appointmentData: any = null) { // B2 — the cancel/reschedule CAS guards use updateMany. updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, - slotOfAppointment: { updateMany: jest.fn(), deleteMany: jest.fn() }, + // transitionSlotCompletion reads the from-status, then moves the cohort + // with updateManyAndReturn so each moved id gets its history row. + slotOfAppointment: { + findMany: jest.fn().mockResolvedValue([]), + updateManyAndReturn: jest.fn().mockResolvedValue([{ id: "slot-1" }]), + deleteMany: jest.fn(), + }, appointmentParticipant: { createMany: jest.fn().mockResolvedValue({ count: 1 }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), @@ -317,6 +323,9 @@ function makeMockTx(appointmentData: any = null) { // Cancel closes any live reschedule proposal so the appointment's // openForAppointmentId reservation is released. rescheduleRequest: { + // The cancel route reads the open proposals, then CASes each by id. + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue({ status: "PENDING_REVIEW" }), updateMany: jest.fn().mockResolvedValue({ count: 0 }), create: jest.fn().mockResolvedValue({ id: "reschedule-request-1" }), }, diff --git a/__tests__/booking-algorithm/reschedule-preference-route.test.ts b/__tests__/booking-algorithm/reschedule-preference-route.test.ts index b5a9a0ed0..fc6cdefcc 100644 --- a/__tests__/booking-algorithm/reschedule-preference-route.test.ts +++ b/__tests__/booking-algorithm/reschedule-preference-route.test.ts @@ -134,15 +134,31 @@ function makeMockTx() { { id: "apt-2", slotsOfAppointment: [SIBLING_SLOTS[1]] }, ]), }, + // Each transition helper reads the from-status before its CAS and appends + // one BookingStatusHistory row after it. subscription: { + findUnique: jest.fn().mockResolvedValue({ status: "APPROVED" }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), count: jest.fn().mockResolvedValue(1), }, - consultation: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) }, - webinar: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) }, - class: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) }, + consultation: { + findUnique: jest.fn().mockResolvedValue({ status: "APPROVED" }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + webinar: { + findUnique: jest.fn().mockResolvedValue({ status: "SCHEDULED" }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + class: { + findUnique: jest.fn().mockResolvedValue({ status: "SCHEDULED" }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, slotOfAppointment: { - updateMany: jest.fn().mockResolvedValue({ count: 2 }), + findMany: jest.fn().mockResolvedValue([]), + updateManyAndReturn: jest + .fn() + .mockResolvedValue([{ id: "slot-1" }, { id: "slot-2" }]), }, rescheduleRequest: { create: jest.fn().mockImplementation(({ data }) => { diff --git a/__tests__/booking-algorithm/reschedule-withdraw-behavior.test.ts b/__tests__/booking-algorithm/reschedule-withdraw-behavior.test.ts index d10eaf444..3533945d2 100644 --- a/__tests__/booking-algorithm/reschedule-withdraw-behavior.test.ts +++ b/__tests__/booking-algorithm/reschedule-withdraw-behavior.test.ts @@ -48,11 +48,20 @@ interface StatusCas { where: { id: string; status?: { in: string[] } }; data: Data; } +/** transitionSlotCompletion's shape: the from-set is an `in` list. */ interface SlotCas { - where: { id: { in: string[] }; completionStatus: string }; + where: { id: { in: string[] }; completionStatus: { in: string[] } }; data: Data; } +function matchSlots(where: SlotCas["where"]): SlotRow[] { + return state.slots.filter( + (s) => + where.id.in.includes(s.id) && + where.completionStatus.in.includes(s.completionStatus), + ); +} + function makeTx() { return { bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, @@ -70,14 +79,16 @@ function makeTx() { }), }, slotOfAppointment: { - updateMany: jest.fn(async ({ where, data }: SlotCas) => { - const targets = state.slots.filter( - (s) => - where.id.in.includes(s.id) && - s.completionStatus === where.completionStatus, - ); + findMany: jest.fn(async ({ where }: SlotCas) => + matchSlots(where).map((s) => ({ + id: s.id, + completionStatus: s.completionStatus, + })), + ), + updateManyAndReturn: jest.fn(async ({ where, data }: SlotCas) => { + const targets = matchSlots(where); targets.forEach((s) => Object.assign(s, data)); - return { count: targets.length }; + return targets.map((s) => ({ id: s.id })); }), }, subscription: { diff --git a/__tests__/booking-algorithm/rescheduleCancel.test.ts b/__tests__/booking-algorithm/rescheduleCancel.test.ts index 33071b4bf..85c79d142 100644 --- a/__tests__/booking-algorithm/rescheduleCancel.test.ts +++ b/__tests__/booking-algorithm/rescheduleCancel.test.ts @@ -273,7 +273,13 @@ function makeMockTx() { // B2 — the cancel/reschedule CAS guards use updateMany. updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, - slotOfAppointment: { updateMany: jest.fn(), deleteMany: jest.fn() }, + // transitionSlotCompletion reads the from-status, then moves the cohort + // with updateManyAndReturn so each moved id gets its history row. + slotOfAppointment: { + findMany: jest.fn().mockResolvedValue([]), + updateManyAndReturn: jest.fn().mockResolvedValue([{ id: "slot-1" }]), + deleteMany: jest.fn(), + }, appointmentParticipant: { createMany: jest.fn().mockResolvedValue({ count: 1 }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), @@ -283,6 +289,9 @@ function makeMockTx() { // openForAppointmentId reservation is released and the expiry cron cannot // act on a cancelled booking. Reschedule creates one when times are proposed. rescheduleRequest: { + // The cancel route reads the open proposals, then CASes each by id. + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue({ status: "PENDING_REVIEW" }), updateMany: jest.fn().mockResolvedValue({ count: 0 }), create: jest.fn().mockResolvedValue({ id: "reschedule-request-1" }), }, @@ -552,14 +561,17 @@ describe("Reschedule Route Handler - POST", () => { expect(body.slotsAffected).toBe(2); // Verify slots marked tentative by appointmentId (non-subscription path) - expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({ - where: { - appointmentId: "apt-1", - // #837 — reschedule never resurrects COMPLETED/CANCELLED history - completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] }, - }, - data: { isTentative: true, completionStatus: "RESCHEDULED" }, - }); + expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith( + // objectContaining: `select` is the helper's own business. + expect.objectContaining({ + where: { + appointmentId: "apt-1", + // #837 — reschedule never resurrects COMPLETED/CANCELLED history + completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] }, + }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, + }), + ); // Verify consultation status reverted expect(mockTx.consultation.updateMany).toHaveBeenCalledWith({ @@ -617,13 +629,16 @@ describe("Reschedule Route Handler - POST", () => { expect(body.rescheduleType).toBe("entire_booking"); // Should mark all appointment slots tentative - expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({ - where: { - appointmentId: { in: ["apt-1", "apt-2"] }, - completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] }, - }, - data: { isTentative: true, completionStatus: "RESCHEDULED" }, - }); + expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith( + // objectContaining: `select` is the helper's own business. + expect.objectContaining({ + where: { + appointmentId: { in: ["apt-1", "apt-2"] }, + completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] }, + }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, + }), + ); // Should update subscription status expect(mockTx.subscription.updateMany).toHaveBeenCalledWith({ @@ -668,13 +683,16 @@ describe("Reschedule Route Handler - POST", () => { // The route marks ALL slots belonging to the affected appointment(s), not just the // specified slot ID. This ensures multi-slot sessions (e.g. 1.5h = 3 × 30-min slots) // are rescheduled atomically — a partial-tentative session would be inconsistent. - expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({ - where: { - appointmentId: { in: ["apt-1"] }, - completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] }, - }, - data: { isTentative: true, completionStatus: "RESCHEDULED" }, - }); + expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith( + // objectContaining: `select` is the helper's own business. + expect.objectContaining({ + where: { + appointmentId: { in: ["apt-1"] }, + completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] }, + }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, + }), + ); }); it("should return 'multiple_sessions' type when slotIds span multiple sessions", async () => { @@ -759,14 +777,17 @@ describe("Reschedule Route Handler - POST", () => { expect(body.success).toBe(true); expect(body.rescheduleType).toBe("entire_booking"); - expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({ - where: { - appointmentId: "apt-1", - // #837 — reschedule never resurrects COMPLETED/CANCELLED history - completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] }, - }, - data: { isTentative: true, completionStatus: "RESCHEDULED" }, - }); + expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith( + // objectContaining: `select` is the helper's own business. + expect.objectContaining({ + where: { + appointmentId: "apt-1", + // #837 — reschedule never resurrects COMPLETED/CANCELLED history + completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] }, + }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, + }), + ); expect(mockTx.webinar.updateMany).toHaveBeenCalledWith({ where: { id: "web-1", status: { in: ["SCHEDULED", "IN_PROGRESS"] } }, @@ -979,16 +1000,20 @@ describe("Cancel Route Handler - POST", () => { // Verify slots soft-cancelled (not hard-deleted — preserves payment // audit trail); only live SCHEDULED slots flip, history is never // re-stamped. - expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({ - // RESCHEDULED counts too: a slot released by a pending reschedule is - // not SCHEDULED, and skipping it left non-terminal rows on a booking - // that no longer exists. - where: { - appointmentId: "apt-1", - completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] }, - }, - data: { completionStatus: "CANCELLED" }, - }); + expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith( + // objectContaining: `select` is the helper's own business. + expect.objectContaining({ + // RESCHEDULED counts too: a slot released by a pending reschedule is + // not SCHEDULED, and skipping it left non-terminal rows on a booking + // that no longer exists. + where: { + appointmentId: "apt-1", + completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] }, + }, + // The tombstone is the other half of the soft-cancel (#676 A10). + data: { completionStatus: "CANCELLED", deletedAt: expect.any(Date) }, + }), + ); // Verify appointment is NOT deleted (soft-cancel preserves records) expect(mockTx.appointment.delete).not.toHaveBeenCalled(); @@ -998,8 +1023,8 @@ describe("Cancel Route Handler - POST", () => { const req = makeCancelRequest("apt-1"); await cancelHandler(req, makeParams("apt-1")); - // Soft-cancel: updateMany with completionStatus, not deleteMany - expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalled(); + // Soft-cancel: a completionStatus write, not deleteMany + expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalled(); expect(mockTx.slotOfAppointment.deleteMany).not.toHaveBeenCalled(); expect(mockTx.appointment.delete).not.toHaveBeenCalled(); }); diff --git a/__tests__/booking-algorithm/rescheduleResponses.test.ts b/__tests__/booking-algorithm/rescheduleResponses.test.ts index 32a043703..4609f075f 100644 --- a/__tests__/booking-algorithm/rescheduleResponses.test.ts +++ b/__tests__/booking-algorithm/rescheduleResponses.test.ts @@ -163,11 +163,15 @@ function makeMockTx(appointmentData: any) { }, consultation: { update: jest.fn(), + // Each transition helper reads the from-status before its CAS. + findUnique: jest.fn().mockResolvedValue({ status: "APPROVED" }), // B2 — the cancel/reschedule CAS guards use updateMany. updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, subscription: { update: jest.fn(), + // Each transition helper reads the from-status before its CAS. + findUnique: jest.fn().mockResolvedValue({ status: "APPROVED" }), // B2 — the cancel/reschedule CAS guards use updateMany. updateMany: jest.fn().mockResolvedValue({ count: 1 }), // #448 — a PARTIAL (slotIds) subscription reschedule only terminal-guards @@ -177,15 +181,26 @@ function makeMockTx(appointmentData: any) { }, webinar: { update: jest.fn(), + // Each transition helper reads the from-status before its CAS. + findUnique: jest.fn().mockResolvedValue({ status: "SCHEDULED" }), // B2 — the cancel/reschedule CAS guards use updateMany. updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, class: { update: jest.fn(), + // Each transition helper reads the from-status before its CAS. + findUnique: jest.fn().mockResolvedValue({ status: "SCHEDULED" }), // B2 — the cancel/reschedule CAS guards use updateMany. updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, - slotOfAppointment: { updateMany: jest.fn(), deleteMany: jest.fn() }, + // transitionSlotCompletion reads the from-status, then moves the cohort + // with updateManyAndReturn so each moved id gets its history row. + slotOfAppointment: { + findMany: jest.fn().mockResolvedValue([]), + updateManyAndReturn: jest.fn().mockResolvedValue([{ id: "slot-1" }]), + deleteMany: jest.fn(), + }, + bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, }; } diff --git a/__tests__/payments/cancel-route-refund.test.ts b/__tests__/payments/cancel-route-refund.test.ts index 72f14c76a..dd7ed3f76 100644 --- a/__tests__/payments/cancel-route-refund.test.ts +++ b/__tests__/payments/cancel-route-refund.test.ts @@ -51,8 +51,20 @@ const txStub = { updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, - slotOfAppointment: { updateMany: jest.fn().mockResolvedValue({ count: 2 }) }, - rescheduleRequest: { updateMany: jest.fn().mockResolvedValue({ count: 0 }) }, + // transitionSlotCompletion reads the from-status, then moves the cohort with + // updateManyAndReturn so each moved id gets its own history row. + slotOfAppointment: { + findMany: jest.fn().mockResolvedValue([]), + updateManyAndReturn: jest + .fn() + .mockResolvedValue([{ id: "slot-1" }, { id: "slot-2" }]), + }, + // The cancel route reads the open proposals, then CASes each by id. + rescheduleRequest: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue({ status: "PENDING_REVIEW" }), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, }; jest.mock("../../lib/prisma", () => ({ @@ -282,8 +294,12 @@ beforeEach(() => { txCommitted = false; txStub.consultation.updateMany.mockResolvedValue({ count: 1 }); txStub.subscription.updateMany.mockResolvedValue({ count: 1 }); - txStub.slotOfAppointment.updateMany.mockResolvedValue({ count: 2 }); - txStub.rescheduleRequest.updateMany.mockResolvedValue({ count: 0 }); + txStub.slotOfAppointment.findMany.mockResolvedValue([]); + txStub.slotOfAppointment.updateManyAndReturn.mockResolvedValue([ + { id: "slot-1" }, + { id: "slot-2" }, + ]); + txStub.rescheduleRequest.findMany.mockResolvedValue([]); mockPaymentFindMany.mockResolvedValue([]); mockMembershipFindUnique.mockResolvedValue(null); mockRecordSystemError.mockResolvedValue(undefined); @@ -736,3 +752,72 @@ describe("failure modes leave the cancellation standing", () => { expect(mockRefundBookingPayment).not.toHaveBeenCalled(); }); }); + +/** + * #1322 A12 — the route wrote its four request statuses, its slots and its + * open proposal with raw updateMany calls, so a successful cancel left no + * BookingStatusHistory row at all and the cancelled slots kept + * `deletedAt: null`, holding the consultant's calendar forever. The pin is on + * the audit trail rather than on the call shape, because that is what was + * empty in production. + */ +describe("a cancel leaves an audit trail", () => { + it("records the request move and every slot it moved, tombstone included", async () => { + mockGetSession.mockResolvedValue(sessionAs("consultee")); + mockAppointmentFindUnique.mockResolvedValue(consultationAppointment()); + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ liveSlotHours: [120] }), + ); + txStub.consultation.findUnique.mockResolvedValue({ status: "APPROVED" }); + + const res = await cancelHandler( + makeRequest({ reason: "SCHEDULE_CONFLICT" }), + makeParams(APPT), + ); + expect(res.status).toBe(200); + + const historyRows = ( + txStub.bookingStatusHistory.create.mock.calls as [ + { data: Record }, + ][] + ).map(([args]) => args.data); + + expect(historyRows).toContainEqual( + expect.objectContaining({ + entity: "CONSULTATION", + entityId: "cons-1", + fromStatus: "APPROVED", + toStatus: "CANCELLED", + actorUserId: CONSULTEE_USER, + reason: "SCHEDULE_CONFLICT", + appointmentId: APPT, + }), + ); + // One per slot the CAS actually moved — the ids come from the UPDATE's own + // RETURNING, so a slot a racing writer pulled out never gets a row. + expect(historyRows.filter((row) => row.entity === "SLOT")).toHaveLength(2); + expect(txStub.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith( + expect.objectContaining({ + data: { completionStatus: "CANCELLED", deletedAt: expect.any(Date) }, + }), + ); + }); + + it("keeps the 409 contract when the request has already moved", async () => { + mockGetSession.mockResolvedValue(sessionAs("consultee")); + mockAppointmentFindUnique.mockResolvedValue(consultationAppointment()); + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ liveSlotHours: [120] }), + ); + txStub.consultation.updateMany.mockResolvedValue({ count: 0 }); + + const res = await cancelHandler(makeRequest(), makeParams(APPT)); + const body = await res.json(); + + // The helper throws ILLEGAL_TRANSITION; the client still sees the code it + // has always keyed its retry copy off. + expect(res.status).toBe(409); + expect(body.code).toBe("NOT_CANCELLABLE"); + expect(txStub.bookingStatusHistory.create).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/payments/refund-preview-parity.test.ts b/__tests__/payments/refund-preview-parity.test.ts index 6a99133b9..b71e54b5d 100644 --- a/__tests__/payments/refund-preview-parity.test.ts +++ b/__tests__/payments/refund-preview-parity.test.ts @@ -62,8 +62,20 @@ const txStub = { updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, - slotOfAppointment: { updateMany: jest.fn().mockResolvedValue({ count: 2 }) }, - rescheduleRequest: { updateMany: jest.fn().mockResolvedValue({ count: 0 }) }, + // transitionSlotCompletion reads the from-status, then moves the cohort with + // updateManyAndReturn so each moved id gets its own history row. + slotOfAppointment: { + findMany: jest.fn().mockResolvedValue([]), + updateManyAndReturn: jest + .fn() + .mockResolvedValue([{ id: "slot-1" }, { id: "slot-2" }]), + }, + // The cancel route reads the open proposals, then CASes each by id. + rescheduleRequest: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue({ status: "PENDING_REVIEW" }), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, }; jest.mock("../../lib/prisma", () => ({ @@ -440,8 +452,12 @@ beforeEach(() => { txCommitted = false; txStub.consultation.updateMany.mockResolvedValue({ count: 1 }); txStub.subscription.updateMany.mockResolvedValue({ count: 1 }); - txStub.slotOfAppointment.updateMany.mockResolvedValue({ count: 2 }); - txStub.rescheduleRequest.updateMany.mockResolvedValue({ count: 0 }); + txStub.slotOfAppointment.findMany.mockResolvedValue([]); + txStub.slotOfAppointment.updateManyAndReturn.mockResolvedValue([ + { id: "slot-1" }, + { id: "slot-2" }, + ]); + txStub.rescheduleRequest.findMany.mockResolvedValue([]); mockPaymentFindMany.mockResolvedValue([]); mockPaymentFindFirst.mockResolvedValue({ currency: "INR", diff --git a/app/api/appointments/[appointmentId]/cancel/route.ts b/app/api/appointments/[appointmentId]/cancel/route.ts index 8bf542b88..060167f4f 100644 --- a/app/api/appointments/[appointmentId]/cancel/route.ts +++ b/app/api/appointments/[appointmentId]/cancel/route.ts @@ -6,7 +6,7 @@ import { withAppointmentLock, } from "@/utils/appointmentlock"; import { setParticipantStatus } from "@/lib/booking/participants"; -import prisma from "@/lib/prisma"; +import prisma, { type Tx } from "@/lib/prisma"; import { NextRequest, NextResponse } from "next/server"; import { CancellationReason } from "@prisma/client"; import { notifyAppointmentCancelled } from "@/lib/novu"; @@ -39,7 +39,78 @@ import { EVENT_ALLOWED_FROM, RESCHEDULE_OPEN_STATUSES, SLOT_RESCHEDULABLE_FROM, + transitionClassEvent, + transitionConsultationRequest, + transitionRescheduleRequest, + transitionSlotCompletion, + transitionSubscriptionRequest, + transitionWebinarEvent, } from "@/lib/booking/transitions"; +import { IllegalTransitionError } from "@/lib/enterprise/transitions"; + +/** Audit attribution shared by every CAS this cancel drives (#1322 A12). */ +type CancelAuditMeta = { + actorUserId: string; + reason: string | null; + organizationId: string | null; +}; + +/** + * Which rows this cancel sweeps. A whole-subscription or whole-class cancel + * also ends the sessions of its sibling appointments; every other booking ends + * only its own. The slot sweep and the participant sweep must never disagree + * about that, so both read the scope from here (#1383). + */ +function cancelSweepScope(appointment: { + id: string; + subscription: { id: string } | null; + class: { id: string } | null; +}) { + if (appointment.subscription) { + return { appointment: { subscriptionId: appointment.subscription.id } }; + } + if (appointment.class) { + return { appointment: { classId: appointment.class.id } }; + } + // Consultation/webinar/trial — single appointment. + return { appointmentId: appointment.id }; +} + +/** + * Close any live reschedule proposal on a booking being cancelled. Leaving one + * open would keep `openForAppointmentId` reserved forever and let the expiry + * cron act on a cancelled booking. The helper CASes one row by id — hence the + * read — and releases the reservation itself on every terminal target, so + * `data` carries nothing here (#1383). + */ +async function declineOpenReschedules( + tx: Pick, + appointmentId: string, + auditMeta: CancelAuditMeta, +): Promise { + const openProposals = await tx.rescheduleRequest.findMany({ + where: { appointmentId, status: { in: RESCHEDULE_OPEN_STATUSES } }, + select: { id: true }, + }); + for (const proposal of openProposals) { + try { + await transitionRescheduleRequest(tx, { + ...auditMeta, + appointmentId, + where: { id: proposal.id }, + to: "DECLINED", + fromIn: RESCHEDULE_OPEN_STATUSES, + }); + } catch (err) { + // The expiry cron holds no appointment lock, so it can answer a + // proposal between the read above and this CAS. Either way the + // booking ends with no open proposal, which is the whole point; + // failing the cancel over it would be the wrong outcome. + if (!(err instanceof IllegalTransitionError)) throw err; + } + } +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ appointmentId: string }> }, @@ -272,15 +343,30 @@ export async function POST( }) : null; - // Prepare cancellation data + // Prepare cancellation data. `status` is NOT here: the transition helpers + // own that column, and their `data` type excludes it so a caller cannot + // write a status past the CAS. const cancellationData = { - status: "CANCELLED" as const, cancellationReason: (validatedData.reason as CancellationReason) || null, cancellationNotes: validatedData.notes || null, cancelledAt: new Date(), cancelledBy: session.user.id, }; + // Audit attribution for every BookingStatusHistory row this cancel writes + // (#1322 A12). `appointmentId` is added per call site rather than here: a + // subscription/class cancel sweeps slots belonging to sibling appointments, + // and stamping this appointment on those rows would file another session's + // history under this booking's timeline. + const auditMeta: CancelAuditMeta = { + actorUserId: session.user.id, + reason: validatedData.reason ?? null, + organizationId: appointment.organizationId, + }; + + // One scope for both sweeps below, resolved before the transaction opens. + const sweepScope = cancelSweepScope(appointment); + // Cancellable from-states: never COMPLETED (history), never CANCELLED // (idempotency — a double-cancel must not re-run refunds), never // REJECTED/EXPIRED (nothing to cancel). The guard rides the WHERE and is @@ -294,52 +380,58 @@ export async function POST( const result = await withAppointmentLock(appointmentId, () => prisma.$transaction( async (tx) => { - // Update appointment status based on type — CAS-guarded. - let moved = 0; - if (appointment.consultation) { - moved = ( - await tx.consultation.updateMany({ - where: { - id: appointment.consultation.id, - status: { in: [...CANCELLABLE_FROM] }, - }, + // Update appointment status based on type — through the CAS helpers, + // which bake the same allowed-from set into the WHERE and append the + // BookingStatusHistory row this route used to skip entirely. + let moved = false; + try { + if (appointment.consultation) { + await transitionConsultationRequest(tx, { + ...auditMeta, + appointmentId, + where: { id: appointment.consultation.id }, + to: "CANCELLED", data: cancellationData, - }) - ).count; - } else if (appointment.subscription) { - moved = ( - await tx.subscription.updateMany({ - where: { - id: appointment.subscription.id, - status: { in: [...CANCELLABLE_FROM] }, - }, + fromIn: [...CANCELLABLE_FROM], + }); + moved = true; + } else if (appointment.subscription) { + await transitionSubscriptionRequest(tx, { + ...auditMeta, + appointmentId, + where: { id: appointment.subscription.id }, + to: "CANCELLED", data: cancellationData, - }) - ).count; - } else if (appointment.webinar) { - // Explicit allowed-from (was notIn) — robust against future enum - // additions (#837). - moved = ( - await tx.webinar.updateMany({ - where: { - id: appointment.webinar.id, - status: { in: EVENT_ALLOWED_FROM.CANCELLED }, - }, - data: { status: "CANCELLED" }, - }) - ).count; - } else if (appointment.class) { - moved = ( - await tx.class.updateMany({ - where: { - id: appointment.class.id, - status: { in: CLASS_EVENT_ALLOWED_FROM.CANCELLED }, - }, - data: { status: "CANCELLED" }, - }) - ).count; + fromIn: [...CANCELLABLE_FROM], + }); + moved = true; + } else if (appointment.webinar) { + // Explicit allowed-from (was notIn) — robust against future enum + // additions (#837). + await transitionWebinarEvent(tx, { + ...auditMeta, + appointmentId, + where: { id: appointment.webinar.id }, + to: "CANCELLED", + fromIn: EVENT_ALLOWED_FROM.CANCELLED, + }); + moved = true; + } else if (appointment.class) { + await transitionClassEvent(tx, { + ...auditMeta, + appointmentId, + where: { id: appointment.class.id }, + to: "CANCELLED", + fromIn: CLASS_EVENT_ALLOWED_FROM.CANCELLED, + }); + moved = true; + } + } catch (err) { + // The helper's zero-row throw IS the old `moved === 0`; the client + // contract stays NOT_CANCELLABLE rather than ILLEGAL_TRANSITION. + if (!(err instanceof IllegalTransitionError)) throw err; } - if (moved === 0) { + if (!moved) { throw Object.assign( new Error( "This appointment can no longer be cancelled (already cancelled, completed, or expired).", @@ -356,58 +448,28 @@ export async function POST( // SCHEDULED, so filtering on SCHEDULED alone left those rows in a // non-terminal state on a booking that no longer exists — and proposals // hang off exactly those rows. - const cancellableSlotStatuses = SLOT_RESCHEDULABLE_FROM; - if (appointment.subscription) { - await tx.slotOfAppointment.updateMany({ - where: { - appointment: { subscriptionId: appointment.subscription.id }, - completionStatus: { in: cancellableSlotStatuses }, - }, - data: { completionStatus: "CANCELLED" }, - }); - } else if (appointment.class) { - await tx.slotOfAppointment.updateMany({ - where: { - appointment: { classId: appointment.class.id }, - completionStatus: { in: cancellableSlotStatuses }, - }, - data: { completionStatus: "CANCELLED" }, - }); - } else { - // Consultation/webinar/trial — single appointment - await tx.slotOfAppointment.updateMany({ - where: { - appointmentId, - completionStatus: { in: cancellableSlotStatuses }, - }, - data: { completionStatus: "CANCELLED" }, - }); - } + // + // The from-set rides in `fromIn`, never in `where`: the helper + // overwrites `completionStatus` in the caller's WHERE with its own + // from-set, so a status left there is silently discarded. + await transitionSlotCompletion(tx, { + ...auditMeta, + where: sweepScope, + to: "CANCELLED", + // The tombstone is half of the soft-cancel: without it the row + // still occupies the consultant's calendar for every reader that + // filters on `deletedAt: null` (#676 A10, the shape + // cleanup-abandoned-payments already writes). + data: { deletedAt: new Date() }, + fromIn: [...SLOT_RESCHEDULABLE_FROM], + // A booking whose sessions are all delivered or already terminal + // is still cancellable; matching no live slot is not a conflict. + allowZero: true, + }); // #1319 A9 — every participant of the cancelled engagement. - await setParticipantStatus( - tx, - appointment.subscription - ? { appointment: { subscriptionId: appointment.subscription.id } } - : appointment.class - ? { appointment: { classId: appointment.class.id } } - : { appointmentId }, - "CANCELLED", - ); + await setParticipantStatus(tx, sweepScope, "CANCELLED"); - // Close any live reschedule proposal on this booking. Leaving one open - // would keep openForAppointmentId reserved forever and let the expiry - // cron act on a cancelled booking. - await tx.rescheduleRequest.updateMany({ - where: { - appointmentId, - status: { in: RESCHEDULE_OPEN_STATUSES }, - }, - data: { - status: "DECLINED", - openForAppointmentId: null, - resolvedAt: new Date(), - }, - }); + await declineOpenReschedules(tx, appointmentId, auditMeta); return { success: true, diff --git a/app/api/appointments/[appointmentId]/reschedule/route.ts b/app/api/appointments/[appointmentId]/reschedule/route.ts index 5de129fbb..754337a77 100644 --- a/app/api/appointments/[appointmentId]/reschedule/route.ts +++ b/app/api/appointments/[appointmentId]/reschedule/route.ts @@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/auth-server"; import { isPrivileged } from "@/lib/auth-helpers"; import type { + Prisma, RescheduleInitiatorRole, ReschedulePreferredDays, ReschedulePreferredTimeOfDay, @@ -38,6 +39,11 @@ import { EVENT_ALLOWED_FROM, RESCHEDULABLE_FROM, SLOT_RESCHEDULABLE_FROM, + transitionClassEvent, + transitionConsultationRequest, + transitionSlotCompletion, + transitionSubscriptionRequest, + transitionWebinarEvent, } from "@/lib/booking/transitions"; import { isOrgAdminOfAppointment } from "@/lib/booking/org-actor"; import { IllegalTransitionError } from "@/lib/enterprise/transitions"; @@ -382,6 +388,36 @@ export async function POST( } } + // Audit attribution for every BookingStatusHistory row this + // reschedule writes (#1322 A12). `appointmentId` is added per call + // site: a whole-subscription or whole-class release moves slots of + // sibling appointments, whose history belongs on their own timeline. + const auditMeta = { + actorUserId: session.user.id, + reason: reason ?? null, + organizationId: appointment.organizationId, + }; + // Every slot flip below releases the row in place: RESCHEDULED plus + // tentative, never a tombstone. The reschedule keeps these rows — + // the proposal's releasedSlotIds point at them and a withdrawal + // restores them — so `deletedAt` is the cancel path's business only. + // + // From-state guard on every slot flip: a reschedule must never + // resurrect COMPLETED/CANCELLED history to RESCHEDULED (#837). It + // rides in `fromIn`, not in `where`, because the helper overwrites + // `completionStatus` in the caller's WHERE with its own from-set. + // `allowZero` keeps the pre-existing contract: a release that matches + // no live row answers 200 with `slotsAffected: 0`, not a 409. + const releaseSlots = (where: Prisma.SlotOfAppointmentWhereInput) => + transitionSlotCompletion(tx, { + ...auditMeta, + where, + to: "RESCHEDULED", + data: { isTentative: true }, + fromIn: SLOT_RESCHEDULABLE_FROM, + allowZero: true, + }); + // Mark the appropriate slots as tentative if ( slotIds && @@ -394,14 +430,8 @@ export async function POST( const affectedAppointmentIds = Array.from( new Set(slotsToReschedule.map((s) => s.appointmentId)), ); - // From-state guard on every slot flip: a reschedule must never - // resurrect COMPLETED/CANCELLED history to RESCHEDULED (#837). - await tx.slotOfAppointment.updateMany({ - where: { - appointmentId: { in: affectedAppointmentIds }, - completionStatus: { in: SLOT_RESCHEDULABLE_FROM }, - }, - data: { isTentative: true, completionStatus: "RESCHEDULED" }, + await releaseSlots({ + appointmentId: { in: affectedAppointmentIds }, }); } else if ( derivedType === "SUBSCRIPTION" && @@ -415,13 +445,7 @@ export async function POST( }) ).map((a) => a.id); - await tx.slotOfAppointment.updateMany({ - where: { - appointmentId: { in: allAppointmentIds }, - completionStatus: { in: SLOT_RESCHEDULABLE_FROM }, - }, - data: { isTentative: true, completionStatus: "RESCHEDULED" }, - }); + await releaseSlots({ appointmentId: { in: allAppointmentIds } }); } else if (derivedType === "CLASS" && appointment.class) { // Entire class reschedule - mark ALL slots in ALL appointments const allAppointmentIds = ( @@ -431,36 +455,24 @@ export async function POST( }) ).map((a) => a.id); - await tx.slotOfAppointment.updateMany({ - where: { - appointmentId: { in: allAppointmentIds }, - completionStatus: { in: SLOT_RESCHEDULABLE_FROM }, - }, - data: { isTentative: true, completionStatus: "RESCHEDULED" }, - }); + await releaseSlots({ appointmentId: { in: allAppointmentIds } }); } else { // Non-multi-appointment: mark all slots in the single appointment - await tx.slotOfAppointment.updateMany({ - where: { - appointmentId, - completionStatus: { in: SLOT_RESCHEDULABLE_FROM }, - }, - data: { isTentative: true, completionStatus: "RESCHEDULED" }, - }); + await releaseSlots({ appointmentId }); } - // Update status based on appointment type — CAS-guarded (B2): a - // reschedule racing a cancel/completion must not resurrect the - // booking; count 0 means the from-state was terminal → 409. The - // set lives in lib/booking/transitions.ts so the map is canonical. - let movedStatus = 1; // group events validated below - if (appointment.consultation) { - movedStatus = ( - await tx.consultation.updateMany({ - where: { - id: appointment.consultation.id, - status: { in: [...RESCHEDULABLE_FROM] }, - }, + // Update status based on appointment type — through the CAS helpers + // (B2): a reschedule racing a cancel/completion must not resurrect + // the booking, so the allowed-from set rides the WHERE and a zero-row + // match throws instead of writing. The set lives in + // lib/booking/transitions.ts so the map is canonical. + try { + if (appointment.consultation) { + await transitionConsultationRequest(tx, { + ...auditMeta, + appointmentId, + where: { id: appointment.consultation.id }, + to: "PENDING", // requestedAt rides along deliberately: the stale-request // expiry sweep keys its PENDING cohort on requestedAt, so a // reschedule re-entering PENDING must refresh the clock or the @@ -468,62 +480,67 @@ export async function POST( // booking older than 48h that is "stale", and the sweep would // terminalise (EXPIRED) and fully refund a live booking the // consultee is actively trying to move. - data: { status: "PENDING", requestedAt: new Date() }, - }) - ).count; - } else if (appointment.subscription) { - // #448 — a single/multi-session reschedule must NOT flip the WHOLE - // subscription to PENDING. Subscription has no per-session status; - // the affected slots already carry isTentative + RESCHEDULED, so the - // session-level state is captured there. Only a full-subscription - // reschedule (no slotIds) genuinely re-enters PENDING. The partial - // path still terminal-guards (count, no write): rescheduling a - // session of a cancelled/completed subscription stays a 409. - const isPartialSubscriptionReschedule = Boolean( - slotIds && slotIds.length > 0, - ); - movedStatus = isPartialSubscriptionReschedule - ? await tx.subscription.count({ + data: { requestedAt: new Date() }, + fromIn: [...RESCHEDULABLE_FROM], + }); + } else if (appointment.subscription) { + // #448 — a single/multi-session reschedule must NOT flip the WHOLE + // subscription to PENDING. Subscription has no per-session status; + // the affected slots already carry isTentative + RESCHEDULED, so the + // session-level state is captured there. Only a full-subscription + // reschedule (no slotIds) genuinely re-enters PENDING. The partial + // path still terminal-guards (count, no write): rescheduling a + // session of a cancelled/completed subscription stays a 409. + const isPartialSubscriptionReschedule = Boolean( + slotIds && slotIds.length > 0, + ); + if (isPartialSubscriptionReschedule) { + const live = await tx.subscription.count({ where: { id: appointment.subscription.id, status: { in: [...RESCHEDULABLE_FROM] }, }, - }) - : ( - await tx.subscription.updateMany({ - where: { - id: appointment.subscription.id, - status: { in: [...RESCHEDULABLE_FROM] }, - }, - // Same clock-refresh rationale as the consultation flip - // above: expirePendingSubscriptions keys on requestedAt. - data: { status: "PENDING", requestedAt: new Date() }, - }) - ).count; - } else if (appointment.webinar) { - // Explicit allowed-from (was notIn) — robust against future enum - // additions (#837). - movedStatus = ( - await tx.webinar.updateMany({ - where: { - id: appointment.webinar.id, - status: { in: EVENT_ALLOWED_FROM.SCHEDULED }, - }, - data: { status: "SCHEDULED" }, - }) - ).count; - } else if (appointment.class) { - movedStatus = ( - await tx.class.updateMany({ - where: { - id: appointment.class.id, - status: { in: CLASS_EVENT_ALLOWED_FROM.SCHEDULED }, - }, - data: { status: "SCHEDULED" }, - }) - ).count; - } - if (movedStatus === 0) { + }); + // No status moves on this path, so there is nothing to CAS and + // nothing to record; the throw only reuses the 409 below. + if (live === 0) { + throw new IllegalTransitionError("Subscription", "PENDING"); + } + } else { + await transitionSubscriptionRequest(tx, { + ...auditMeta, + appointmentId, + where: { id: appointment.subscription.id }, + to: "PENDING", + // Same clock-refresh rationale as the consultation flip + // above: expirePendingSubscriptions keys on requestedAt. + data: { requestedAt: new Date() }, + fromIn: [...RESCHEDULABLE_FROM], + }); + } + } else if (appointment.webinar) { + // Explicit allowed-from (was notIn) — robust against future enum + // additions (#837). + await transitionWebinarEvent(tx, { + ...auditMeta, + appointmentId, + where: { id: appointment.webinar.id }, + to: "SCHEDULED", + fromIn: EVENT_ALLOWED_FROM.SCHEDULED, + }); + } else if (appointment.class) { + await transitionClassEvent(tx, { + ...auditMeta, + appointmentId, + where: { id: appointment.class.id }, + to: "SCHEDULED", + fromIn: CLASS_EVENT_ALLOWED_FROM.SCHEDULED, + }); + } + } catch (err) { + // The helper's zero-row throw IS the old `movedStatus === 0`; the + // client contract stays NOT_RESCHEDULABLE. + if (!(err instanceof IllegalTransitionError)) throw err; throw Object.assign( new Error( "This appointment can no longer be rescheduled (already cancelled or completed).", diff --git a/docs/booking/05-troubleshooting-and-changelog.md b/docs/booking/05-troubleshooting-and-changelog.md index 45080c523..7a8b427f9 100644 --- a/docs/booking/05-troubleshooting-and-changelog.md +++ b/docs/booking/05-troubleshooting-and-changelog.md @@ -139,6 +139,14 @@ Wave 6 picks up the follow-ups that wave 5 left marked in the code. Each PR appe - **`sync-payment-earnings` reads the flag too.** The earnings healer accrues through the same `createEarningsFromPayment` the capture webhook uses, so the workflow now carries `RATE_CARD_SCOPED_RESOLUTION` in its environment. Set on Netlify alone, the same booking would have settled on the scoped card when the webhook caught it and on the org default when the healer did. - **Pin:** `__tests__/payments/rate-card-scoped-settlement.test.ts` drives the real resolver end to end and asserts the bps that land on the earnings rows — the plan-scoped card's split with the flag on, the org default's with it off, and no contract query at all when the sponsoring contract belongs to another org. +### PR E — the cancel and reschedule routes go through the CAS helpers (`fix/lifecycle-routes-cas-helpers`) + +- **A successful cancel wrote no history and left its slots holding the calendar.** The two most-used lifecycle routes carried seventeen raw status writes between them — eight in cancel, eight in reschedule, one in `lib/booking/reschedule-withdraw.ts` — so doctrine rule 1 was false exactly where it matters most. Each write did re-check the status in its own WHERE, so the compare-and-set itself was sound, but nothing appended the `BookingStatusHistory` row the helpers write, which is why that table is empty on production, and the cancelled slots were stamped CANCELLED without the `deletedAt` tombstone, so every reader that filters on `deletedAt: null` still saw the consultant as busy. Both routes now call `transitionConsultationRequest`, `transitionSubscriptionRequest`, `transitionWebinarEvent`, `transitionClassEvent`, `transitionSlotCompletion` and `transitionRescheduleRequest` with the same `tx`, and the raw count in the three files is zero. +- **The client contract is unchanged.** The helpers throw `IllegalTransitionError` where the routes used to read a zero count, so each route catches it and answers with the code it always answered: 409 `NOT_CANCELLABLE` on cancel, 409 `NOT_RESCHEDULABLE` on reschedule. Every WHERE guard was preserved as `fromIn` plus its non-status predicates, and the sweeps that legitimately match no live row — a cancel of a fully delivered booking, a release that finds nothing — pass `allowZero` rather than turning a 200 into a conflict. +- **Only the cancel path tombstones.** A cancelled slot is retired, so it takes `deletedAt` alongside the terminal status, which is the shape `cleanup-abandoned-payments` already writes. A rescheduled slot is kept: the proposal's `releasedSlotIds` point at those rows and a withdrawal restores them in place, so the reschedule route flips them to RESCHEDULED and tentative and never tombstones them. +- **Closing an open proposal reads before it writes.** `transitionRescheduleRequest` CASes one row by id, so the cancel route now reads the appointment's open proposals inside the transaction and moves each to DECLINED, letting the helper release `openForAppointmentId` and stamp `resolvedAt` itself. The proposal-expiry cron holds no appointment lock and can answer a proposal in between, so a lost CAS on this step is tolerated: either way the cancelled booking ends with no open proposal, and failing the cancel over it would be the wrong outcome. +- The pin extends `__tests__/payments/cancel-route-refund.test.ts`, the suite that already drives this route: a successful consultation cancel records the request's move with the acting user and one row per slot the update actually returned, the slot write carries the tombstone, and a lost CAS still answers 409 `NOT_CANCELLABLE` with no history row written. + ## Changelog: 2026-09-02 — wave 5 The wave-5 train (#1319) reconciles the original booking and maintenance audit briefs against everything that shipped in waves 1–4 and closes the residuals that survived. Each PR appends its own bullets here. diff --git a/lib/booking/reschedule-withdraw.ts b/lib/booking/reschedule-withdraw.ts index 7e3548dea..159c0c467 100644 --- a/lib/booking/reschedule-withdraw.ts +++ b/lib/booking/reschedule-withdraw.ts @@ -4,6 +4,7 @@ import { RESCHEDULE_OPEN_STATUSES, transitionConsultationRequest, transitionRescheduleRequest, + transitionSlotCompletion, transitionSubscriptionRequest, } from "@/lib/booking/transitions"; import { IllegalTransitionError } from "@/lib/enterprise/transitions"; @@ -71,20 +72,29 @@ export async function withdrawRescheduleRequest(args: { // deciding, this matches zero rows and throws rather than un-releasing // slots that a concurrent accept has already re-confirmed. await transitionRescheduleRequest(tx, { + actorUserId: withdrawnById, + appointmentId: request.appointmentId, where: { id: request.id }, to: "WITHDRAWN", data: { resolvedById: withdrawnById }, }); - // Reverses exactly what the reschedule did to these rows. - const result = await tx.slotOfAppointment.updateMany({ - where: { - id: { in: request.releasedSlotIds }, - completionStatus: "RESCHEDULED", - }, - data: { isTentative: false, completionStatus: "SCHEDULED" }, + // Reverses exactly what the reschedule did to these rows. The from-set + // rides in `fromIn` rather than the WHERE (the helper overwrites + // `completionStatus` there), and `allowZero` keeps the outcome below + // intact: restoring nothing means the released rows are gone, which is + // what an allocation replacing them does, not a lost CAS. + // No appointmentId: a whole-subscription reschedule releases slots across + // sibling appointments, so each row's history belongs to the appointment + // it actually sits on, not to the one the proposal was opened against. + restored = await transitionSlotCompletion(tx, { + actorUserId: withdrawnById, + where: { id: { in: request.releasedSlotIds } }, + to: "SCHEDULED", + data: { isTentative: false }, + fromIn: ["RESCHEDULED"], + allowZero: true, }); - restored = result.count; // A consultation reschedule sends the booking back to PENDING so it // re-enters the consultant's queue; withdrawing has to undo that or the @@ -97,6 +107,8 @@ export async function withdrawRescheduleRequest(args: { // re-stamped. if (request.appointment?.consultationId) { await transitionConsultationRequest(tx, { + actorUserId: withdrawnById, + appointmentId: request.appointmentId, where: { id: request.appointment.consultationId }, to: "APPROVED", fromIn: ["PENDING"], @@ -120,6 +132,8 @@ export async function withdrawRescheduleRequest(args: { }); if (sub?.status === "PENDING") { await transitionSubscriptionRequest(tx, { + actorUserId: withdrawnById, + appointmentId: request.appointmentId, where: { id: request.appointment.subscriptionId }, to: "APPROVED", fromIn: ["PENDING"], @@ -143,7 +157,7 @@ export async function withdrawRescheduleRequest(args: { throw err; } - // The updateMany filters on RESCHEDULED, so a row whose status drifted stays + // The CAS moves RESCHEDULED rows only, so a row whose status drifted stays // released while the request is already WITHDRAWN — a half-restored booking // that otherwise reports success and shows nothing anywhere. The withdrawal // itself is committed and correct, so this reports rather than throws. @@ -184,22 +198,30 @@ export async function withdrawRescheduleRequest(args: { appointmentType: true, consultation: { select: { - requestedBy: { select: { user: { select: { id: true, name: true } } } }, + requestedBy: { + select: { user: { select: { id: true, name: true } } }, + }, consultationPlan: { select: { title: true, - consultantProfile: { select: { user: { select: { id: true, name: true } } } }, + consultantProfile: { + select: { user: { select: { id: true, name: true } } }, + }, }, }, }, }, subscription: { select: { - requestedBy: { select: { user: { select: { id: true, name: true } } } }, + requestedBy: { + select: { user: { select: { id: true, name: true } } }, + }, subscriptionPlan: { select: { title: true, - consultantProfile: { select: { user: { select: { id: true, name: true } } } }, + consultantProfile: { + select: { user: { select: { id: true, name: true } } }, + }, }, }, }, @@ -235,11 +257,14 @@ export async function withdrawRescheduleRequest(args: { ); } } catch (notifyErr) { - reportSentryError(notifyErr instanceof Error ? notifyErr : new Error(String(notifyErr)), { - subsystem: "bookings", - op: "reschedule-withdraw-notify", - expected: true, - }); + reportSentryError( + notifyErr instanceof Error ? notifyErr : new Error(String(notifyErr)), + { + subsystem: "bookings", + op: "reschedule-withdraw-notify", + expected: true, + }, + ); } return { withdrawn: true };