diff --git a/__tests__/booking-algorithm/consultee-affordances.test.ts b/__tests__/booking-algorithm/consultee-affordances.test.ts new file mode 100644 index 000000000..5da1c5b15 --- /dev/null +++ b/__tests__/booking-algorithm/consultee-affordances.test.ts @@ -0,0 +1,22 @@ +import { + consulteeDestructiveAction, + consulteeMayReschedule, +} from "@/lib/appointments/consultee-affordances"; + +describe("#1005 consultee affordances", () => { + it("allows reschedule only for 1:1 kinds", () => { + expect(consulteeMayReschedule("CONSULTATION")).toBe(true); + expect(consulteeMayReschedule("SUBSCRIPTION")).toBe(true); + expect(consulteeMayReschedule("WEBINAR")).toBe(false); + expect(consulteeMayReschedule("CLASS")).toBe(false); + expect(consulteeMayReschedule("TRIAL")).toBe(false); + }); + + it("maps destructive actions by kind", () => { + expect(consulteeDestructiveAction("CONSULTATION")).toBe("cancel-booking"); + expect(consulteeDestructiveAction("SUBSCRIPTION")).toBe("cancel-booking"); + expect(consulteeDestructiveAction("TRIAL")).toBe("cancel-trial"); + expect(consulteeDestructiveAction("WEBINAR")).toBe("leave-event"); + expect(consulteeDestructiveAction("CLASS")).toBe("leave-event"); + }); +}); diff --git a/__tests__/booking-algorithm/contiguous-slot-run.test.ts b/__tests__/booking-algorithm/contiguous-slot-run.test.ts new file mode 100644 index 000000000..5c3a15964 --- /dev/null +++ b/__tests__/booking-algorithm/contiguous-slot-run.test.ts @@ -0,0 +1,363 @@ +/** + * #1071 — contiguous N×30min slot runs for planner create/update. + */ + +import "./setup"; + +import { + assertSingleContiguousLiveRun, + buildContiguousSlotAtoms, + replaceContiguousSlotRun, + SLOT_DURATION_MS, +} from "@/lib/appointments/contiguous-slot-run"; + +describe("buildContiguousSlotAtoms", () => { + const startsAt = new Date("2026-08-10T10:00:00.000Z"); + + it("creates one atom for a 30-minute session", () => { + const atoms = buildContiguousSlotAtoms({ + startsAt, + durationInHours: 0.5, + consultantProfileId: "cp_1", + }); + expect(atoms).toHaveLength(1); + expect(atoms[0].startsAt.toISOString()).toBe("2026-08-10T10:00:00.000Z"); + expect(atoms[0].endsAt.toISOString()).toBe("2026-08-10T10:30:00.000Z"); + }); + + it("creates four contiguous atoms for a 2-hour session", () => { + const atoms = buildContiguousSlotAtoms({ + startsAt, + durationInHours: 2, + consultantProfileId: "cp_1", + isTentative: false, + }); + expect(atoms).toHaveLength(4); + for (let i = 0; i < atoms.length; i++) { + const expectedStart = startsAt.getTime() + i * SLOT_DURATION_MS; + expect(atoms[i].startsAt.getTime()).toBe(expectedStart); + expect(atoms[i].endsAt.getTime()).toBe(expectedStart + SLOT_DURATION_MS); + } + for (let i = 1; i < atoms.length; i++) { + expect(atoms[i].startsAt.getTime()).toBe(atoms[i - 1].endsAt.getTime()); + } + }); + + it("creates two atoms for a 60-minute session (allocator parity)", () => { + const atoms = buildContiguousSlotAtoms({ + startsAt, + durationInHours: 1, + consultantProfileId: "cp_1", + }); + expect(atoms).toHaveLength(2); + expect(atoms[1].endsAt.getTime() - atoms[0].startsAt.getTime()).toBe( + 60 * 60 * 1000, + ); + }); + + it("attaches user connects when userIds are provided", () => { + const atoms = buildContiguousSlotAtoms({ + startsAt, + durationInHours: 1, + consultantProfileId: "cp_1", + userIds: ["u1", "u2", "u1"], + }); + expect(atoms[0].user?.connect).toEqual([{ id: "u1" }, { id: "u2" }]); + }); + + it("rejects non-positive duration", () => { + expect(() => + buildContiguousSlotAtoms({ + startsAt, + durationInHours: 0, + consultantProfileId: "cp_1", + }), + ).toThrow(/durationInHours/); + }); +}); + +describe("assertSingleContiguousLiveRun", () => { + it("accepts a contiguous 2-hour run", () => { + const startsAt = new Date("2026-08-10T10:00:00.000Z"); + const atoms = buildContiguousSlotAtoms({ + startsAt, + durationInHours: 2, + consultantProfileId: "cp_1", + }); + expect(() => + assertSingleContiguousLiveRun( + atoms.map((a, i) => ({ + id: `s${i}`, + appointmentId: "a1", + startsAt: a.startsAt, + endsAt: a.endsAt, + isTentative: false, + completionStatus: "SCHEDULED", + })), + ), + ).not.toThrow(); + }); + + it("rejects the old #1071 failure mode (first atom moved, rest stranded)", () => { + expect(() => + assertSingleContiguousLiveRun([ + { + id: "s0", + appointmentId: "a1", + startsAt: new Date("2026-08-14T10:00:00.000Z"), + endsAt: new Date("2026-08-14T10:30:00.000Z"), + completionStatus: "SCHEDULED", + }, + { + id: "s1", + appointmentId: "a1", + startsAt: new Date("2026-08-10T10:30:00.000Z"), + endsAt: new Date("2026-08-10T11:00:00.000Z"), + completionStatus: "SCHEDULED", + }, + { + id: "s2", + appointmentId: "a1", + startsAt: new Date("2026-08-10T11:00:00.000Z"), + endsAt: new Date("2026-08-10T11:30:00.000Z"), + completionStatus: "SCHEDULED", + }, + ]), + ).toThrow(/exactly one contiguous run/); + }); + + it("ignores CANCELLED / RESCHEDULED rows when checking contiguity", () => { + expect(() => + assertSingleContiguousLiveRun([ + { + id: "dead", + appointmentId: "a1", + startsAt: new Date("2026-08-01T10:00:00.000Z"), + endsAt: new Date("2026-08-01T10:30:00.000Z"), + completionStatus: "RESCHEDULED", + }, + { + id: "s0", + appointmentId: "a1", + startsAt: new Date("2026-08-10T10:00:00.000Z"), + endsAt: new Date("2026-08-10T10:30:00.000Z"), + completionStatus: "SCHEDULED", + }, + { + id: "s1", + appointmentId: "a1", + startsAt: new Date("2026-08-10T10:30:00.000Z"), + endsAt: new Date("2026-08-10T11:00:00.000Z"), + completionStatus: "SCHEDULED", + }, + ]), + ).not.toThrow(); + }); +}); + +describe("replaceContiguousSlotRun", () => { + function stubTx(liveRows: Array>) { + const updates: Array<{ id: string; data: Record }> = []; + const creates: Array> = []; + const updateManyCalls: Array<{ + where: { id: { in: string[] } }; + data: Record; + }> = []; + let findManyCalls = 0; + return { + updates, + creates, + updateManyCalls, + tx: { + slotOfAppointment: { + findMany: jest.fn(async () => { + findManyCalls += 1; + if (findManyCalls === 1) return liveRows; + // Post-write live read — synthesise from creates + updated times. + const retired = new Set( + updates + .filter((u) => u.data.completionStatus === "RESCHEDULED") + .map((u) => u.id), + ); + const isLive = (r: Record) => + !r.deletedAt && + r.completionStatus !== "CANCELLED" && + r.completionStatus !== "RESCHEDULED" && + !retired.has(r.id as string); + const kept = liveRows.filter(isLive).map((r) => { + const upd = [...updates].reverse().find((u) => u.id === r.id); + return upd ? { ...r, ...upd.data } : r; + }); + return [...kept, ...creates].sort( + (a, b) => + new Date(a.startsAt as Date).getTime() - + new Date(b.startsAt as Date).getTime(), + ); + }), + updateMany: jest.fn( + async ({ + where, + data, + }: { + where: { id: { in: string[] } }; + data: Record; + }) => { + updateManyCalls.push({ where, data }); + for (const id of where.id.in) { + updates.push({ id, data }); + } + return { count: where.id.in.length }; + }, + ), + update: jest.fn( + async ({ + where, + data, + }: { + where: { id: string }; + data: Record; + }) => { + updates.push({ id: where.id, data }); + return {}; + }, + ), + create: jest.fn(async ({ data }: { data: Record }) => { + creates.push({ id: `new-${creates.length}`, ...data }); + return {}; + }), + }, + }, + }; + } + + it("updates overlapping live rows in place and soft-retires surplus", async () => { + const startsAt = new Date("2026-08-10T10:00:00.000Z"); + const liveRows = [ + { + id: "s0", + startsAt, + endsAt: new Date("2026-08-10T10:30:00.000Z"), + completionStatus: "SCHEDULED", + deletedAt: null, + user: [{ id: "u1" }], + }, + { + id: "s1", + startsAt: new Date("2026-08-10T10:30:00.000Z"), + endsAt: new Date("2026-08-10T11:00:00.000Z"), + completionStatus: "SCHEDULED", + deletedAt: null, + user: [{ id: "u1" }], + }, + { + id: "dead", + startsAt: new Date("2026-08-01T10:00:00.000Z"), + endsAt: new Date("2026-08-01T10:30:00.000Z"), + completionStatus: "RESCHEDULED", + deletedAt: null, + user: [{ id: "u2" }], + }, + ]; + const { tx, updates, creates } = stubTx(liveRows); + + const result = await replaceContiguousSlotRun(tx as never, { + appointmentId: "a1", + startsAt: new Date("2026-08-14T12:00:00.000Z"), + durationInHours: 0.5, + consultantProfileId: "cp_1", + }); + + // One live atom kept (updated), one surplus soft-retired, dead untouched. + // Assert startsAt — updateMany's tentative pre-pass also touches s0. + expect( + updates.some( + (u) => + u.id === "s0" && + (u.data.startsAt as Date | undefined)?.toISOString() === + "2026-08-14T12:00:00.000Z", + ), + ).toBe(true); + expect( + updates.some( + (u) => u.id === "s1" && u.data.completionStatus === "RESCHEDULED", + ), + ).toBe(true); + expect(updates.some((u) => u.id === "dead")).toBe(false); + expect(creates).toHaveLength(0); + expect(result.preservedUserIds).toEqual(["u1"]); + expect(result.createdCount).toBe(1); + }); + + it("creates extra atoms when duration grows and preserves user ids", async () => { + const liveRows = [ + { + id: "s0", + startsAt: new Date("2026-08-10T10:00:00.000Z"), + endsAt: new Date("2026-08-10T10:30:00.000Z"), + completionStatus: "SCHEDULED", + deletedAt: null, + user: [{ id: "host" }, { id: "buyer" }], + }, + ]; + const { tx, updates, creates } = stubTx(liveRows); + + const result = await replaceContiguousSlotRun(tx as never, { + appointmentId: "a1", + startsAt: new Date("2026-08-10T10:00:00.000Z"), + durationInHours: 1, + consultantProfileId: "cp_1", + }); + + expect( + updates.some( + (u) => + u.id === "s0" && + (u.data.startsAt as Date | undefined)?.toISOString() === + "2026-08-10T10:00:00.000Z", + ), + ).toBe(true); + expect(creates).toHaveLength(1); + expect(result.preservedUserIds.sort()).toEqual(["buyer", "host"]); + expect(result.createdCount).toBe(2); + }); + + it("tentative-flips the whole live run before an overlapping forward shift", async () => { + // 2h @ 10:00 → 11:00: without the pre-pass, updating s0 to [11:00,11:30) + // collides with s2 still holding that window under slot_no_confirmed_overlap. + const liveRows = [0, 1, 2, 3].map((i) => ({ + id: `s${i}`, + startsAt: new Date(`2026-08-10T${10 + Math.floor(i / 2)}:${i % 2 === 0 ? "00" : "30"}:00.000Z`), + endsAt: new Date( + `2026-08-10T${10 + Math.floor((i + 1) / 2)}:${(i + 1) % 2 === 0 ? "00" : "30"}:00.000Z`, + ), + completionStatus: "SCHEDULED", + deletedAt: null, + user: [], + })); + const { tx, updateManyCalls, updates } = stubTx(liveRows); + + await replaceContiguousSlotRun(tx as never, { + appointmentId: "a1", + startsAt: new Date("2026-08-10T11:00:00.000Z"), + durationInHours: 2, + consultantProfileId: "cp_1", + isTentative: false, + }); + + expect(updateManyCalls).toHaveLength(1); + expect(updateManyCalls[0].data).toEqual({ isTentative: true }); + expect(updateManyCalls[0].where.id.in.sort()).toEqual([ + "s0", + "s1", + "s2", + "s3", + ]); + // Per-row restores must follow the tentative pre-pass (first 4 updates + // are the updateMany fan-out in the stub). + const restoreIdx = updates.findIndex( + (u) => u.id === "s0" && u.data.startsAt instanceof Date, + ); + expect(restoreIdx).toBeGreaterThanOrEqual(4); + expect(updates[restoreIdx].data.isTentative).toBe(false); + }); +}); diff --git a/__tests__/booking-algorithm/expected-tentative-count.test.ts b/__tests__/booking-algorithm/expected-tentative-count.test.ts new file mode 100644 index 000000000..cfebbff04 --- /dev/null +++ b/__tests__/booking-algorithm/expected-tentative-count.test.ts @@ -0,0 +1,279 @@ +/** + * #1012 — expectedTentativeSlotCount stale-tab reschedule precondition. + */ + +import "./setup"; + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + $transaction: jest.fn(), + consultation: { findUnique: jest.fn() }, + subscription: { findUnique: jest.fn() }, + webinar: { findUnique: jest.fn() }, + class: { findUnique: jest.fn() }, + appointment: { findMany: jest.fn(), findFirst: jest.fn() }, + slotOfAppointment: { count: jest.fn() }, + }, + ALLOCATION_TX_MAX_WAIT_MS: 8000, + ALLOCATION_TX_TIMEOUT_MS: 30000, +})); + +const mockValidateFn = jest.fn(); +const mockRevalidateConflictsFn = jest.fn(); +jest.mock("../../utils/slotAllocation/SlotValidationService", () => ({ + ...jest.requireActual("../../utils/slotAllocation/SlotValidationService"), + SlotValidationService: jest.fn().mockImplementation(() => ({ + validate: mockValidateFn, + revalidateConflicts: mockRevalidateConflictsFn, + })), +})); + +jest.mock("../../utils/appointmentlock", () => ({ + lockAutoAllocate: jest + .fn() + .mockResolvedValue({ key: "mock-key", value: "mock-value" }), + unlockAutoAllocate: jest.fn().mockResolvedValue(undefined), + lockConsulteeBooking: jest + .fn() + .mockResolvedValue({ key: "mock-consultee-key", value: "mock-value" }), + unlockConsulteeBooking: jest.fn().mockResolvedValue(undefined), + lockManualAllocate: jest + .fn() + .mockResolvedValue({ key: "mock-manual-key", value: "mock-value" }), + unlockManualAllocate: jest.fn().mockResolvedValue(undefined), +})); + +import prisma from "@/lib/prisma"; +import { SlotAllocationService } from "@/utils/slotAllocation/SlotAllocationService"; + +const mockPrisma = prisma as unknown as { + $transaction: jest.Mock; + subscription: { findUnique: jest.Mock }; + appointment: { findMany: jest.Mock; findFirst: jest.Mock }; + slotOfAppointment: { count: jest.Mock }; +}; + +const FUTURE_SLOTS = [ + "2026-08-03T09:00:00.000Z", + "2026-08-03T09:30:00.000Z", +]; + +const richSubscription = { + subscriptionPlan: { + consultantProfileId: "cp-1", + consultantProfile: { + user: { id: "consultant-user-1" }, + scheduleType: "WEEKLY", + slotsOfAvailabilityWeekly: [], + slotsOfAvailabilityCustom: [], + }, + durationInMonths: 1, + sessionsPerWeek: 1, + sessionDurationInHours: 1, + totalSessions: 1, + }, + requestedBy: { user: { id: "user-1" } }, + appointments: [], + schedulingPeriodStartsAt: new Date("2026-08-02T00:00:00.000Z"), + schedulingPeriodEndsAt: new Date("2026-08-29T23:59:59.000Z"), + schedulingTimezone: "Asia/Kolkata", +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.subscription.findUnique.mockResolvedValue(richSubscription); + mockPrisma.appointment.findFirst.mockResolvedValue(null); + mockPrisma.slotOfAppointment.count.mockResolvedValue(0); +}); + +describe("#1012 expectedTentativeSlotCount", () => { + it("returns 409 when the page's tentative count no longer matches", async () => { + // First tab already finished: zero tentative, two confirmed. + mockPrisma.appointment.findMany.mockResolvedValue([ + { + id: "appt-1", + slotsOfAppointment: [ + { + id: "s1", + startsAt: new Date(FUTURE_SLOTS[0]), + endsAt: new Date("2026-08-03T09:30:00.000Z"), + isTentative: false, + }, + { + id: "s2", + startsAt: new Date(FUTURE_SLOTS[1]), + endsAt: new Date("2026-08-03T10:00:00.000Z"), + isTentative: false, + }, + ], + }, + ]); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-1", + mode: "manual", + slots: FUTURE_SLOTS, + // Stale tab still thinks the reschedule has 2 tentative slots. + expectedTentativeSlotCount: 2, + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(409); + expect(result.error).toMatch(/Reschedule state changed/); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it("proceeds past the precondition when the tentative count matches", async () => { + mockPrisma.appointment.findMany.mockResolvedValue([ + { + id: "appt-1", + slotsOfAppointment: [ + { + id: "s1", + startsAt: new Date(FUTURE_SLOTS[0]), + endsAt: new Date("2026-08-03T09:30:00.000Z"), + isTentative: true, + }, + { + id: "s2", + startsAt: new Date(FUTURE_SLOTS[1]), + endsAt: new Date("2026-08-03T10:00:00.000Z"), + isTentative: true, + }, + ], + }, + ]); + + // Validation will fail later (incomplete mock fixture) — we only assert + // that the #1012 guard did NOT 409 on a matching count. + mockValidateFn.mockResolvedValue({ + isValid: false, + errors: ["fixture incomplete"], + warnings: [], + }); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-1", + mode: "manual", + slots: FUTURE_SLOTS, + expectedTentativeSlotCount: 2, + }); + + expect(result.httpStatus).not.toBe(409); + expect(result.error ?? "").not.toMatch(/Reschedule state changed/); + }); + + it("skips the precondition when expectedTentativeSlotCount is omitted", async () => { + mockPrisma.appointment.findMany.mockResolvedValue([ + { + id: "appt-1", + slotsOfAppointment: [ + { + id: "s1", + startsAt: new Date(FUTURE_SLOTS[0]), + endsAt: new Date("2026-08-03T09:30:00.000Z"), + isTentative: false, + }, + ], + }, + ]); + mockValidateFn.mockResolvedValue({ + isValid: false, + errors: ["fixture incomplete"], + warnings: [], + }); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-1", + mode: "manual", + slots: FUTURE_SLOTS, + }); + + expect(result.error ?? "").not.toMatch(/Reschedule state changed/); + }); + + it("re-asserts the tentative count inside the write transaction", async () => { + // Pre-txn view still matches (2 tentative) so we enter the write txn; + // inside the txn another tab already confirmed — in-txn re-read 409s. + const matchingTentative = [ + { + id: "appt-1", + slotsOfAppointment: [ + { + id: "s1", + startsAt: new Date(FUTURE_SLOTS[0]), + endsAt: new Date("2026-08-03T09:30:00.000Z"), + isTentative: true, + }, + { + id: "s2", + startsAt: new Date(FUTURE_SLOTS[1]), + endsAt: new Date("2026-08-03T10:00:00.000Z"), + isTentative: true, + }, + ], + }, + ]; + const confirmedAfterRace = [ + { + id: "appt-1", + slotsOfAppointment: [ + { + id: "s1", + startsAt: new Date(FUTURE_SLOTS[0]), + endsAt: new Date("2026-08-03T09:30:00.000Z"), + isTentative: false, + }, + { + id: "s2", + startsAt: new Date(FUTURE_SLOTS[1]), + endsAt: new Date("2026-08-03T10:00:00.000Z"), + isTentative: false, + }, + ], + }, + ]; + mockPrisma.appointment.findMany.mockResolvedValue(matchingTentative); + mockValidateFn.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [], + }); + mockRevalidateConflictsFn.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [], + }); + + mockPrisma.$transaction.mockImplementation(async (fn: (tx: unknown) => Promise) => { + const tx = { + $queryRaw: jest.fn().mockResolvedValue(undefined), + appointment: { + findMany: jest.fn().mockResolvedValue(confirmedAfterRace), + findFirst: jest.fn().mockResolvedValue(null), + }, + slotOfAppointment: { + count: jest.fn().mockResolvedValue(0), + }, + }; + return fn(tx); + }); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-1", + mode: "manual", + slots: FUTURE_SLOTS, + expectedTentativeSlotCount: 2, + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(409); + expect(result.error).toMatch(/Reschedule state changed/); + expect(mockPrisma.$transaction).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/payments/attendee-removal-refund.test.ts b/__tests__/payments/attendee-removal-refund.test.ts index 04a7577a0..cb51bb600 100644 --- a/__tests__/payments/attendee-removal-refund.test.ts +++ b/__tests__/payments/attendee-removal-refund.test.ts @@ -20,6 +20,7 @@ */ const mockPaymentFindFirst = jest.fn(); +const mockSlotFindFirst = jest.fn(); const mockRefundBookingPayment = jest.fn(); const mockNotifyRefundProcessed = jest.fn(); const mockRecordSystemError = jest.fn(); @@ -32,6 +33,9 @@ jest.mock("../../lib/prisma", () => ({ findFirst: (...a: unknown[]) => mockPaymentFindFirst(...a), findMany: jest.fn().mockResolvedValue([]), }, + slotOfAppointment: { + findFirst: (...a: unknown[]) => mockSlotFindFirst(...a), + }, }, })); @@ -97,6 +101,9 @@ function seat( beforeEach(() => { jest.clearAllMocks(); mockPaymentFindFirst.mockResolvedValue(seat("pay_ABC")); + mockSlotFindFirst.mockResolvedValue({ + startsAt: new Date(Date.now() + 48 * 60 * 60 * 1000), + }); mockRefundBookingPayment.mockResolvedValue({ refundId: "r1", amountRefundedPaise: 50_000, @@ -294,4 +301,48 @@ describe("refundRemovedAttendeeSeat", () => { expect(mockReportSentryError).toHaveBeenCalled(); expect(mockRecordSystemError).toHaveBeenCalled(); }); + + it("self-leave after start refunds 0% under attendee notice tiers (#1005)", async () => { + // No upcoming live slot (startsAt >= now) → hoursUntilStart stays -1 → 0%. + mockSlotFindFirst.mockResolvedValue(null); + + const result = await refundRemovedAttendeeSeat({ + kind: "webinar", + eventId: "web-1", + attendeeUserId: ATTENDEE, + initiatedByUserId: ATTENDEE, + initiatedBy: "attendee", + }); + + expect(result).toEqual({ amountRefundedPaise: 0, refundPct: 0 }); + expect(mockRefundBookingPayment).not.toHaveBeenCalled(); + expect(mockSlotFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + startsAt: expect.objectContaining({ gte: expect.any(Date) }), + }), + }), + ); + }); + + it("mid-program class self-leave uses the next future session for notice", async () => { + const nextStart = new Date(Date.now() + 72 * 60 * 60 * 1000); + mockSlotFindFirst.mockResolvedValue({ startsAt: nextStart }); + + const result = await refundRemovedAttendeeSeat({ + kind: "class", + eventId: "class-1", + attendeeUserId: ATTENDEE, + initiatedByUserId: ATTENDEE, + initiatedBy: "attendee", + }); + + // Default policy: ≥48h notice → full attendee tier (100% under platform defaults). + expect(result).toEqual({ amountRefundedPaise: 50_000, refundPct: 100 }); + expect(mockRefundBookingPayment).toHaveBeenCalledWith( + expect.objectContaining({ + reason: expect.stringContaining("by the attendee"), + }), + ); + }); }); diff --git a/app/api/bookings/classes/[classId]/allocate/route.ts b/app/api/bookings/classes/[classId]/allocate/route.ts index fba087403..5c9c71df1 100644 --- a/app/api/bookings/classes/[classId]/allocate/route.ts +++ b/app/api/bookings/classes/[classId]/allocate/route.ts @@ -91,6 +91,7 @@ export async function PATCH( // the first batch instead of allocating twice. idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, initialAllocation: body.initialAllocation, + expectedTentativeSlotCount: body.expectedTentativeSlotCount, // Honoured only for the consultant (or ADMIN/STAFF): accepting a // time outside the published availability is the consultant's call, // not something a consultee may assert about someone else's schedule. diff --git a/app/api/bookings/classes/crud-with-plan/route.ts b/app/api/bookings/classes/crud-with-plan/route.ts index 9954685bb..80d4eda88 100644 --- a/app/api/bookings/classes/crud-with-plan/route.ts +++ b/app/api/bookings/classes/crud-with-plan/route.ts @@ -20,6 +20,7 @@ import { import { getSession } from "@/lib/auth-server"; import { resolveSchedulingTimezone } from "@/lib/scheduling/schedulingTimezone"; +import { buildContiguousSlotAtoms } from "@/lib/appointments/contiguous-slot-run"; // Schema for class content input (without Prisma-managed fields like createdAt, updatedAt, classPlanId) const ClassContentInputSchema = ClassContentSchema.omit({ createdAt: true, @@ -269,24 +270,17 @@ export async function POST(request: NextRequest) { appointmentDate.getDate() + weekOffset + dayWithinWeek, ); const slotStart = new Date(appointmentDate); - const slotEnd = new Date(appointmentDate); - slotEnd.setTime( - slotEnd.getTime() + - sessionDurationInHours * 60 * 60 * 1000, - ); + // #1071 — N×30min atoms per session (allocator parity). return { appointmentType: "CLASS", slotsOfAppointment: { - create: { + create: buildContiguousSlotAtoms({ startsAt: slotStart, - endsAt: slotEnd, - isTentative: true, // Mark as tentative until confirmed - // #784 — owner denormalized so the overlap exclusion - // guards the host once the session is confirmed - // (constraint applies WHERE NOT isTentative). + durationInHours: sessionDurationInHours, consultantProfileId, - }, + isTentative: true, + }), }, }; }) @@ -378,6 +372,26 @@ export async function PATCH(request: NextRequest) { JSON.stringify(body, null, 2), ); + // Grandfather legacy non-30-min session durations on unrelated PATCHes. + // ClassPlanSchema now rejects non-aligned values, but the planner often + // re-sends the full form — without this, editing title/price on a 0.75h + // plan would 400 even though duration is unchanged (#1071 / PR #1091). + if ( + typeof body?.id === "string" && + typeof body?.sessionDurationInHours === "number" + ) { + const existingDuration = await prisma.classPlan.findUnique({ + where: { id: body.id }, + select: { sessionDurationInHours: true }, + }); + if ( + existingDuration && + body.sessionDurationInHours === existingDuration.sessionDurationInHours + ) { + delete body.sessionDurationInHours; + } + } + // --- Zod Validation --- const validationResult = PatchClassWithPlanBodySchema.safeParse(body); diff --git a/app/api/bookings/consultations/[consultationId]/allocate/route.ts b/app/api/bookings/consultations/[consultationId]/allocate/route.ts index 6bd51655c..6518c1efc 100644 --- a/app/api/bookings/consultations/[consultationId]/allocate/route.ts +++ b/app/api/bookings/consultations/[consultationId]/allocate/route.ts @@ -92,6 +92,7 @@ export async function PATCH( // the first batch instead of allocating twice. idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, initialAllocation: body.initialAllocation, + expectedTentativeSlotCount: body.expectedTentativeSlotCount, // Honoured only for the consultant (or ADMIN/STAFF): accepting a // time outside the published availability is the consultant's call, // not something a consultee may assert about someone else's schedule. diff --git a/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts b/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts index 7037bc0eb..893648054 100644 --- a/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts +++ b/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts @@ -91,6 +91,7 @@ export async function PATCH( // the first batch instead of allocating twice. idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, initialAllocation: body.initialAllocation, + expectedTentativeSlotCount: body.expectedTentativeSlotCount, // Honoured only for the consultant (or ADMIN/STAFF): accepting a // time outside the published availability is the consultant's call, // not something a consultee may assert about someone else's schedule. diff --git a/app/api/bookings/webinars/[webinarId]/allocate/route.ts b/app/api/bookings/webinars/[webinarId]/allocate/route.ts index b03f989b8..cb0d0ded1 100644 --- a/app/api/bookings/webinars/[webinarId]/allocate/route.ts +++ b/app/api/bookings/webinars/[webinarId]/allocate/route.ts @@ -91,6 +91,7 @@ export async function PATCH( // the first batch instead of allocating twice. idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, initialAllocation: body.initialAllocation, + expectedTentativeSlotCount: body.expectedTentativeSlotCount, // Honoured only for the consultant (or ADMIN/STAFF): accepting a // time outside the published availability is the consultant's call, // not something a consultee may assert about someone else's schedule. diff --git a/app/api/bookings/webinars/crud-with-plan/route.ts b/app/api/bookings/webinars/crud-with-plan/route.ts index 4070581e3..6ca379d23 100644 --- a/app/api/bookings/webinars/crud-with-plan/route.ts +++ b/app/api/bookings/webinars/crud-with-plan/route.ts @@ -20,6 +20,34 @@ import { CollaboratorUnavailableError, } from "@/lib/collaborators/availability"; import { isExclusionViolation } from "@/lib/db/pg-errors"; +import { + buildContiguousSlotAtoms, + replaceContiguousSlotRun, +} from "@/lib/appointments/contiguous-slot-run"; +import { isDeadSlot } from "@/lib/appointments/slots"; + +/** + * Nested include for "what is the current live run?". + * + * Ordering by `startsAt` alone is not enough: the consultee reschedule route + * leaves replaced atoms in place as `RESCHEDULED`, so `[0]` becomes the *old* + * earlier dead row. Duration-only planner edits then rewrote the live run back + * onto the cancelled time. Filter at the query (and again with `isDeadSlot` + * when reading already-loaded arrays) so runStart/runEnd are always live. + * + * `completionStatus` is `SlotCompletionStatus @default(SCHEDULED)` — never + * NULL — so a plain `notIn` is enough (SQL's NULL/`NOT IN` caveat does not + * apply). `satisfies` keeps the hoisted literal contextually typed as + * Prisma's nested-args shape; without it `notIn: string[]` widens and poisons + * the whole webinar include inference. + */ +const LIVE_SLOTS_INCLUDE = { + orderBy: { startsAt: "asc" as const }, + where: { + deletedAt: null, + completionStatus: { notIn: ["CANCELLED", "RESCHEDULED"] }, + }, +} satisfies Prisma.Appointment$slotsOfAppointmentArgs; import { getSession } from "@/lib/auth-server"; // Schema for POST request body based on WebinarPlanSchema @@ -269,18 +297,17 @@ export async function POST(request: NextRequest) { appointment: startTime && endTime ? { - // Check if dates were successfully calculated + // #1071 — N×30min atoms (same shape as SlotAllocationService), + // never one long row spanning the full duration. create: { appointmentType: "WEBINAR", slotsOfAppointment: { - create: { - startsAt: startTime, // Use calculated startTime - endsAt: endTime, // Use calculated endTime - isTentative: false, - // #784 — owner denormalized so the slot overlap - // exclusion guards the host on group events too. + create: buildContiguousSlotAtoms({ + startsAt: startTime, + durationInHours, consultantProfileId, - }, + isTentative: false, + }), }, }, } @@ -460,7 +487,8 @@ export async function PATCH(request: NextRequest) { include: { appointment: { include: { - slotsOfAppointment: true, + // #1071 — live rows only; dead RESCHEDULED must not own [0]. + slotsOfAppointment: LIVE_SLOTS_INCLUDE, }, }, }, @@ -493,7 +521,7 @@ export async function PATCH(request: NextRequest) { include: { appointment: { include: { - slotsOfAppointment: true, + slotsOfAppointment: LIVE_SLOTS_INCLUDE, }, }, }, @@ -538,9 +566,9 @@ export async function PATCH(request: NextRequest) { throw new Error("Invalid duration for calculating end time."); } - const endTimeDate = new Date(scheduledDate); - endTimeDate.setHours(endTimeDate.getHours() + effectiveDuration); - endTime = endTimeDate; + endTime = new Date( + scheduledDate.getTime() + effectiveDuration * 60 * 60 * 1000, + ); console.log("Calculated slot times (PATCH):", { startTime: startTime.toISOString(), @@ -549,21 +577,26 @@ export async function PATCH(request: NextRequest) { }); } else if ( durationInHours !== undefined && - webinarToUpdate?.appointment?.slotsOfAppointment[0] + webinarToUpdate?.appointment?.slotsOfAppointment?.length ) { - // Handle case where only duration changes, recalculate end time based on existing start time - const existingSlot = webinarToUpdate.appointment.slotsOfAppointment[0]; - startTime = existingSlot.startsAt; // Keep existing start time - endTime = new Date(startTime); - endTime.setHours(startTime.getHours() + durationInHours); - console.log( - "Recalculated slot end time due to duration change (PATCH):", - { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - newDuration: durationInHours, - }, + // Duration-only change: keep the live run's earliest start. + const existingSlot = webinarToUpdate.appointment.slotsOfAppointment.find( + (s) => !isDeadSlot(s), ); + if (existingSlot) { + startTime = existingSlot.startsAt; + endTime = new Date( + startTime.getTime() + durationInHours * 60 * 60 * 1000, + ); + console.log( + "Recalculated slot end time due to duration change (PATCH):", + { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + newDuration: durationInHours, + }, + ); + } } // FIX #626/#628: Guard against unsafe edits on webinars with confirmed bookings. @@ -582,13 +615,16 @@ export async function PATCH(request: NextRequest) { // Only block when the time ACTUALLY differs from the current slot // (the planner client may always send scheduledAt even for non-time edits). if (activePayments > 0 && (startTime || endTime)) { - const existingSlot = - webinarToUpdate.appointment?.slotsOfAppointment?.[0]; + const existingSlots = ( + webinarToUpdate.appointment?.slotsOfAppointment ?? [] + ).filter((s) => !isDeadSlot(s)); + const runStart = existingSlots[0]?.startsAt; + const runEnd = existingSlots[existingSlots.length - 1]?.endsAt; const timeChanged = - !existingSlot || - (startTime && - existingSlot.startsAt.getTime() !== startTime.getTime()) || - (endTime && existingSlot.endsAt.getTime() !== endTime.getTime()); + !runStart || + !runEnd || + (startTime && runStart.getTime() !== startTime.getTime()) || + (endTime && runEnd.getTime() !== endTime.getTime()); if (timeChanged) { return NextResponse.json( @@ -602,6 +638,9 @@ export async function PATCH(request: NextRequest) { } } + const effectiveDurationForSlots = + durationInHours ?? existingPlan.durationInHours; + // Update webinar plan and related data in a transaction const result = await prisma.$transaction( async (tx) => { @@ -752,9 +791,8 @@ export async function PATCH(request: NextRequest) { }); } - // 8. Update appointment slot if startTime and endTime were calculated + // 8. Replace the appointment's live slot run (#1071) when times change. if (startTime && endTime) { - // Check if recalculation happened const appointment = updatedWebinar.appointment; // AE-2 (#784) — block (re)scheduling onto a time any ACCEPTED co-host @@ -768,73 +806,61 @@ export async function PATCH(request: NextRequest) { excludeAppointmentId: appointment?.id ?? null, }); + // Validate here (→ 400 in catch) instead of letting + // buildContiguousSlotAtoms throw a generic Error (→ 500). TypeError + // also satisfies Sonar's "use TypeError for type checks" hint. + if ( + typeof effectiveDurationForSlots !== "number" || + !Number.isFinite(effectiveDurationForSlots) || + effectiveDurationForSlots <= 0 + ) { + throw new TypeError( + "Invalid duration for rewriting contiguous slot run.", + ); + } + // Prefer the PATCH-requested owner when transferring the plan so + // rewritten atoms land on the new consultant's calendar (and + // slot_no_confirmed_overlap protects the right profile). + const ownerProfileId = + consultantProfileId ?? existingPlan.consultantProfileId; + if (!ownerProfileId) { + throw new Error( + "Webinar plan is missing consultantProfileId; cannot rewrite slots.", + ); + } + if (appointment) { - // Update existing appointment slots - if ( - appointment.slotsOfAppointment && - appointment.slotsOfAppointment.length > 0 - ) { - const slot = appointment.slotsOfAppointment[0]; - - console.log("Updating existing slot:", { - slotId: slot.id, - oldStartTime: slot.startsAt, - oldEndTime: slot.endsAt, - newStartTime: startTime, - newEndTime: endTime, - }); - - await tx.slotOfAppointment.update({ - where: { id: slot.id }, - data: { - startsAt: startTime, - endsAt: endTime, - // #784 — denormalize the owner so slot_no_confirmed_overlap - // guards the host against double-booking on group events too - // (group slots were NULL here, leaving the owner unprotected). - consultantProfileId: existingPlan.consultantProfileId, - }, - }); - } else { - // Create a new slot if none exists - console.log("Creating new slot for appointment:", { - appointmentId: appointment.id, - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - }); - - await tx.slotOfAppointment.create({ - data: { - appointmentId: appointment.id, - startsAt: startTime, - endsAt: endTime, - isTentative: false, - // #784 — owner denormalized for the overlap exclusion guard. - consultantProfileId: existingPlan.consultantProfileId, - }, - }); - } + console.log("Replacing contiguous slot run (#1071):", { + appointmentId: appointment.id, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + durationInHours: effectiveDurationForSlots, + }); + + await replaceContiguousSlotRun(tx, { + appointmentId: appointment.id, + startsAt: startTime, + durationInHours: effectiveDurationForSlots, + consultantProfileId: ownerProfileId, + isTentative: false, + }); } else { - // Create a new appointment and slot if no appointment exists - console.log("Creating new appointment and slot for webinar"); + console.log("Creating new appointment + contiguous slot run"); - const newAppointment = await tx.appointment.create({ + await tx.appointment.create({ data: { webinar: { connect: { id: updatedWebinar.id } }, appointmentType: "WEBINAR", slotsOfAppointment: { - create: { + create: buildContiguousSlotAtoms({ startsAt: startTime, - endsAt: endTime, + durationInHours: effectiveDurationForSlots, + consultantProfileId: ownerProfileId, isTentative: false, - // #784 — owner denormalized for the overlap exclusion guard. - consultantProfileId: existingPlan.consultantProfileId, - }, + }), }, }, }); - - console.log("Created new appointment:", newAppointment.id); } } @@ -905,6 +931,12 @@ export async function PATCH(request: NextRequest) { if (error instanceof CapacityBelowEnrollmentError) { return NextResponse.json({ error: error.message }, { status: 400 }); } + if ( + error instanceof TypeError && + error.message.includes("Invalid duration") + ) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } // AE-2 — co-host clash is a conflict, not a server error. if (error instanceof CollaboratorUnavailableError) { return NextResponse.json({ error: error.message }, { status: 409 }); diff --git a/app/api/participants/class/[classId]/route.ts b/app/api/participants/class/[classId]/route.ts index 8676538b3..1b13ad35d 100644 --- a/app/api/participants/class/[classId]/route.ts +++ b/app/api/participants/class/[classId]/route.ts @@ -7,6 +7,7 @@ import { forbiddenResponse, } from "@/lib/auth-helpers"; import { refundRemovedAttendeeSeat } from "@/lib/payments/operations/event-refunds"; +import { findLiveEventSlot } from "@/lib/appointments/live-event-slot"; import { applyRateLimit, eventMutationLimiter, @@ -120,10 +121,6 @@ export async function DELETE( if (authResult.error) return authResult.error; const { session } = authResult; - if (!isPrivileged(session.user.role) && !session.user.consultantProfileId) { - return forbiddenResponse("Only consultants can remove participants"); - } - const rl = await applyRateLimit(eventMutationLimiter, session.user.id); if (rl) return rl; @@ -137,13 +134,21 @@ export async function DELETE( return new NextResponse("User ID is required", { status: 400 }); } - // Ownership check only — the old shape loaded the entire roster - // (every appointment × every slot × every full User row) just to find - // the one participant being removed. + // #1005 — consultees may remove themselves (self-leave). Organisers and + // privileged roles may remove anyone on their event. + const isSelfLeave = userId === session.user.id; + const isOrganiser = + isPrivileged(session.user.role) || !!session.user.consultantProfileId; + if (!isSelfLeave && !isOrganiser) { + return forbiddenResponse("Only consultants can remove other participants"); + } + + // Ownership check for organiser removals; self-leave only needs the event + // to exist and the caller to be on the roster (checked via userSlots). const classEvent = await prisma.class.findFirst({ where: { id: classId, - ...(isPrivileged(session.user.role) + ...(isSelfLeave || isPrivileged(session.user.role) ? {} : { classPlan: { @@ -159,6 +164,22 @@ export async function DELETE( return new NextResponse("Class not found", { status: 404 }); } + // #1005 — class self-leave is allowed between sessions until the *last* + // live session has started. Webinar DELETE correctly keys on the earliest + // atom (one contiguous event); a months-long class keeps past sessions as + // COMPLETED/UNVERIFIED which are still "live" for run math, so an earliest + // gate permanently 400s after week 1 while the UI still offers Leave. + // Organiser removals keep working mid/post session for moderation. + if (isSelfLeave) { + const lastLive = await findLiveEventSlot({ classId }, { order: "desc" }); + if (lastLive && lastLive.startsAt.getTime() <= Date.now()) { + return NextResponse.json( + { error: "Cannot leave a class after its last session has started." }, + { status: 400 }, + ); + } + } + // Only the slots this user actually occupies. const userSlots = await prisma.slotOfAppointment.findMany({ where: { @@ -198,14 +219,14 @@ export async function DELETE( ), ); - // #1003 — the seat was paid for. Removing the attendee without returning - // the fee let the organiser keep money for a session they just barred the - // buyer from. Post-commit and non-throwing: the removal stands either way. + // #1003 — seat was paid; refund after roster commit (non-throwing). + // #1005 — pass initiatedBy so self-leave does not get organiser-fault 100%. const refund = await refundRemovedAttendeeSeat({ kind: "class", eventId: classId, attendeeUserId: userId, initiatedByUserId: session.user.id, + initiatedBy: isSelfLeave ? "attendee" : "organiser", }); return NextResponse.json({ removed: true, refund }); diff --git a/app/api/participants/webinar/[webinarId]/route.ts b/app/api/participants/webinar/[webinarId]/route.ts index f73f5f04a..f5e6256f6 100644 --- a/app/api/participants/webinar/[webinarId]/route.ts +++ b/app/api/participants/webinar/[webinarId]/route.ts @@ -7,6 +7,7 @@ import { forbiddenResponse, } from "@/lib/auth-helpers"; import { refundRemovedAttendeeSeat } from "@/lib/payments/operations/event-refunds"; +import { findLiveEventSlot } from "@/lib/appointments/live-event-slot"; import { applyRateLimit, eventMutationLimiter, @@ -115,10 +116,6 @@ export async function DELETE( if (authResult.error) return authResult.error; const { session } = authResult; - if (!isPrivileged(session.user.role) && !session.user.consultantProfileId) { - return forbiddenResponse("Only consultants can remove participants"); - } - const rl = await applyRateLimit(eventMutationLimiter, session.user.id); if (rl) return rl; @@ -132,13 +129,21 @@ export async function DELETE( return new NextResponse("User ID is required", { status: 400 }); } - // Ownership check only — the old shape loaded the entire roster - // (every slot × every full User row) just to find the one participant - // being removed. + // #1005 — consultees may remove themselves (self-leave). Organisers and + // privileged roles may remove anyone on their event. + const isSelfLeave = userId === session.user.id; + const isOrganiser = + isPrivileged(session.user.role) || !!session.user.consultantProfileId; + if (!isSelfLeave && !isOrganiser) { + return forbiddenResponse("Only consultants can remove other participants"); + } + + // Ownership check for organiser removals; self-leave only needs the event + // to exist and the caller to be on the roster (checked via userSlots). const webinarEvent = await prisma.webinar.findFirst({ where: { id: webinarId, - ...(isPrivileged(session.user.role) + ...(isSelfLeave || isPrivileged(session.user.role) ? {} : { webinarPlan: { @@ -154,6 +159,26 @@ export async function DELETE( return new NextResponse("Webinar not found", { status: 404 }); } + // #1005 — belt-and-braces with the attendee refund tier. Even if + // computeRefundPct returned 0% after start, we still refuse the roster + // mutation so "Leave event" cannot be used as a post-session cleanup that + // looks like a successful leave. Organiser removals (moderation) skip this. + if (isSelfLeave) { + // Single contiguous event: once the first live atom has started, leave + // is closed (unlike class, which keys on the last session — see class + // DELETE). + const earliestLive = await findLiveEventSlot( + { webinarId }, + { order: "asc" }, + ); + if (earliestLive && earliestLive.startsAt.getTime() <= Date.now()) { + return NextResponse.json( + { error: "Cannot leave an event that has already started." }, + { status: 400 }, + ); + } + } + // Only the slots this user actually occupies. const userSlots = await prisma.slotOfAppointment.findMany({ where: { @@ -193,14 +218,15 @@ export async function DELETE( ), ); - // #1003 — the seat was paid for. Removing the attendee without returning - // the fee let the organiser keep money for a session they just barred the - // buyer from. Post-commit and non-throwing: the removal stands either way. + // #1003 — seat was paid; refund after roster commit (non-throwing). + // #1005 — must pass initiatedBy: self-leave used to inherit organiser-fault + // 100% because the helper defaulted isConsultantInitiated=true. const refund = await refundRemovedAttendeeSeat({ kind: "webinar", eventId: webinarId, attendeeUserId: userId, initiatedByUserId: session.user.id, + initiatedBy: isSelfLeave ? "attendee" : "organiser", }); return NextResponse.json({ removed: true, refund }); diff --git a/components/appointments/consultee/CancelConfirmationDialog.tsx b/components/appointments/consultee/CancelConfirmationDialog.tsx index 894894f47..43556b64f 100644 --- a/components/appointments/consultee/CancelConfirmationDialog.tsx +++ b/components/appointments/consultee/CancelConfirmationDialog.tsx @@ -26,6 +26,10 @@ interface CancelConfirmationDialogProps { * irreversible cancellation of a paid session. */ isPendingPayment?: boolean; + /** + * #1005 — group self-leave uses different copy than a full booking cancel. + */ + mode?: "cancel" | "leave"; } export function CancelConfirmationDialog({ @@ -37,25 +41,40 @@ export function CancelConfirmationDialog({ appointmentType, isLoading = false, isPendingPayment = false, + mode = "cancel", }: Readonly) { + const isLeave = mode === "leave"; + // Flattened from a nested ternary — Sonar flags nested ternaries in JSX; + // the three outcomes are easier to skim as sequential assignments. + let dialogTitle: string; + if (isLeave) { + dialogTitle = `Leave ${appointmentType}?`; + } else if (isPendingPayment) { + dialogTitle = `Cancel ${appointmentType} request?`; + } else { + dialogTitle = `Cancel ${appointmentType}?`; + } return ( !open && onCancel()}> - {isPendingPayment - ? `Cancel ${appointmentType} request?` - : `Cancel ${appointmentType}?`} + {dialogTitle}

- Are you sure you want to cancel{" "} + Are you sure you want to {isLeave ? "leave" : "cancel"}{" "} "{title}" with{" "} {consultant}?

- {isPendingPayment ? ( + {isLeave ? ( +

+ You will be removed from this event. If you paid for a seat, + a refund is issued under the event's cancellation policy. +

+ ) : isPendingPayment ? (

You haven't been charged — this releases the approved request without any payment. @@ -66,7 +85,8 @@ export function CancelConfirmationDialog({ This action cannot be undone.

{(appointmentType === "Consultation" || - appointmentType === "Subscription") && ( + appointmentType === "Subscription" || + appointmentType === "Trial") && (

If a payment was captured, any refund follows the booking's cancellation policy. @@ -79,7 +99,7 @@ export function CancelConfirmationDialog({ - Keep Appointment + {isLeave ? "Stay enrolled" : "Keep Appointment"} - Cancelling... + {isLeave ? "Leaving..." : "Cancelling..."} + ) : isLeave ? ( + "Yes, Leave" ) : ( "Yes, Cancel" )} diff --git a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx index d47acf42d..bfc471bf9 100644 --- a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx +++ b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx @@ -4,9 +4,10 @@ import { useState } from "react"; import * as Sentry from "@sentry/nextjs"; import { useParams, useRouter } from "next/navigation"; import { useStreamVideoClient } from "@stream-io/video-react-sdk"; - +import { useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; +import { useSession } from "@/lib/auth-client"; import { getOrCreateAppointmentMeeting } from "@/lib/meeting"; import type { SlotOfAppointment } from "@prisma/client"; import type { @@ -19,6 +20,10 @@ import { getJoinableSlot, slotsAllowReschedule, } from "@/lib/appointments/slots"; +import { + consulteeDestructiveAction, + consulteeMayReschedule, +} from "@/lib/appointments/consultee-affordances"; import { isApprovedStatus, isConfirmedStatus, @@ -34,7 +39,53 @@ import { CancelConfirmationDialog } from "@/components/appointments/consultee/Ca import { ReportIssueDialog } from "@/components/appointments/consultee/ReportIssueDialog"; import { DocumentUpload } from "@/components/appointments/DocumentUpload"; -type DialogKind = "cancel" | "report" | "documents"; +type DialogKind = "cancel" | "leave" | "report" | "documents"; + +/** + * Event id for leave / cancel-trial API paths. + * + * ## Why two lookups? + * + * `map-consultee` sets `raw.source` to the webinar/class/trial row (has `.id`). + * `map-detail` sets `raw.source` to `{ appointment, siblings }` — no event id. + * The same adapter mounts on list AND detail, so gating on `source.id` alone + * hid Leave / Cancel trial on `/appointments/[appointmentId]` after #1005. + * + * Prefer `source.id` when present; otherwise read the appointment FKs the + * detail mapper already loaded. Longer-term a dedicated `eventId` on + * `AppointmentVM` would force both mappers to supply it — this fallback is + * the smaller surgical fix that unblocks the detail page. + */ +function sourceId(vm: AppointmentVM): string | null { + const source = vm.raw.source as { id?: string } | undefined; + if (source?.id) return source.id; + + const appt = vm.raw.appointment as + | { + webinarId?: string | null; + classId?: string | null; + consultationId?: string | null; + subscriptionId?: string | null; + trialSession?: { id?: string } | null; + } + | undefined; + if (!appt) return null; + + switch (vm.kind) { + case "WEBINAR": + return appt.webinarId ?? null; + case "CLASS": + return appt.classId ?? null; + case "TRIAL": + return appt.trialSession?.id ?? null; + case "CONSULTATION": + return appt.consultationId ?? null; + case "SUBSCRIPTION": + return appt.subscriptionId ?? null; + default: + return null; + } +} const KIND_TO_TYPE: Record< AppointmentVM["kind"], @@ -63,6 +114,8 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { const router = useRouter(); const { toast } = useToast(); const client = useStreamVideoClient(); + const { data: session } = useSession(); + const queryClient = useQueryClient(); const params = useParams<{ consulteeId: string }>(); const consulteeId = params?.consulteeId; @@ -70,6 +123,7 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { const [activeVm, setActiveVm] = useState(null); const [dialog, setDialog] = useState(null); const [joiningId, setJoiningId] = useState(null); + const [actionLoading, setActionLoading] = useState(false); const typeLabel = activeVm ? KIND_TO_TYPE[activeVm.kind] : "Consultation"; const actions = useEventActions({ @@ -169,7 +223,9 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { const items: OverflowItem[] = []; const inactive = isInactiveStatus(vm.status); const slots = vm.raw.rawSlots ?? []; + // #1005 — kind-gate: only offer actions the server will honour. if ( + consulteeMayReschedule(vm.kind) && vm.appointmentId && // The reschedule page lives under this route's consultee; off that route // there is no id to send them to. @@ -189,7 +245,8 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { ), }); } - if (vm.appointmentId && !inactive) { + const destructive = consulteeDestructiveAction(vm.kind); + if (!inactive && destructive === "cancel-booking" && vm.appointmentId) { items.push({ key: "cancel", label: isPendingPaymentStatus(vm.status) @@ -198,6 +255,20 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { destructive: true, onClick: () => openDialog(vm, "cancel"), }); + } else if (!inactive && destructive === "cancel-trial" && sourceId(vm)) { + items.push({ + key: "cancel", + label: "Cancel trial", + destructive: true, + onClick: () => openDialog(vm, "cancel"), + }); + } else if (!inactive && destructive === "leave-event" && sourceId(vm)) { + items.push({ + key: "leave", + label: "Leave event", + destructive: true, + onClick: () => openDialog(vm, "leave"), + }); } if ( vm.appointmentId && @@ -236,27 +307,128 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { return items; }; + const invalidateBookings = () => { + if (!consulteeId) return; + void queryClient.invalidateQueries({ + queryKey: ["consultee-events", consulteeId], + }); + void queryClient.invalidateQueries({ + queryKey: ["pending-payments", consulteeId], + }); + }; + + // Extracted so Sonar cognitive-complexity on confirmDestructive stays under + // the gate; each helper owns its fetch + toast, the dispatcher owns loading. + const cancelTrial = async (id: string, title: string) => { + const response = await fetch(`/api/trials/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "CANCELLED" }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + (data as { error?: string }).error || "Failed to cancel trial", + ); + } + toast({ + title: "Trial cancelled", + description: `Your trial "${title}" has been cancelled.`, + }); + }; + + const leaveEvent = async ( + kind: Extract, + id: string, + userId: string, + title: string, + ) => { + let path: string; + switch (kind) { + case "WEBINAR": + path = `/api/participants/webinar/${id}?userId=${encodeURIComponent(userId)}`; + break; + case "CLASS": + path = `/api/participants/class/${id}?userId=${encodeURIComponent(userId)}`; + break; + default: { + const _exhaustive: never = kind; + throw new Error(`Unsupported leave-event kind: ${_exhaustive}`); + } + } + const response = await fetch(path, { method: "DELETE" }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + (data as { error?: string }).error || "Failed to leave the event", + ); + } + toast({ + title: "Left event", + description: `You have left "${title}".`, + }); + }; + + const confirmDestructive = async () => { + if (!activeVm) return; + const id = sourceId(activeVm); + const destructive = consulteeDestructiveAction(activeVm.kind); + + if (destructive === "cancel-booking") { + await actions.handleCancelConfirm(); + closeDialog(); + return; + } + + setActionLoading(true); + try { + if (destructive === "cancel-trial") { + if (!id) throw new Error("Trial id is missing"); + await cancelTrial(id, activeVm.title); + } else if (destructive === "leave-event") { + if (!id) throw new Error("Event id is missing"); + const userId = session?.user?.id; + if (!userId) throw new Error("You must be signed in to leave"); + if (activeVm.kind !== "WEBINAR" && activeVm.kind !== "CLASS") { + throw new Error("Only webinars and classes support leave-event"); + } + await leaveEvent(activeVm.kind, id, userId, activeVm.title); + } + closeDialog(); + invalidateBookings(); + } catch (error) { + Sentry.captureException(error); + toast({ + title: "Error", + description: + error instanceof Error ? error.message : "Something went wrong", + variant: "destructive", + }); + } finally { + setActionLoading(false); + } + }; + const renderDialogs = () => { if (!activeVm) return null; const isPendingPayment = isPendingPaymentStatus(activeVm.status); const scheduledAt = activeVm.raw.rawSlots?.[0] ? new Date(activeVm.raw.rawSlots[0].startsAt as Date | string).toISOString() : undefined; + const dialogMode = dialog === "leave" ? "leave" : "cancel"; return ( <> { - await actions.handleCancelConfirm(); - closeDialog(); - }} + isOpen={dialog === "cancel" || dialog === "leave"} + onConfirm={() => void confirmDestructive()} onCancel={closeDialog} title={activeVm.title} consultant={activeVm.counterpart.name} appointmentType={typeLabel} - isLoading={actions.isLoading} + isLoading={actions.isLoading || actionLoading} isPendingPayment={isPendingPayment} + mode={dialogMode} /> {activeVm.appointmentId && dialog === "report" && ( diff --git a/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx b/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx index 5dd9ad162..6e9055185 100644 --- a/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx +++ b/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx @@ -801,6 +801,11 @@ export function RequestSlotAllocationTab({ initialAllocation: (selectedRequestForDialog.tentativeSlotCount ?? 0) === 0 || undefined, + // #1012 — stale-tab reschedule precondition. + expectedTentativeSlotCount: + (selectedRequestForDialog.tentativeSlotCount ?? 0) > 0 + ? selectedRequestForDialog.tentativeSlotCount + : undefined, }), }); diff --git a/components/notifications/NotificationInbox.tsx b/components/notifications/NotificationInbox.tsx index 3e59f6595..686a37787 100644 --- a/components/notifications/NotificationInbox.tsx +++ b/components/notifications/NotificationInbox.tsx @@ -108,9 +108,44 @@ export function NotificationInbox() { borderRadius: "0.5rem", }, elements: { + // Novu's InboxContent is height:auto by default. When the Radix + // popover was `overflow-y-auto` + shrink-to-content, a short feed + // made the whole panel squat and tabs ("LearnPro Academy") overflowed + // sideways. Give Novu a filled column and scroll the *list* only. + inboxContent: { + height: "100%", + display: "flex", + flexDirection: "column", + overflow: "hidden", + minWidth: 0, + }, + // Org tab labels can be long; horizontal scroll here is fine — + // vertical scroll must stay on the notification list below. + tabsList: { + flexShrink: 0, + overflowX: "auto", + overflowY: "hidden", + minWidth: 0, + }, + notificationList: { + flex: 1, + minHeight: 0, + overflowY: "auto", + overflowX: "hidden", + }, notification: { padding: "12px 16px", gap: "12px", + minWidth: 0, + }, + // Long reminder copy was forcing horizontal overflow; wrap instead. + notificationSubject: { + overflowWrap: "anywhere", + wordBreak: "break-word", + }, + notificationBody: { + overflowWrap: "anywhere", + wordBreak: "break-word", }, }, }} @@ -144,14 +179,14 @@ export function NotificationInbox() { /> - {/* Narrower than the old 400px and collision-padded so the panel stays - inside the viewport instead of running to the window edge over the - page it is anchored above. */} + {/* Restore ~400px width (was narrowed to 22rem and felt cramped). Fixed + height + overflow-hidden so the panel does not shrink-wrap content + and invent a horizontal scrollbar; list scroll is configured above. */} { diff --git a/components/scheduling/SlotPicker.tsx b/components/scheduling/SlotPicker.tsx index 40852227d..b70c77f87 100644 --- a/components/scheduling/SlotPicker.tsx +++ b/components/scheduling/SlotPicker.tsx @@ -219,6 +219,13 @@ export function SlotPicker({ ? !subject.hasReleasedSlots : undefined } + // #1012 — when this is a reschedule (released/tentative slots), pin + // the tentative count so a stale tab 409s instead of replacing. + expectedTentativeSlotCount={ + subject.hasReleasedSlots + ? (subject.slots?.filter((s) => s.isTentative).length ?? 0) + : undefined + } showAllocationButtons={!isSelectMode} onSlotsSelected={(slots) => setProposedSlots( diff --git a/components/scheduling/UnifiedCalendar.tsx b/components/scheduling/UnifiedCalendar.tsx index 312ff6aa2..e50f46311 100644 --- a/components/scheduling/UnifiedCalendar.tsx +++ b/components/scheduling/UnifiedCalendar.tsx @@ -361,6 +361,8 @@ export interface UnifiedCalendarProps { /** Reject allocations if the event already has confirmed slots (fresh * PENDING allocations only; reschedule hosts must not set this). */ initialAllocation?: boolean; + /** #1012 — tentative count captured when the allocate dialog opened. */ + expectedTentativeSlotCount?: number; onClose?: () => void; showAllocationButtons?: boolean; preSelectedSlots?: TimeSlot[]; @@ -395,6 +397,7 @@ export function UnifiedCalendar({ onAllocationComplete, onAllocationConflict, initialAllocation, + expectedTentativeSlotCount, onClose, showAllocationButtons = false, preSelectedSlots = [], @@ -527,7 +530,9 @@ export function UnifiedCalendar({ pastConfirmedSlotCount: isRecurringEventType(eventType) ? pastEventSlotCount : undefined, + weeklyConfirmedCallCounts, initialAllocation, + expectedTentativeSlotCount, schedulingTimezone, onSuccess: handleAllocationSuccess, onConflict: onAllocationConflict, diff --git a/docs/booking/03-slot-math-and-calculations.md b/docs/booking/03-slot-math-and-calculations.md index 80596a228..b09975632 100644 --- a/docs/booking/03-slot-math-and-calculations.md +++ b/docs/booking/03-slot-math-and-calculations.md @@ -37,13 +37,15 @@ A one-hour consultation is two rows, a four-hour consultation is eight, and in b 3. Split the bucket wherever `prev.endsAt !== next.startsAt`, so a gap between two sittings ends the run. 4. Split it again wherever `isTentative` changes, because an unallocated placeholder is not part of the confirmed session sitting next to it. -Rows whose `completionStatus` is `CANCELLED` or `RESCHEDULED` are dropped before any of this happens. They can never be joined, and leaving them in would let a dead row bridge two runs that are not actually contiguous. +Rows that `isDeadSlot` rejects — `completionStatus` in `{CANCELLED, RESCHEDULED}` **or** a non-null `deletedAt` tombstone — are dropped before any of this happens. They can never be joined, and leaving them in would let a dead row bridge two runs that are not actually contiguous. Callers that do not select `deletedAt` still degrade safely (`undefined` is treated as live for that signal alone). The run's first row is its **anchor**, and it is the only row anything may be keyed to. ### Why the Walk Exists Rather Than Keying on `appointmentId` -One appointment per session is the designed model, and the allocator enforces it on every path that creates a booking. It is not, however, enforced by the schema, and it is already violated in two real places. `prisma/seedFiles/6a-create-appointments.ts:368` attaches weeks-apart sessions to a single appointment, and the webinar reschedule described in #1071 moves only `slotsOfAppointment[0]`, which strands the remaining rows on a different day. +One appointment per session is the designed model, and the allocator enforces it on every path that creates a booking. It is not, however, enforced by the schema, and it was historically violated in two places. `prisma/seedFiles/6a-create-appointments.ts:368` attaches weeks-apart sessions to a single appointment (seed-only). The planner webinar/class path used to write one long slot or move only `slotsOfAppointment[0]` (#1071). + +**Planner create** for both webinars and classes now goes through `lib/appointments/contiguous-slot-run.ts` (`buildContiguousSlotAtoms`) and writes a contiguous N×30min run. **Planner PATCH** differs by type: webinar `crud-with-plan` rewrites the live run via `replaceContiguousSlotRun` (in-place reconcile — update overlapping ids, create the delta, soft-retire surplus as `RESCHEDULED` — so `MeetingSession` / `Recording` cascades are not tripped). Class `crud-with-plan` PATCH does **not** rewrite slot times when `sessionDurationInHours` changes; allocated class sessions keep their existing run length until a future allocator/reschedule path moves them. If sessions were keyed on `appointmentId` alone, both of those cases would collapse unrelated sessions into one shared video room. That is a cross-session privacy leak rather than a cosmetic defect, so the contiguity walk is load-bearing and must not be simplified away. diff --git a/docs/booking/05-troubleshooting-and-changelog.md b/docs/booking/05-troubleshooting-and-changelog.md index f36b88e05..869978985 100644 --- a/docs/booking/05-troubleshooting-and-changelog.md +++ b/docs/booking/05-troubleshooting-and-changelog.md @@ -101,6 +101,23 @@ Booking-calendar correctness sweep (branch `fix/booking-algorithm-calendar`, tra --- +## Changelog: August 2026 + +Booking algorithm Pre-MVP wave (`fix/booking-algorithm`, tracker #1072). + +| Fix | Severity | Description | Files / Issues | +| --- | -------- | ----------- | -------------- | +| Contiguous N×30min planner runs | Critical | Webinar/class **create** writes allocator-parity atoms via `buildContiguousSlotAtoms`. Webinar **PATCH** rewrites the live run via `replaceContiguousSlotRun` (reconcile in place — no `deleteMany`, so Stream `MeetingSession`/`Recording` survive). Class PATCH does not rewrite slot times on duration edits. | `lib/appointments/contiguous-slot-run.ts`, webinar/class `crud-with-plan` — #1071 | +| Reconcile avoids exclusion self-collision | Critical | Before shifting confirmed atoms, `replaceContiguousSlotRun` `updateMany`s live rows to `isTentative: true` so `slot_no_confirmed_overlap` cannot 23P01 a run against itself mid-statement. | `contiguous-slot-run.ts` — PR #1091 | +| Live-slot reads ignore dead/tombstoned rows | Critical | Planner webinar PATCH filters `CANCELLED`/`RESCHEDULED`/`deletedAt`; `isDeadSlot` also treats `deletedAt` as dead (affects run math / join / reschedule affordances). | `slots.ts`, webinar `crud-with-plan` — #1071 | +| Reschedule stale-tab precondition | Critical | Allocate accepts `expectedTentativeSlotCount`; mismatch → 409. Auto/manual re-assert inside the write txn (not only the pre-txn read), matching requested-slots — covers races `guardInitialAllocationInTx` skips on reschedule. | `SlotAllocationService`, allocate routes, SlotPicker — #1012 | +| Consultee kind-gates + self-leave | High | Hide impossible Reschedule/Cancel; trial cancel via trial API; group Leave Event via participant DELETE self. Webinar leave closes once the first live atom has started; class leave closes once the **last** live session has started. Self-leave refunds use attendee notice tiers (`initiatedBy: "attendee"`, next future slot for `hoursUntilStart`). | `consultee-affordances.ts`, adapter, participant routes, `event-refunds.ts` — #1005 | +| #997 Phase 3 weekly-limit parity | Medium | `useSlotAllocation` weekly guard now includes server `weeklyConfirmedCallCounts` (Phase 2 grid already shipped). Residual: per-cell rule-flag payload still optional follow-up. | `useSlotAllocation.ts`, `UnifiedCalendar.tsx` — Part of #997 | +| ClassPlan 30-min duration refine | Medium | `sessionDurationInHours` must be a multiple of 0.5 on create / when the field changes. Class PATCH grandfathers an unchanged legacy duration so unrelated edits (title/price) still succeed. | `schemas/plans.ts`, class `crud-with-plan` PATCH | +| Novu inbox popover sizing | Low | Fixed notification popover height/scroll (unrelated to booking integrity; shipped on the same branch). | `NotificationInbox.tsx` | + +--- + ## Changelog: March 2026 Security, auth, and booking fix sprint. 12 PRs merged. diff --git a/docs/booking/07-rescheduling-flow.md b/docs/booking/07-rescheduling-flow.md index 7221a4810..39090ae87 100644 --- a/docs/booking/07-rescheduling-flow.md +++ b/docs/booking/07-rescheduling-flow.md @@ -25,10 +25,14 @@ Rescheduling allows a consultee to request new time slots for an existing appointment without creating a new booking and without triggering any payment operation. The original payment is fully reused -- there is no charge, no refund, and no new invoice. -**Who can trigger a reschedule:** The user who originally booked the appointment (the consultee). +**Who can trigger a reschedule:** For consultations and subscriptions, the consultee who booked (or the consultant on Manage Timings / allocate). Webinars, classes, and trials are **not** consultee-reschedulable (#1005); group events are organiser-managed. **Minimum notice:** 24 hours before any affected slot (`MINIMUM_HOURS_BEFORE_RESCHEDULE = 24`). If any slot selected for rescheduling starts within 24 hours, the entire request is rejected. +**Stale-tab guard (#1012):** Re-allocation after a reschedule must send `expectedTentativeSlotCount` matching the live tentative set. A second tab that submits after the first finished receives 409 instead of delete+recreating confirmed slots. + +**Planner webinar time/duration edits (#1071):** Host edits go through `replaceContiguousSlotRun`, which reconciles the live N×30min atoms in place (tentative-flip first so `slot_no_confirmed_overlap` cannot self-collide, then update / create / soft-retire). This is distinct from consultee allocate-reschedule; class planner PATCH does not rewrite session slot runs when only `sessionDurationInHours` changes. + **Code location:** `app/api/appointments/[appointmentId]/reschedule/route.ts` --- diff --git a/docs/booking/08-cancellation-flow.md b/docs/booking/08-cancellation-flow.md index 145c6b819..4b409e4ea 100644 --- a/docs/booking/08-cancellation-flow.md +++ b/docs/booking/08-cancellation-flow.md @@ -33,7 +33,7 @@ Cancellation is one of the most architecturally nuanced flows in the booking sys 2. **Nothing is deleted.** The appointment, its slots and its payment records are all preserved; the cancellation is a status change plus audit fields. `Payment.appointment` cascades on delete, so destroying an appointment would destroy the money trail with it. 3. **Refunds are automatic and policy-driven.** The cancellation flow computes a refund from the tiers frozen onto the booking at checkout and drives it through the payment operations, without an admin in the loop. -> **A note on this chapter's age.** Sections below still describe an earlier design in which cancellation deleted the appointment, required no authentication and never touched money. All three of those statements are false against the current route, and the passages that repeat them are corrected in place where they are load-bearing. A full rewrite of the walkthrough — including its line-number references, which no longer match the route — is tracked in #1013. +> **A note on this chapter's age.** Sections below still describe an earlier design in which cancellation deleted the appointment, required no authentication and never touched money. All three of those statements are false against the current route (soft-cancel + CAS auth + automatic policy refunds, including whole-event fan-out for webinars/classes — #1003). Consultees leave group events via participant self-leave (#1005), not appointment cancel. A full rewrite of the walkthrough — including its line-number references and the outdated mermaid that still shows `delete Appointment` — is tracked in #1013. Here is the complete decision tree for the cancellation endpoint, from the moment a request arrives to the final response: @@ -894,7 +894,7 @@ For webinars and classes the organiser is read off the plan and every paid atten When a paid consultation or subscription is cancelled, the route resolves three facts about the **whole booking** rather than the single appointment it was handed, because a subscription is one slot-less placeholder that carries the money plus one appointment per allocated session. It finds the payment funding the booking, the policy snapshot frozen on the row the buyer paid for, and the start time of the earliest session that has not yet been delivered. It then applies the tier for that many hours of notice, and refunds that percentage of the amount paid. A cancellation the consultant initiates always settles at the policy's consultant-initiated percentage — one hundred per cent under the platform defaults — because the buyer did nothing wrong. -Cancelling a whole class or webinar refunds every attendee in full instead, since the attendees did not choose to leave. Removing a single attendee from a live event refunds that attendee's seat on the same reasoning. +Cancelling a whole class or webinar refunds every attendee in full instead, since the attendees did not choose to leave. Removing a single attendee as the **organiser** refunds that seat at the consultant-initiated percentage (same reasoning). A consultee **self-leave** (`DELETE` on the participant route with their own user id — #1005) instead uses the attendee notice tiers: `refundRemovedAttendeeSeat({ initiatedBy: "attendee" })` resolves `hoursUntilStart` from the next future live slot (`startsAt >= now`). Webinar self-leave is refused once the first live atom has started; class self-leave is refused only after the last live session has started, so mid-program leaves between sessions still work. The refund runs after the cancellation transaction commits, and a failure to refund never rolls the cancellation back. The outcome is returned on the `refund` field of the response and surfaced to the buyer in the cancellation toast, so a refund that did not happen reads differently from one that did; failures are additionally reported to Sentry for ops. diff --git a/hooks/scheduling/useSlotAllocation.ts b/hooks/scheduling/useSlotAllocation.ts index 075d366f2..98875a18d 100644 --- a/hooks/scheduling/useSlotAllocation.ts +++ b/hooks/scheduling/useSlotAllocation.ts @@ -116,6 +116,13 @@ export interface UseEventSlotAllocationOptions { /** Number of confirmed past event slots (for in-progress recurring events) */ pastConfirmedSlotCount?: number; + /** + * #997 Phase 3 — server-bucketed confirmed calls per scheduling-timezone + * week key. Used so the interactive weekly-limit guard does not re-derive + * aggregates from a whole-window appointment fetch on every click. + */ + weeklyConfirmedCallCounts?: Record; + /** Preferred time slots (if any) */ preferredTimeSlots?: TimeSlot[]; @@ -143,6 +150,9 @@ export interface UseEventSlotAllocationOptions { * flows must NOT set it — they legitimately re-allocate. */ initialAllocation?: boolean; + /** #1012 — reschedule stale-tab precondition (tentative count at dialog open). */ + expectedTentativeSlotCount?: number; + /** Enable caching of availability data */ enableCaching?: boolean; @@ -571,8 +581,10 @@ export function useEventSlotAllocation( options.schedulingTimezone, ); - // Count complete calls already in this week (pre-add) - let completeCallsThisWeek = 0; + // Count complete calls already in this week (pre-add), plus + // server-confirmed calls for the same week key (#997 Phase 3). + let completeCallsThisWeek = + options.weeklyConfirmedCallCounts?.[targetWeekKey] || 0; preSlotsByDay.forEach((daySlots) => { if (isCompleteCall(daySlots, slotsPerCall)) completeCallsThisWeek++; @@ -722,12 +734,18 @@ export function useEventSlotAllocation( options.schedulingTimezone, ); - // Confirmed calls per week WITHOUT the new slot + // Confirmed calls per week WITHOUT the new slot. + // Seed from `weeklyConfirmedCallCounts` (server truth for sessions + // already allocated this week) — the earlier weekly-limit block + // already does; this sibling block used to count only `currentSlots` + // and under-reported progress toasts on partially confirmed weeks. const existingSlotsByDay = groupSlotsByDay( currentSlots, options.schedulingTimezone, ); - const existingWeeklyConfirmedCallCounts = new Map(); + const existingWeeklyConfirmedCallCounts = new Map( + Object.entries(options.weeklyConfirmedCallCounts ?? {}), + ); existingSlotsByDay.forEach((daySlots) => { if ( daySlots.length === slotsPerCall && @@ -930,6 +948,7 @@ export function useEventSlotAllocation( schedulingTimezone: options.schedulingTimezone, idempotencyKey: attempt.key, initialAllocation: options.initialAllocation || undefined, + expectedTentativeSlotCount: options.expectedTentativeSlotCount, }; const result = await AllocationAlgorithms.manualAllocate( @@ -1003,6 +1022,7 @@ export function useEventSlotAllocation( isAuto: true, idempotencyKey: attempt.key, initialAllocation: options.initialAllocation || undefined, + expectedTentativeSlotCount: options.expectedTentativeSlotCount, }, ); @@ -1098,6 +1118,7 @@ export function useEventSlotAllocation( schedulingTimezone: options.schedulingTimezone, idempotencyKey: attempt.key, initialAllocation: options.initialAllocation || undefined, + expectedTentativeSlotCount: options.expectedTentativeSlotCount, }; const result = diff --git a/lib/appointments/consultee-affordances.ts b/lib/appointments/consultee-affordances.ts new file mode 100644 index 000000000..2fd6639c1 --- /dev/null +++ b/lib/appointments/consultee-affordances.ts @@ -0,0 +1,36 @@ +/** + * #1005 — which consultee overflow actions are honest for each appointment kind. + * + * Server routes already 403 impossible actions; the UI must not offer them. + */ + +import type { AppointmentKind } from "@/lib/appointments/view-model"; + +export type ConsulteeDestructiveAction = + | "cancel-booking" + | "cancel-trial" + | "leave-event" + | "none"; + +/** Reschedule is 1:1 only — group events are organiser-managed; trials have no path. */ +export function consulteeMayReschedule(kind: AppointmentKind): boolean { + return kind === "CONSULTATION" || kind === "SUBSCRIPTION"; +} + +/** What destructive action the overflow menu should offer. */ +export function consulteeDestructiveAction( + kind: AppointmentKind, +): ConsulteeDestructiveAction { + switch (kind) { + case "CONSULTATION": + case "SUBSCRIPTION": + return "cancel-booking"; + case "TRIAL": + return "cancel-trial"; + case "WEBINAR": + case "CLASS": + return "leave-event"; + default: + return "none"; + } +} diff --git a/lib/appointments/contiguous-slot-run.ts b/lib/appointments/contiguous-slot-run.ts new file mode 100644 index 000000000..4f2daf0fc --- /dev/null +++ b/lib/appointments/contiguous-slot-run.ts @@ -0,0 +1,244 @@ +/** + * Canonical N×30-minute slot atoms for a single session (#1071 / ADR B1). + * + * Planner CRUD historically wrote one long SlotOfAppointment spanning the full + * duration, while SlotAllocationService wrote ceil(hours/0.5) half-hour rows. + * Reschedule then updated only slotsOfAppointment[0], stranding the rest. + * + * Every planner create/update path must go through these helpers so an + * appointment's live slots always form exactly one contiguous run. + */ + +import type { PrismaLike } from "@/lib/prisma"; +import { SlotCalculationService } from "@/utils/slotAllocation/SlotCalculationService"; +import { groupSlotsIntoRuns, isDeadSlot } from "@/lib/appointments/slots"; + +export const SLOT_DURATION_MS = 30 * 60 * 1000; + +export type ContiguousSlotAtomInput = { + startsAt: Date; + durationInHours: number; + consultantProfileId: string; + isTentative?: boolean; + /** User ids to connect on every atom (host + enrolled attendees). */ + userIds?: string[]; +}; + +export type ContiguousSlotAtomCreate = { + startsAt: Date; + endsAt: Date; + isTentative: boolean; + consultantProfileId: string; + user?: { connect: Array<{ id: string }> }; +}; + +/** + * Pure: expand a session start + duration into N half-hour create payloads. + */ +export function buildContiguousSlotAtoms( + input: ContiguousSlotAtomInput, +): ContiguousSlotAtomCreate[] { + const { startsAt, durationInHours, consultantProfileId } = input; + if (!(startsAt instanceof Date) || Number.isNaN(startsAt.getTime())) { + throw new Error("buildContiguousSlotAtoms: invalid startsAt"); + } + if (typeof durationInHours !== "number" || durationInHours <= 0) { + throw new Error("buildContiguousSlotAtoms: durationInHours must be > 0"); + } + + const slotsPerSession = + SlotCalculationService.getSlotsPerCall(durationInHours); + const isTentative = input.isTentative ?? false; + const userIds = [...new Set((input.userIds ?? []).filter(Boolean))]; + + const atoms: ContiguousSlotAtomCreate[] = []; + for (let i = 0; i < slotsPerSession; i++) { + const atomStart = new Date(startsAt.getTime() + i * SLOT_DURATION_MS); + const atomEnd = new Date(atomStart.getTime() + SLOT_DURATION_MS); + atoms.push({ + startsAt: atomStart, + endsAt: atomEnd, + isTentative, + consultantProfileId, + ...(userIds.length > 0 + ? { user: { connect: userIds.map((id) => ({ id })) } } + : {}), + }); + } + return atoms; +} + +/** + * Live slots on one appointment must form exactly one contiguous run. + * Throws when the invariant is violated (write-time assert for #1071). + */ +export function assertSingleContiguousLiveRun( + slots: Array<{ + id: string; + appointmentId?: string | null; + startsAt: Date | string; + endsAt?: Date | string | null; + isTentative?: boolean | null; + completionStatus?: string | null; + deletedAt?: Date | string | null; + }>, +): void { + const live = slots.filter((s) => !isDeadSlot(s)); + if (live.length === 0) return; + + const runs = groupSlotsIntoRuns( + live.map((s) => ({ + ...s, + appointmentId: s.appointmentId ?? "__single__", + })), + ); + + if (runs.length !== 1) { + throw new Error( + `Appointment live slots must form exactly one contiguous run; found ${runs.length}`, + ); + } +} + +/** + * Rewrite the appointment's live slot run to `startsAt` + duration. + * + * ## Why reconcile instead of deleteMany + recreate? + * + * The first #1071 cut hard-deleted live rows and inserted new ones. That fixed + * the stranded-atom bug, but `MeetingSession` / `Recording` cascade on + * `SlotOfAppointment` delete (`onDelete: Cascade`). A free webinar (or one + * whose payments are all FAILED/EXPIRED) still reaches this path, and a host + * who opened the room once already has a MeetingSession — so a duration-only + * edit could wipe recordings. The pre-#1071 code updated `[0]` in place and + * preserved ids; we keep that property for the overlapping prefix. + * + * Chosen shape (PR #1091 review): + * 1. UPDATE the first `min(existingLive, N)` rows' times (ids stay put). + * 2. CREATE only the delta when duration grows. + * 3. Soft-retire surplus live rows as `RESCHEDULED` (same stamp the consultee + * reschedule route uses) so history + Stream children remain queryable. + * + * Dead rows (CANCELLED / RESCHEDULED / deletedAt) are left alone — they are + * not part of the live run and must not donate their `startsAt` to a + * duration-only rewrite (that snap-back was the other half of the bug). + */ +export async function replaceContiguousSlotRun( + // PrismaLike (not Prisma.TransactionClient): the app client is `$extends`, + // and interactive-tx clients fail assignability against the bare generated + // type (excessive stack depth / incompatible tx shape in CI). + tx: PrismaLike, + args: { + appointmentId: string; + startsAt: Date; + durationInHours: number; + consultantProfileId: string; + isTentative?: boolean; + /** Extra user ids to connect (merged with users already on live slots). */ + extraUserIds?: string[]; + }, +): Promise<{ createdCount: number; preservedUserIds: string[] }> { + const existing = await tx.slotOfAppointment.findMany({ + where: { appointmentId: args.appointmentId }, + orderBy: { startsAt: "asc" }, + include: { user: { select: { id: true } } }, + }); + + // Only live rows participate in the run. Users on dead rows are intentionally + // not re-attached — those seats were already left / cancelled. + const live = existing.filter((s) => !isDeadSlot(s)); + const preservedUserIds = new Set(args.extraUserIds ?? []); + for (const slot of live) { + for (const u of slot.user ?? []) { + preservedUserIds.add(u.id); + } + } + const userIds = Array.from(preservedUserIds); + + const atoms = buildContiguousSlotAtoms({ + startsAt: args.startsAt, + durationInHours: args.durationInHours, + consultantProfileId: args.consultantProfileId, + isTentative: args.isTentative ?? false, + userIds, + }); + + const shared = Math.min(live.length, atoms.length); + + // `slot_no_confirmed_overlap` is NOT DEFERRABLE: each UPDATE is checked + // against sibling rows that still hold their old times. Shifting a 2h run + // forward by 1h makes atom 0 land on atom 2's old window → 23P01 against + // ourselves. Flip every live row tentative first (drops them out of the + // partial exclusion index), then write the new times and restore the real + // flag. Contiguous `[)` target atoms cannot collide with each other. + if (live.length > 0) { + await tx.slotOfAppointment.updateMany({ + where: { id: { in: live.map((s) => s.id) } }, + data: { isTentative: true }, + }); + } + + // In-place updates: Stream room keys and recordings stay keyed to these ids. + for (let i = 0; i < shared; i++) { + const atom = atoms[i]; + await tx.slotOfAppointment.update({ + where: { id: live[i].id }, + data: { + startsAt: atom.startsAt, + endsAt: atom.endsAt, + isTentative: atom.isTentative, + consultantProfileId: atom.consultantProfileId, + // `set` replaces the M2M so host + enrolled attendees survive the move. + ...(userIds.length > 0 + ? { user: { set: userIds.map((id) => ({ id })) } } + : {}), + }, + }); + } + + for (let i = shared; i < atoms.length; i++) { + const atom = atoms[i]; + await tx.slotOfAppointment.create({ + data: { + appointmentId: args.appointmentId, + startsAt: atom.startsAt, + endsAt: atom.endsAt, + isTentative: atom.isTentative, + consultantProfileId: atom.consultantProfileId, + ...(atom.user ? { user: atom.user } : {}), + }, + }); + } + + // Duration shrunk: mark leftover live atoms RESCHEDULED rather than delete. + // Hard-delete would cascade MeetingSession → Recording for those rows. + // They are already tentative from the pre-pass above. + for (let i = shared; i < live.length; i++) { + await tx.slotOfAppointment.update({ + where: { id: live[i].id }, + data: { + isTentative: true, + completionStatus: "RESCHEDULED", + }, + }); + } + + // completionStatus defaults to SCHEDULED and is never NULL — plain notIn. + const liveAfter = await tx.slotOfAppointment.findMany({ + where: { + appointmentId: args.appointmentId, + deletedAt: null, + completionStatus: { notIn: ["CANCELLED", "RESCHEDULED"] }, + }, + orderBy: { startsAt: "asc" }, + }); + assertSingleContiguousLiveRun( + liveAfter.map((s) => ({ ...s, appointmentId: args.appointmentId })), + ); + + return { + /** Live atoms after the rewrite (not "how many were inserted"). */ + createdCount: liveAfter.length, + preservedUserIds: userIds, + }; +} diff --git a/lib/appointments/live-event-slot.ts b/lib/appointments/live-event-slot.ts new file mode 100644 index 000000000..890d05544 --- /dev/null +++ b/lib/appointments/live-event-slot.ts @@ -0,0 +1,42 @@ +/** + * Shared "live slot on a group event" lookup for self-leave gates and + * attendee-refund notice windows (#1005). + * + * Three call sites used the same `deletedAt` + `completionStatus notIn` + * filter with different sort / `startsAt` bounds. Centralising keeps the + * eligibility and refund % definitions from drifting. + */ + +import type { Prisma, SlotCompletionStatus } from "@prisma/client"; +import prisma from "@/lib/prisma"; + +/** Mutable list — Prisma's `notIn` rejects `readonly` tuples from `as const`. */ +const DEAD_COMPLETION_STATUSES: SlotCompletionStatus[] = [ + "CANCELLED", + "RESCHEDULED", +]; + +export type LiveEventAppointmentFilter = + | { webinarId: string } + | { classId: string }; + +export async function findLiveEventSlot( + appointment: LiveEventAppointmentFilter, + opts: { + order: "asc" | "desc"; + /** When set, only slots at/after this instant (next-session notice window). */ + startsAtGte?: Date; + }, +): Promise<{ startsAt: Date } | null> { + const where: Prisma.SlotOfAppointmentWhereInput = { + appointment, + deletedAt: null, + completionStatus: { notIn: DEAD_COMPLETION_STATUSES }, + ...(opts.startsAtGte ? { startsAt: { gte: opts.startsAtGte } } : {}), + }; + return prisma.slotOfAppointment.findFirst({ + where, + orderBy: { startsAt: opts.order }, + select: { startsAt: true }, + }); +} diff --git a/lib/appointments/slots.ts b/lib/appointments/slots.ts index f2064decb..6dac0d075 100644 --- a/lib/appointments/slots.ts +++ b/lib/appointments/slots.ts @@ -50,9 +50,19 @@ export interface SessionRun { const DEAD_COMPLETION_STATUSES = new Set(["CANCELLED", "RESCHEDULED"]); +/** + * Non-live for run math / planner rewrites. + * + * `completionStatus` alone used to be enough, but A10 also soft-deletes via + * `deletedAt`. A tombstoned row with a still-"SCHEDULED" status would otherwise + * count as live: its users got re-attached on rewrite, and a `notIn` delete + * could hard-delete the tombstone. Treat either signal as dead. + */ export function isDeadSlot(slot: { completionStatus?: string | null; + deletedAt?: Date | string | null; }): boolean { + if (slot.deletedAt) return true; return ( !!slot.completionStatus && DEAD_COMPLETION_STATUSES.has(slot.completionStatus) diff --git a/lib/payments/operations/event-refunds.ts b/lib/payments/operations/event-refunds.ts index 763f37497..9ceb5228d 100644 --- a/lib/payments/operations/event-refunds.ts +++ b/lib/payments/operations/event-refunds.ts @@ -17,6 +17,7 @@ import { computeRefundPct, parsePolicySnapshot, } from "./cancellation-policy"; +import { findLiveEventSlot } from "@/lib/appointments/live-event-slot"; /** * Whole-event refund (#776 §C) — the production front door for the reversal @@ -188,10 +189,20 @@ export async function refundWholeEventPayments( * moderation bulk-cancel has always refunded a removed attendee in full; the * interactive endpoints were the outlier. * - * A removal is the organiser's act, so the frozen policy settles it at - * `consultantInitiatedPct` (100% under the platform defaults) — the attendee - * did nothing wrong. Routed through `refundBookingPayment` so an org-funded - * seat reverses in-ledger instead of dying on UNKNOWN_GATEWAY. + * ## Who initiated the removal matters for the % (#1005) + * + * Historically only organisers hit this helper, so it always passed + * `isConsultantInitiated: true` into `computeRefundPct` (full + * `consultantInitiatedPct`, clock ignored). Self-leave reused that path and + * paid out organiser-fault money even after the session had started — the + * dialog copy promised "under the event's cancellation policy", which is the + * attendee notice tiers. + * + * Default remains `"organiser"` so existing roster/moderation callers keep the + * full-refund behaviour without an explicit flag. Self-leave must pass + * `"attendee"` and we resolve `hoursUntilStart` from the next future live slot + * (`startsAt >= now`) so a mid-program class leave uses the upcoming session, + * not a past COMPLETED/UNVERIFIED row that would force 0%. * * Never throws: the roster change has already committed. */ @@ -200,11 +211,18 @@ export async function refundRemovedAttendeeSeat(args: { eventId: string; attendeeUserId: string; initiatedByUserId: string | null; + /** + * Defaults to organiser (full tier). Pass `"attendee"` for consultee + * self-leave so notice-window tiers apply. + */ + initiatedBy?: "organiser" | "attendee"; }): Promise<{ amountRefundedPaise: number; refundPct: number } | null> { const eventFilter = args.kind === "webinar" ? { webinarId: args.eventId } : { classId: args.eventId }; + // Missing flag = legacy organiser path; do not flip the money default. + const isOrganiserInitiated = (args.initiatedBy ?? "organiser") === "organiser"; // Hoisted so the catch can scope its ops event to the funding organisation; // a failure reported against `null` never reaches the org that is owed it. @@ -233,11 +251,30 @@ export async function refundRemovedAttendeeSeat(args: { if (!payment) return null; organizationId = payment.organizationId; + // Organiser branch ignores the clock inside computeRefundPct; skip the + // slot lookup. Attendee branch needs a real hoursUntilStart — negative + // means already started → 0% under the tiers (and the DELETE route should + // have 400'd before we got here for self-leave). + let hoursUntilStart = -1; + if (!isOrganiserInitiated) { + const now = new Date(); + // Next upcoming session — not the earliest historical live row. + // Past class sessions stay SCHEDULED/COMPLETED/UNVERIFIED and would + // otherwise pin hoursUntilStart negative → permanent 0% refund. + const nextLive = await findLiveEventSlot(eventFilter, { + order: "asc", + startsAtGte: now, + }); + if (nextLive) { + hoursUntilStart = + (nextLive.startsAt.getTime() - now.getTime()) / (1000 * 60 * 60); + } + } + const refundPct = computeRefundPct( parsePolicySnapshot(payment.appointment?.cancellationPolicySnapshot), - // Ignored on the consultant-initiated branch. - -1, - true, + hoursUntilStart, + isOrganiserInitiated, ); // Clamp to the remaining balance, exactly as the cancel route does. A seat // carrying an earlier partial refund would otherwise ask for more than is @@ -249,10 +286,11 @@ export async function refundRemovedAttendeeSeat(args: { ); if (amountPaise <= 0) return { amountRefundedPaise: 0, refundPct }; + const actorLabel = isOrganiserInitiated ? "organiser" : "attendee"; const result = await refundBookingPayment({ paymentId: payment.id, amountPaise, - reason: `removed from ${args.kind} ${args.eventId} by the organiser (${refundPct}%)`, + reason: `removed from ${args.kind} ${args.eventId} by the ${actorLabel} (${refundPct}%)`, initiatedByUserId: args.initiatedByUserId, }); @@ -265,7 +303,9 @@ export async function refundRemovedAttendeeSeat(args: { ...notificationScope(payment.organizationId), amount: amountPaise, currency: payment.currency, - reason: `You were removed from this ${args.kind}.`, + reason: isOrganiserInitiated + ? `You were removed from this ${args.kind}.` + : `You left this ${args.kind}.`, dashboardUrl: `${getAppUrl()}/dashboard`, }).catch(() => {}); } diff --git a/lib/scheduling/allocationAlgorithms.ts b/lib/scheduling/allocationAlgorithms.ts index 885d9c2a2..26da349ce 100644 --- a/lib/scheduling/allocationAlgorithms.ts +++ b/lib/scheduling/allocationAlgorithms.ts @@ -53,6 +53,8 @@ export interface AllocationOptions { idempotencyKey?: string; // Reject with 409 if the event already has confirmed slots (multi-tab guard). initialAllocation?: boolean; + /** #1012 — reschedule stale-tab precondition. */ + expectedTentativeSlotCount?: number; // Timezone defining the limit day/week buckets (ADR B9); defaults to // Asia/Kolkata in the shared helpers. schedulingTimezone?: string; @@ -208,6 +210,7 @@ export class AllocationAlgorithms { { idempotencyKey: options.idempotencyKey, initialAllocation: options.initialAllocation, + expectedTentativeSlotCount: options.expectedTentativeSlotCount, }, ); @@ -369,6 +372,7 @@ export class AllocationAlgorithms { // as an explicit manual batch; isAuto would make the server re-pick. idempotencyKey: options.idempotencyKey, initialAllocation: options.initialAllocation, + expectedTentativeSlotCount: options.expectedTentativeSlotCount, }, ); @@ -456,6 +460,7 @@ export class AllocationAlgorithms { useRequestedSlots: true, idempotencyKey: options.idempotencyKey, initialAllocation: options.initialAllocation, + expectedTentativeSlotCount: options.expectedTentativeSlotCount, }, ); diff --git a/lib/scheduling/allocationService.ts b/lib/scheduling/allocationService.ts index 5c350866a..d1c0c6bc1 100644 --- a/lib/scheduling/allocationService.ts +++ b/lib/scheduling/allocationService.ts @@ -34,6 +34,8 @@ export interface AllocationRequest { * Allocate Slots dialog so a stale tab can't silently replace another * tab's allocation. Reschedule flows omit it. */ initialAllocation?: boolean; + /** #1012 — reschedule stale-tab precondition. */ + expectedTentativeSlotCount?: number; } /** What the allocate endpoints actually return in `data`: the created (or @@ -55,6 +57,8 @@ export interface AllocationCallOptions { isAuto?: boolean; useRequestedSlots?: boolean; initialAllocation?: boolean; + /** #1012 — reschedule stale-tab precondition. */ + expectedTentativeSlotCount?: number; /** Sent as the Idempotency-Key header; the server replays the original * batch for a repeated key instead of double-booking (#837). */ idempotencyKey?: string; @@ -253,6 +257,7 @@ export class AllocationService { slots: allocationOptions?.isAuto ? undefined : slotStrings, useRequestedSlots: allocationOptions?.useRequestedSlots, initialAllocation: allocationOptions?.initialAllocation, + expectedTentativeSlotCount: allocationOptions?.expectedTentativeSlotCount, }; const paths = { diff --git a/schemas/plans.ts b/schemas/plans.ts index 1e542811e..5054f493f 100644 --- a/schemas/plans.ts +++ b/schemas/plans.ts @@ -544,10 +544,19 @@ export const ClassPlanSchema = BaseEventPlanSchema.extend({ .number() .min(1, "Duration must be at least 1 month") .max(24, "Duration cannot exceed 24 months"), + // #1071 — planner CRUD now expands each session into N×30min atoms via + // `getSlotsPerCall` (= ceil(hours/0.5)). WebinarPlan already refined to + // 30-minute steps; ClassPlan only had min/max, so a 0.75h class silently + // became 2×30 = 60min on the consultant's calendar. Reject at the schema + // rather than clamping the last atom (non-30 ends break allocator parity). sessionDurationInHours: z .number() .min(0.5, "Session duration must be at least 30 minutes") .max(4, "Session duration cannot exceed 4 hours") + .refine( + (val) => (val * 60) % 30 === 0, + "Session duration must be in 30-minute increments", + ) .default(1), certificateProvided: z.boolean().default(false), recordingEnabled: z.boolean().default(false), diff --git a/schemas/slotAllocation/validationSchemas.ts b/schemas/slotAllocation/validationSchemas.ts index e5df28117..7cbe9d5d6 100644 --- a/schemas/slotAllocation/validationSchemas.ts +++ b/schemas/slotAllocation/validationSchemas.ts @@ -52,6 +52,16 @@ export const allocationRequestSchema = z }) .optional(), + // #1012 — reschedule stale-tab precondition. When present, must equal the + // live tentative slot count or the allocate returns 409. + expectedTentativeSlotCount: z + .number({ + invalid_type_error: "'expectedTentativeSlotCount' must be a number", + }) + .int() + .nonnegative() + .optional(), + // Consultant's explicit acceptance of times outside their own published // availability. The dialog has always offered this ("Override and // Allocate"), but the field was absent from this schema and therefore diff --git a/utils/slotAllocation/SlotAllocationService.ts b/utils/slotAllocation/SlotAllocationService.ts index a319c2a36..7b4ea2a81 100644 --- a/utils/slotAllocation/SlotAllocationService.ts +++ b/utils/slotAllocation/SlotAllocationService.ts @@ -113,6 +113,7 @@ export class SlotAllocationService { request.eventId, request.idempotencyKey, request.initialAllocation, + request.expectedTentativeSlotCount, ); case "manual": @@ -131,6 +132,7 @@ export class SlotAllocationService { request.idempotencyKey, request.initialAllocation, request.wideLock, + request.expectedTentativeSlotCount, ); case "requested": @@ -140,6 +142,7 @@ export class SlotAllocationService { request.initialAllocation, request.idempotencyKey, request.override, + request.expectedTentativeSlotCount, ); default: @@ -407,6 +410,61 @@ export class SlotAllocationService { } } + /** + * #1012 — stale-tab reschedule precondition. The page that opened the + * allocate dialog captured the tentative count; if another tab already + * finished (or mutated) the reschedule, that count no longer matches and + * we 409 instead of delete+recreating confirmed slots. + */ + private static assertExpectedTentativeSlotCount( + actual: number, + expected: number | undefined, + ): void { + if (expected === undefined) return; + if (actual !== expected) { + throw new AllocationConflictError( + `Reschedule state changed in another session ` + + `(expected ${expected} tentative slot(s), found ${actual}). ` + + `Reload and try again.`, + ); + } + } + + /** + * #1012 — in-txn re-assert of expectedTentativeSlotCount. + * + * The pre-txn read only catches sequential stale submissions. Under the + * Redis lock another writer can still commit between that read and our + * write txn (e.g. a second tab that raced the lock, or useRequestedSlots). + * `guardInitialAllocationInTx` only covers fresh allocations, not + * reschedules. Re-read with `tx` before delete/recreate, matching the + * requested-slots path. + */ + private static async assertExpectedTentativeSlotCountInTx( + tx: Tx, + eventType: EventType, + eventId: string, + expected: number | undefined, + ): Promise { + if (expected === undefined) return; + const relationField = this.getEventRelationField(eventType); + const existingAppointments: AppointmentWithSlots[] = + await tx.appointment.findMany({ + where: { + [`${relationField}Id`]: eventId, + } as Prisma.AppointmentWhereInput, + include: { slotsOfAppointment: true }, + }); + const tentativeSlotCount = existingAppointments.reduce( + (count, appointment) => + count + + appointment.slotsOfAppointment.filter((slot) => slot.isTentative) + .length, + 0, + ); + this.assertExpectedTentativeSlotCount(tentativeSlotCount, expected); + } + /** * AUTO ALLOCATION: Find and allocate first available consecutive slots * @@ -595,6 +653,7 @@ export class SlotAllocationService { eventId: string, idempotencyKey?: string, initialAllocation?: boolean, + expectedTentativeSlotCount?: number, ): Promise { // #837 — return the prior batch on a double-submit before doing any work. const replay = await this.findIdempotentAllocation( @@ -689,6 +748,12 @@ export class SlotAllocationService { .length, 0, ); + // #1012 — before any delete+recreate, confirm the page's view of the + // tentative set still matches the database. + this.assertExpectedTentativeSlotCount( + tentativeSlotCount, + expectedTentativeSlotCount, + ); const isReschedule = tentativeSlotCount > 0; // ADR B10, derived rather than trusted. The client set initialAllocation @@ -877,6 +942,15 @@ export class SlotAllocationService { if (lockedReplay) return lockedReplay; } + // #1012 — reschedule path is outside guardInitialAllocationInTx; + // re-assert tentative count under the write txn before delete. + await SlotAllocationService.assertExpectedTentativeSlotCountInTx( + tx, + eventType, + eventId, + expectedTentativeSlotCount, + ); + // CRITICAL FIX: Delete existing appointments before creating new ones // For reschedules: only delete appointments with tentative slots (preserve confirmed ones) // For in-progress: only delete future slots (preserve past confirmed ones) @@ -977,6 +1051,7 @@ export class SlotAllocationService { idempotencyKey?: string, initialAllocation?: boolean, wideLock?: boolean, + expectedTentativeSlotCount?: number, ): Promise { // #837 — return the prior batch on a double-submit before doing any work. const replay = await this.findIdempotentAllocation( @@ -1121,6 +1196,12 @@ export class SlotAllocationService { .length, 0, ); + // #1012 — before any delete+recreate, confirm the page's view of the + // tentative set still matches the database. + this.assertExpectedTentativeSlotCount( + tentativeSlotCount, + expectedTentativeSlotCount, + ); const isReschedule = tentativeSlotCount > 0; const existingNonTentativeSlotCount = existingAppointments.reduce( @@ -1276,6 +1357,15 @@ export class SlotAllocationService { if (lockedReplay) return lockedReplay; } + // #1012 — reschedule path is outside guardInitialAllocationInTx; + // re-assert tentative count under the write txn before delete. + await SlotAllocationService.assertExpectedTentativeSlotCountInTx( + tx, + eventType, + eventId, + expectedTentativeSlotCount, + ); + // Delete existing appointments // For reschedules: only delete tentative slots (preserve confirmed ones) // For in-progress: only delete future slots (preserve past confirmed ones) @@ -1375,6 +1465,7 @@ export class SlotAllocationService { idempotencyKey?: string, /** Consultant accepting times outside their own published availability. */ overrideAvailabilityWindow?: boolean, + expectedTentativeSlotCount?: number, ): Promise { // #837 — a retry whose first response was lost must replay the approved // batch, not trip the initial-allocation guard with a 409. @@ -1464,6 +1555,19 @@ export class SlotAllocationService { include: { slotsOfAppointment: true }, }); + const tentativeSlotCount = existingAppointments.reduce( + (count, appointment) => + count + + appointment.slotsOfAppointment.filter((slot) => slot.isTentative) + .length, + 0, + ); + // #1012 — stale-tab reschedule / approval precondition. + SlotAllocationService.assertExpectedTentativeSlotCount( + tentativeSlotCount, + expectedTentativeSlotCount, + ); + if (existingAppointments.length === 0) { throw new AllocationValidationError( "Cannot approve requested slots: No appointments found. " + diff --git a/utils/slotAllocation/types.ts b/utils/slotAllocation/types.ts index 777c94c31..cc28163f6 100644 --- a/utils/slotAllocation/types.ts +++ b/utils/slotAllocation/types.ts @@ -46,6 +46,13 @@ export interface AllocationRequest { // Redis lock keys (#860), so a cross-mode race from two tabs otherwise ends // in the manual path silently deleting the winner's allocation. initialAllocation?: boolean; + /** + * #1012 — reschedule stale-tab guard. When set, the current tentative slot + * count must match exactly; otherwise another tab already completed (or + * mutated) the reschedule and this submit would delete+recreate confirmed + * slots. Fresh allocations omit the field. + */ + expectedTentativeSlotCount?: number; /** * Manual mode only. When true the Redis lock is taken consultant-WIDE rather * than sharded by the target day.