diff --git a/__tests__/booking-algorithm/reschedule-affordance.test.ts b/__tests__/booking-algorithm/reschedule-affordance.test.ts new file mode 100644 index 000000000..1930946dc --- /dev/null +++ b/__tests__/booking-algorithm/reschedule-affordance.test.ts @@ -0,0 +1,56 @@ +/** + * The Reschedule menu item's slot-derived gate. + * + * Both sides now share one predicate because they drifted: the consultee's had + * the zero-slot and in-flight checks, the consultant's had neither, so a + * consultant could open a picker for a booking with nothing to move or one + * already awaiting a new time — reaching a 409 or PROPOSAL_WINDOW_CLOSED that + * the menu should never have offered. + */ + +import { slotsAllowReschedule } from "@/lib/appointments/slots"; + +describe("slotsAllowReschedule", () => { + it("allows a booking with confirmed sessions", () => { + expect( + slotsAllowReschedule([ + { isTentative: false, completionStatus: "SCHEDULED" }, + { isTentative: false, completionStatus: "SCHEDULED" }, + ]), + ).toBe(true); + }); + + it("refuses an approved booking with nothing allocated", () => { + // "Not scheduled · 0/0" — the proposal window is derived from the earliest + // released session, so there is nothing to derive it from. + expect(slotsAllowReschedule([])).toBe(false); + }); + + it("refuses a request still awaiting its first allocation", () => { + expect( + slotsAllowReschedule([{ isTentative: true, completionStatus: null }]), + ).toBe(false); + }); + + it("refuses while a reschedule is already in flight", () => { + // openForAppointmentId is a nullable-unique, so a second live request is + // impossible — offering the action again only earns a 409. + expect( + slotsAllowReschedule([ + { isTentative: false, completionStatus: "SCHEDULED" }, + { isTentative: true, completionStatus: "RESCHEDULED" }, + ]), + ).toBe(false); + }); + + it("stays permissive about sessions that merely finished", () => { + // COMPLETED is not RESCHEDULED: a subscription with past sessions and + // future ones is still movable. + expect( + slotsAllowReschedule([ + { isTentative: false, completionStatus: "COMPLETED" }, + { isTentative: false, completionStatus: "SCHEDULED" }, + ]), + ).toBe(true); + }); +}); diff --git a/__tests__/booking-algorithm/reschedule-proposal-schema.test.ts b/__tests__/booking-algorithm/reschedule-proposal-schema.test.ts new file mode 100644 index 000000000..a319a5842 --- /dev/null +++ b/__tests__/booking-algorithm/reschedule-proposal-schema.test.ts @@ -0,0 +1,70 @@ +/** + * Proposed times are 30-minute atoms (ADR B1), enforced at the contract edge. + * + * This is not style. Auto-confirm hands the allocator `startsAt` alone and + * manual mode reads each string as ONE 30-minute start, so `endsAt` is never + * consulted — a 60-minute proposal books 30 minutes. The count check passes + * (one proposed row per released slot), which is exactly what makes it + * invisible: the consultee asks to move a 1-hour session and silently gets + * half of one. + */ + +import { RescheduleProposalSchema } from "@/schemas/appointments"; + +const at = (iso: string) => new Date(iso).toISOString(); + +describe("RescheduleProposalSchema", () => { + it("accepts a 30-minute proposal", () => { + const parsed = RescheduleProposalSchema.safeParse({ + proposedSlots: [ + { startsAt: at("2026-09-01T09:00:00Z"), endsAt: at("2026-09-01T09:30:00Z") }, + ], + }); + expect(parsed.success).toBe(true); + }); + + it("accepts consecutive atoms — a 1-hour session is two rows, not one long one", () => { + const parsed = RescheduleProposalSchema.safeParse({ + proposedSlots: [ + { startsAt: at("2026-09-01T09:00:00Z"), endsAt: at("2026-09-01T09:30:00Z") }, + { startsAt: at("2026-09-01T09:30:00Z"), endsAt: at("2026-09-01T10:00:00Z") }, + ], + }); + expect(parsed.success).toBe(true); + }); + + it("rejects a 60-minute row, which would book 30", () => { + const parsed = RescheduleProposalSchema.safeParse({ + proposedSlots: [ + { startsAt: at("2026-09-01T09:00:00Z"), endsAt: at("2026-09-01T10:00:00Z") }, + ], + }); + expect(parsed.success).toBe(false); + }); + + it("rejects a row shorter than an atom", () => { + const parsed = RescheduleProposalSchema.safeParse({ + proposedSlots: [ + { startsAt: at("2026-09-01T09:00:00Z"), endsAt: at("2026-09-01T09:15:00Z") }, + ], + }); + expect(parsed.success).toBe(false); + }); + + it("still rejects an end at or before its start", () => { + for (const endsAt of ["2026-09-01T09:00:00Z", "2026-08-01T09:00:00Z"]) { + const parsed = RescheduleProposalSchema.safeParse({ + proposedSlots: [{ startsAt: at("2026-09-01T09:00:00Z"), endsAt: at(endsAt) }], + }); + expect(parsed.success).toBe(false); + } + }); + + it("keeps 'any time works' valid — no proposed times at all", () => { + // Releasing without naming a time predates proposals and is still the whole + // contract for group events. + expect(RescheduleProposalSchema.safeParse({ slotIds: ["a"] }).success).toBe( + true, + ); + }); +}); diff --git a/__tests__/booking-algorithm/reschedule-proposals.test.ts b/__tests__/booking-algorithm/reschedule-proposals.test.ts index 8f4024f41..5057aea23 100644 --- a/__tests__/booking-algorithm/reschedule-proposals.test.ts +++ b/__tests__/booking-algorithm/reschedule-proposals.test.ts @@ -8,11 +8,9 @@ import "./setup"; import { - MAX_PROPOSAL_ROUNDS, PROPOSAL_MAX_LIFETIME_HOURS, computeProposalExpiry, mayAutoConfirm, - mayCounter, proposalCountMatches, supportsProposals, } from "../../lib/booking/reschedule-proposals"; @@ -90,11 +88,6 @@ describe("scope and shape guards", () => { expect(proposalCountMatches(2, 1)).toBe(false); expect(proposalCountMatches(2, 3)).toBe(false); }); - - it("permits exactly one counter-round", () => { - expect(mayCounter(1)).toBe(true); - expect(mayCounter(MAX_PROPOSAL_ROUNDS)).toBe(false); - }); }); describe("RESCHEDULE_ALLOWED_FROM state machine", () => { @@ -134,9 +127,8 @@ describe("RESCHEDULE_ALLOWED_FROM state machine", () => { describe("allocationRequestSchema carries override", () => { it("no longer strips the field the Override button sends", async () => { - const { allocationRequestSchema } = await import( - "../../schemas/slotAllocation/validationSchemas" - ); + const { allocationRequestSchema } = + await import("../../schemas/slotAllocation/validationSchemas"); // Before this, `override` was absent from the schema and a plain (non- // passthrough) Zod object dropped it, so "Override and Allocate" sent a diff --git a/__tests__/booking-algorithm/reschedule-withdraw-behavior.test.ts b/__tests__/booking-algorithm/reschedule-withdraw-behavior.test.ts new file mode 100644 index 000000000..99b462ef4 --- /dev/null +++ b/__tests__/booking-algorithm/reschedule-withdraw-behavior.test.ts @@ -0,0 +1,273 @@ +/** + * @jest-environment node + */ + +/** + * withdrawRescheduleRequest — behaviour, not just the enum shape. + * + * State-based prisma mock (same idiom as cancel-pending-checkout.test.ts): the + * tx stub mutates an in-memory store so the CAS guards run for real via the + * mocked updateMany counts. + * + * The asymmetry under test: a WITHDRAWAL restores the booking, a DECLINE does + * not. Both end the same request, and collapsing them would leave a consultee + * who was declined silently back where they started. + */ + +interface SlotRow { + id: string; + isTentative: boolean; + completionStatus: string; +} + +interface RequestRow { + id: string; + status: string; + initiatedById: string; + releasedSlotIds: string[]; + appointmentId: string; + openForAppointmentId: string | null; + appointment: { + consultationId: string | null; + subscriptionId: string | null; + }; +} + +interface Store { + request: RequestRow | null; + slots: SlotRow[]; + consultation: { id: string; status: string } | null; +} + +let state: Store; + +type Data = Record; +/** The CAS shape both request tables are guarded by. */ +interface StatusCas { + where: { id: string; status?: { in: string[] } }; + data: Data; +} +interface SlotCas { + where: { id: { in: string[] }; completionStatus: string }; + data: Data; +} + +function makeTx() { + return { + rescheduleRequest: { + updateMany: jest.fn(async ({ where, data }: StatusCas) => { + const row = state.request; + if (!row || row.id !== where.id) return { count: 0 }; + // The CAS: the from-set is the state machine. + if (where.status?.in && !where.status.in.includes(row.status)) { + return { count: 0 }; + } + Object.assign(row, data); + return { count: 1 }; + }), + }, + slotOfAppointment: { + updateMany: jest.fn(async ({ where, data }: SlotCas) => { + const targets = state.slots.filter( + (s) => + where.id.in.includes(s.id) && + s.completionStatus === where.completionStatus, + ); + targets.forEach((s) => Object.assign(s, data)); + return { count: targets.length }; + }), + }, + consultation: { + updateMany: jest.fn(async ({ where, data }: StatusCas) => { + const row = state.consultation; + if (!row || row.id !== where.id) return { count: 0 }; + if (where.status?.in && !where.status.in.includes(row.status)) { + return { count: 0 }; + } + Object.assign(row, data); + return { count: 1 }; + }), + }, + }; +} + +let tx: ReturnType; + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + rescheduleRequest: { + findUnique: jest.fn(async () => state.request), + }, + $transaction: jest.fn(async (fn: (t: ReturnType) => unknown) => + fn(tx), + ), + }, +})); + +const reportSentryError = jest.fn(); +jest.mock("../../lib/observability/report", () => ({ + __esModule: true, + reportSentryError: (...args: unknown[]) => reportSentryError(...args), +})); + +import { withdrawRescheduleRequest } from "../../lib/booking/reschedule-withdraw"; + +const INITIATOR = "user-consultee"; +const OTHER = "user-consultant"; + +function seed( + overrides: Partial<{ + status: string; + slots: SlotRow[]; + consultationId: string | null; + subscriptionId: string | null; + consultationStatus: string; + }> = {}, +) { + const slots = overrides.slots ?? [ + { id: "slot-1", isTentative: true, completionStatus: "RESCHEDULED" }, + { id: "slot-2", isTentative: true, completionStatus: "RESCHEDULED" }, + ]; + const consultationId = + overrides.consultationId === undefined ? "cons-1" : overrides.consultationId; + + state = { + request: { + id: "req-1", + status: overrides.status ?? "PENDING_REVIEW", + initiatedById: INITIATOR, + releasedSlotIds: slots.map((s) => s.id), + appointmentId: "appt-1", + openForAppointmentId: "appt-1", + appointment: { + consultationId, + subscriptionId: overrides.subscriptionId ?? null, + }, + }, + slots, + consultation: consultationId + ? { id: consultationId, status: overrides.consultationStatus ?? "PENDING" } + : null, + }; + tx = makeTx(); + reportSentryError.mockClear(); +} + +describe("withdrawRescheduleRequest", () => { + it("restores the released slots and reopens the appointment's lock", async () => { + seed(); + + const result = await withdrawRescheduleRequest({ + rescheduleRequestId: "req-1", + withdrawnById: INITIATOR, + }); + + expect(result).toEqual({ withdrawn: true }); + expect(state.slots).toEqual([ + { id: "slot-1", isTentative: false, completionStatus: "SCHEDULED" }, + { id: "slot-2", isTentative: false, completionStatus: "SCHEDULED" }, + ]); + expect(state.request?.status).toBe("WITHDRAWN"); + // Terminal, so the nullable-unique stops reserving the appointment. Miss + // this and every later reschedule of this booking is blocked forever. + expect(state.request?.openForAppointmentId).toBeNull(); + }); + + it("refuses anyone who did not open it, and touches nothing", async () => { + seed(); + + const result = await withdrawRescheduleRequest({ + rescheduleRequestId: "req-1", + withdrawnById: OTHER, + }); + + expect(result).toEqual({ withdrawn: false, reason: "NOT_INITIATOR" }); + expect(state.request?.status).toBe("PENDING_REVIEW"); + expect(state.slots.every((s) => s.completionStatus === "RESCHEDULED")).toBe( + true, + ); + }); + + it("refuses a request that already resolved", async () => { + seed({ status: "ACCEPTED" }); + + const result = await withdrawRescheduleRequest({ + rescheduleRequestId: "req-1", + withdrawnById: INITIATOR, + }); + + expect(result).toEqual({ withdrawn: false, reason: "PROPOSAL_NOT_OPEN" }); + expect(state.slots.every((s) => s.completionStatus === "RESCHEDULED")).toBe( + true, + ); + }); + + it("reports a lost CAS as PROPOSAL_NOT_OPEN, not as an error", async () => { + seed(); + // The other party answers between the open-status read and the CAS. The + // transition then matches zero rows and throws IllegalTransitionError — + // a modelled outcome, so it must not reach Sentry or become a 500. + tx.rescheduleRequest.updateMany.mockResolvedValueOnce({ count: 0 }); + + const result = await withdrawRescheduleRequest({ + rescheduleRequestId: "req-1", + withdrawnById: INITIATOR, + }); + + expect(result).toEqual({ withdrawn: false, reason: "PROPOSAL_NOT_OPEN" }); + expect(reportSentryError).not.toHaveBeenCalled(); + }); + + it("puts a consultation back to APPROVED so it leaves the allocate queue", async () => { + seed(); + + await withdrawRescheduleRequest({ + rescheduleRequestId: "req-1", + withdrawnById: INITIATOR, + }); + + expect(state.consultation?.status).toBe("APPROVED"); + }); + + it("leaves a subscription alone — it has no per-session status (#448)", async () => { + seed({ consultationId: null, subscriptionId: "sub-1" }); + + const result = await withdrawRescheduleRequest({ + rescheduleRequestId: "req-1", + withdrawnById: INITIATOR, + }); + + expect(result).toEqual({ withdrawn: true }); + expect(tx.consultation.updateMany).not.toHaveBeenCalled(); + // The slots still restore; only the parent status is skipped. + expect(state.slots.every((s) => s.completionStatus === "SCHEDULED")).toBe( + true, + ); + }); + + it("reports a partial restore instead of claiming success silently", async () => { + // One row's status drifted, so the RESCHEDULED-filtered updateMany skips + // it. The withdrawal is committed and correct, but the booking is + // half-restored and nothing else would ever say so. + seed({ + slots: [ + { id: "slot-1", isTentative: true, completionStatus: "RESCHEDULED" }, + { id: "slot-2", isTentative: true, completionStatus: "CANCELLED" }, + ], + }); + + const result = await withdrawRescheduleRequest({ + rescheduleRequestId: "req-1", + withdrawnById: INITIATOR, + }); + + expect(result).toEqual({ withdrawn: true }); + expect(reportSentryError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("restored 1 of 2"), + }), + expect.objectContaining({ op: "reschedule-withdraw-partial" }), + ); + }); +}); diff --git a/__tests__/booking-algorithm/reschedule-withdraw.test.ts b/__tests__/booking-algorithm/reschedule-withdraw.test.ts new file mode 100644 index 000000000..8bf2569a3 --- /dev/null +++ b/__tests__/booking-algorithm/reschedule-withdraw.test.ts @@ -0,0 +1,54 @@ +/** + * Withdrawal semantics. + * + * The asymmetry pinned here is deliberate and easy to "simplify" away later: + * a WITHDRAWAL restores the booking, a DECLINE does not. Both end the same + * request, but they mean opposite things — the initiator no longer wants the + * move, versus the other party not agreeing to it. Collapsing them would leave + * a consultee who was declined silently back where they started with no + * pending request, and would make the audit trail unable to say who ended it. + */ + +import { + RESCHEDULE_ALLOWED_FROM, + RESCHEDULE_OPEN_STATUSES, + RESCHEDULE_TERMINAL_STATUSES, +} from "@/lib/booking/transitions"; + +describe("WITHDRAWN is a first-class terminal state", () => { + it("is terminal, so it releases the appointment's reschedule lock", () => { + // transitionRescheduleRequest clears openForAppointmentId for any terminal + // status. Miss this and a withdrawn request keeps the nullable-unique held, + // permanently blocking every later reschedule of that booking. + expect(RESCHEDULE_TERMINAL_STATUSES).toContain("WITHDRAWN"); + }); + + it("is not an open state", () => { + expect(RESCHEDULE_OPEN_STATUSES).not.toContain("WITHDRAWN"); + }); + + it("is reachable only from a request still awaiting an answer", () => { + expect(RESCHEDULE_ALLOWED_FROM.WITHDRAWN).toEqual([ + "PENDING_REVIEW", + "COUNTERED", + ]); + }); + + it("cannot be reached from a request that already resolved", () => { + // The CAS guard is what stops a withdrawal un-releasing slots that a + // concurrent accept has already re-confirmed. + for (const settled of ["ACCEPTED", "AUTO_ACCEPTED", "DECLINED", "EXPIRED"]) { + expect(RESCHEDULE_ALLOWED_FROM.WITHDRAWN).not.toContain(settled); + } + }); + + it("stays distinct from DECLINED", () => { + // Same allowed-from, deliberately different status: they resolve the + // released slots differently, so they must not be collapsed into one. + expect(RESCHEDULE_ALLOWED_FROM.DECLINED).toEqual( + RESCHEDULE_ALLOWED_FROM.WITHDRAWN, + ); + expect(RESCHEDULE_TERMINAL_STATUSES).toContain("DECLINED"); + expect(RESCHEDULE_TERMINAL_STATUSES).toContain("WITHDRAWN"); + }); +}); diff --git a/__tests__/plans/offering-manifests.test.ts b/__tests__/plans/offering-manifests.test.ts index 8c03d60bd..5dfb59cf8 100644 --- a/__tests__/plans/offering-manifests.test.ts +++ b/__tests__/plans/offering-manifests.test.ts @@ -212,4 +212,8 @@ describe("slot status tokens", () => { Object.keys(SLOT_STATUS_TOKENS).sort(), ); }); + + // The cell-vs-legend colour-equality and border-conflict regression tests + // for #1064 live in __tests__/schedule/slot-palette.test.ts, alongside a + // source scan for the retired hardcoded classes. }); diff --git a/__tests__/schedule/slot-palette.test.ts b/__tests__/schedule/slot-palette.test.ts new file mode 100644 index 000000000..9d5c52e20 --- /dev/null +++ b/__tests__/schedule/slot-palette.test.ts @@ -0,0 +1,177 @@ +/** + * #1064 — the legend and the grid must paint the same colours. + * + * The pre-existing legend test asserted only that the consultant legend + * COVERS every state the grid can render. It did, the whole time the grid was + * hardcoded to green-300 / yellow-400 / slate-400 / black while the legend + * rendered from the emerald / amber / slate tokens. Coverage was never the + * property that mattered; sameness was. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { + SLOT_CELL_BASE_CLASS, + SLOT_STATUS_TOKENS, + CONSULTANT_LEGEND_KEYS, + resolveSlotStatusKey, + slotCellClassName, + type SlotStatusKey, +} from "@/lib/scheduling/slot-status-tokens"; + +const KEYS = Object.keys(SLOT_STATUS_TOKENS) as SlotStatusKey[]; + +const classesOf = (value: string) => value.split(/\s+/).filter(Boolean); + +/** Every colour utility on an element, ignoring variants like `hover:`. */ +const colourUtilities = (value: string, prefix: string) => + classesOf(value).filter( + (name) => !name.includes(":") && name.startsWith(prefix), + ); + +describe("slot palette — cells and legend render the same colours", () => { + it.each(KEYS)("%s: the cell and its legend swatch share one fill", (key) => { + const token = SLOT_STATUS_TOKENS[key]; + + expect(colourUtilities(slotCellClassName(key), "bg-")).toEqual([ + token.fill, + ]); + expect(colourUtilities(token.swatchClassName, "bg-")).toEqual([token.fill]); + }); + + it.each(KEYS)( + "%s: the cell and its legend swatch share one border", + (key) => { + const token = SLOT_STATUS_TOKENS[key]; + + expect(colourUtilities(slotCellClassName(key), "border-")).toEqual([ + token.border, + ]); + expect(colourUtilities(token.swatchClassName, "border-")).toEqual([ + token.border, + ]); + }, + ); + + it("no two states are painted the same colour", () => { + const fills = CONSULTANT_LEGEND_KEYS.map( + (key) => SLOT_STATUS_TOKENS[key].fill, + ); + expect(new Set(fills).size).toBe(fills.length); + }); +}); + +describe("slot palette — the base cell string cannot fight the token", () => { + /** + * The reverted attempt (49973623) left `border-transparent` in the base + * string and appended `border-slate-200` from the token. Two same-specificity + * border-color utilities on one element resolve by stylesheet order, and + * Tailwind emits `.border-transparent` last — so the token's border was + * always discarded and unavailable cells rendered with no outline at all. + */ + it("carries a border width but no border colour", () => { + expect(classesOf(SLOT_CELL_BASE_CLASS)).toContain("border"); + expect(colourUtilities(SLOT_CELL_BASE_CLASS, "border-")).toEqual([]); + }); + + it("carries no background of its own", () => { + expect(colourUtilities(SLOT_CELL_BASE_CLASS, "bg-")).toEqual([]); + }); +}); + +describe("slot palette — Tailwind can see where the tokens live", () => { + /** + * The tokens sit in `lib/`, and `content` used to list only `components/` + * and `app/`. Tailwind emits a utility only if it has SEEN the class in a + * scanned file, so every token colour that did not coincidentally appear + * under those two trees was absent from the stylesheet entirely — cells with + * no fill and no border, which is how the reverted attempt produced a grid + * whose future days looked blank while past days (painted from a literal in + * the component) still showed (#1064). + */ + it("scans the directory the tokens are defined in", () => { + const config = readFileSync( + path.join(process.cwd(), "tailwind.config.ts"), + "utf8", + ); + expect(config).toContain('"./lib/**/*.{js,ts,jsx,tsx,mdx}"'); + }); +}); + +describe("slot palette — unavailable stays visible", () => { + /** + * bg-slate-100 on a white card is what made a sparse week read as an empty + * grid instead of a full one with little availability. Whatever this token + * becomes, it may not go back to near-white. + */ + it("is not a near-white fill", () => { + expect([ + "bg-white", + "bg-transparent", + "bg-slate-50", + "bg-slate-100", + ]).not.toContain(SLOT_STATUS_TOKENS.unavailable.fill); + }); + + it("is lighter than a booked slot but darker than nothing", () => { + expect(SLOT_STATUS_TOKENS.unavailable.fill).not.toBe( + SLOT_STATUS_TOKENS.fullyBooked.fill, + ); + expect(SLOT_STATUS_TOKENS.unavailable.border).toBeTruthy(); + }); +}); + +describe("slot palette — the grid resolves states through the tokens", () => { + const flags = { + isSelected: false, + isThisEventSlot: false, + isRescheduling: false, + isBookedForDisplay: false, + isPartiallyBooked: false, + isAvailable: false, + isInPast: false, + }; + + it.each([ + ["selected" as const, { isSelected: true }], + ["thisEvent" as const, { isThisEventSlot: true }], + ["rescheduling" as const, { isRescheduling: true }], + ["fullyBooked" as const, { isBookedForDisplay: true }], + ["partiallyBooked" as const, { isPartiallyBooked: true }], + ["available" as const, { isAvailable: true }], + ["unavailable" as const, { isAvailable: true, isInPast: true }], + ["unavailable" as const, {}], + ])("resolves to %s", (expected, overrides) => { + expect(resolveSlotStatusKey({ ...flags, ...overrides })).toBe(expected); + }); + + /** + * A source scan, deliberately: the drift this file exists to catch was + * `renderTimeCell` writing colours by hand, and no assertion about the + * tokens alone can see that happen again. + */ + it("UnifiedCalendar hardcodes none of the retired slot colours", () => { + const source = readFileSync( + path.join( + process.cwd(), + "components", + "scheduling", + "UnifiedCalendar.tsx", + ), + "utf8", + ); + + for (const retired of [ + "bg-green-300", + "bg-green-700", + "bg-yellow-400", + "bg-slate-400", + "bg-gray-300", + "bg-amber-400", + "bg-black", + "border border-transparent", + ]) { + expect(source).not.toContain(retired); + } + }); +}); diff --git a/app/api/appointments/[appointmentId]/reschedule/withdraw/route.ts b/app/api/appointments/[appointmentId]/reschedule/withdraw/route.ts new file mode 100644 index 000000000..b1e011552 --- /dev/null +++ b/app/api/appointments/[appointmentId]/reschedule/withdraw/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth-server"; +import prisma from "@/lib/prisma"; +import { apiError } from "@/lib/errors"; +import { withdrawRescheduleRequest } from "@/lib/booking/reschedule-withdraw"; +import { RESCHEDULE_OPEN_STATUSES } from "@/lib/booking/transitions"; + +/** + * POST /api/appointments/[appointmentId]/reschedule/withdraw + * + * The initiator takes back their own open reschedule. The other party has + * Decline, which ends the same request with a different meaning: a withdrawal + * restores the booking, a decline leaves the slots released for the consultant + * to re-place. + */ +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ appointmentId: string }> }, +) { + try { + const { appointmentId } = await params; + const session = await getSession(true); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Found via the appointment rather than by request id: the caller is acting + // on a booking they can see, and openForAppointmentId already guarantees at + // most one live reschedule per appointment, so there is nothing to choose + // between. + const open = await prisma.rescheduleRequest.findFirst({ + where: { + appointmentId, + status: { in: RESCHEDULE_OPEN_STATUSES }, + }, + select: { id: true, initiatedById: true }, + }); + + // Authorization is identity, not role: whoever opened it may take it back, + // whichever side of the booking they are on. + // + // "No open request" and "someone else's open request" answer with the SAME + // 404, deliberately. This route never checks that the caller can see the + // appointment at all, so distinguishing them would let any signed-in user + // walk appointment ids and learn which bookings have a live reschedule and + // whose it is. A 403 here is a membership oracle for other people's + // bookings; the initiator is the only caller with a legitimate reason to + // tell the two apart, and they are not the one being told. + if (!open || open.initiatedById !== session.user.id) { + return NextResponse.json( + { error: "No open reschedule request for this booking." }, + { status: 404 }, + ); + } + + const result = await withdrawRescheduleRequest({ + rescheduleRequestId: open.id, + withdrawnById: session.user.id, + }); + + if (!result.withdrawn) { + // PROPOSAL_NOT_OPEN means the other party answered while this request was + // in flight — a 409, not a failure of the caller's input. NOT_INITIATOR + // cannot reach here (the guard above already 404s), but the service is + // callable from elsewhere, so it keeps its own answer. + return NextResponse.json( + { + error: "This reschedule can no longer be withdrawn.", + code: result.reason, + }, + { status: result.reason === "NOT_INITIATOR" ? 404 : 409 }, + ); + } + + return NextResponse.json({ + withdrawn: true, + message: "Your reschedule request has been withdrawn.", + }); + } catch (error) { + return apiError({ tag: "[Reschedule.Withdraw]", error }); + } +} diff --git a/app/api/slots/availability-with-allocation/[consultantId]/route.ts b/app/api/slots/availability-with-allocation/[consultantId]/route.ts index 8a2983b55..dce18f7a1 100644 --- a/app/api/slots/availability-with-allocation/[consultantId]/route.ts +++ b/app/api/slots/availability-with-allocation/[consultantId]/route.ts @@ -66,6 +66,39 @@ function isValidOvernightSlot(startTime: Date, endTime: Date): boolean { return false; // Invalid slot } +// An org OWNER/MAINTAINER acting for a member consultant (RequestSlotAllocationTab +// mounts mode="allocate" for org admins allocating on a consultant's behalf) +// is authorized the same as the owning consultant. isPrivileged only covers +// PLATFORM staff, so without this an org admin 403s and loses the whole +// calendar rather than just the tooltip detail. EXPERT/other org roles are +// deliberately excluded — same Membership shape requireOrgAccess/catalog +// route use elsewhere (consultantProfileId + role: "EXPERT" identifies the +// org the consultant belongs to; isAtLeastRole's MAINTAINER floor is the +// "admin" rank used throughout app/api/organizations/**). +async function isOrgAdminOfConsultant( + userId: string, + consultantId: string, +): Promise { + const membership = await prisma.membership.findFirst({ + where: { + userId, + status: "ACTIVE", + role: { in: ["OWNER", "MAINTAINER"] }, + organization: { + memberships: { + some: { + consultantProfileId: consultantId, + role: "EXPERT", + status: "ACTIVE", + }, + }, + }, + }, + select: { id: true }, + }); + return !!membership; +} + export async function GET( req: NextRequest, { params }: { params: Promise<{ consultantId: string }> }, @@ -99,21 +132,58 @@ export async function GET( includeAppointmentDetailsRequested || requestedConsulteeUserId ? await getSession(true) : null; + // Ownership is a fact about the database, not about the session. + // + // The session field is a snapshot from when the session was minted, so a + // consultant whose profile link changed since then fails this check while + // requirePersonalProfileAccess — which re-reads the user — lets them onto + // the page. The result was a page that rendered and then 403'd its own + // calendar. Session first because it is free and almost always right; the + // read only happens when it disagrees. const isOwningConsultant = - session?.user?.consultantProfileId === consultantId; + !!session?.user?.id && + (session.user.consultantProfileId === consultantId || + (await prisma.consultantProfile.count({ + where: { id: consultantId, userId: session.user.id }, + })) > 0); + + // Resolved lazily and once: BOTH gates below need it, and the second used + // not to know about it at all — an org admin cleared the details gate and + // was then refused by the consultee gate with "cannot read another user's + // calendar", which made Allocate Slots unusable for them. + let orgAdminCheck: Promise | null = null; + const isOrgAdmin = () => { + if (!session?.user?.id) return Promise.resolve(false); + orgAdminCheck ??= isOrgAdminOfConsultant(session.user.id, consultantId); + return orgAdminCheck; + }; let includeAppointmentDetails = false; if (includeAppointmentDetailsRequested) { - if ( - !session?.user?.id || - (!isOwningConsultant && !isPrivileged(session.user.role)) - ) { + // Three outcomes, not two. + // + // The detail payload carries plan titles and participant names. The + // consultant may see their own; platform staff may see anyone's. An org + // admin allocating for a member consultant needs the CALENDAR — which + // cells are taken — but not what each block is: the consultant's personal + // bookings with unrelated consultees are content, and ADR 20 gives an org + // metadata, not content. + // + // So they are neither authorized nor refused. They get the same + // busy/free grid a buyer gets, and the allocate surface works. 403ing + // them cost the whole calendar over a tooltip they must not see anyway. + const maySeeDetails = + !!session?.user?.id && + (isOwningConsultant || isPrivileged(session.user.role)); + const maySeeCalendar = maySeeDetails || (await isOrgAdmin()); + + if (!maySeeCalendar) { return NextResponse.json( { error: "Forbidden: appointment details require consultant ownership" }, { status: 403 }, ); } - includeAppointmentDetails = true; + includeAppointmentDetails = maySeeDetails; } // The allocator treats the CONSULTEE's bookings with ANY consultant as @@ -126,8 +196,17 @@ export async function GET( // consultant (and staff) may ask about a consultee booking with them. let consulteeUserId: string | null = null; if (requestedConsulteeUserId) { + // The org-admin arm belongs here too. This parameter only marks cells + // BUSY — it carries no titles or names — so it is metadata, which ADR 20 + // does allow an org to see, and allocation is wrong without it: the grid + // would paint cells green that validation then rejects. const isSelf = session?.user?.id === requestedConsulteeUserId; - if (!isSelf && !isOwningConsultant && !isPrivileged(session?.user?.role)) { + if ( + !isSelf && + !isOwningConsultant && + !isPrivileged(session?.user?.role) && + !(await isOrgAdmin()) + ) { return NextResponse.json( { error: "Forbidden: cannot read another user's calendar" }, { status: 403 }, diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/AppointmentsPageClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/AppointmentsPageClient.tsx index fb14c7c09..505f3a3b7 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/AppointmentsPageClient.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/AppointmentsPageClient.tsx @@ -203,7 +203,13 @@ export default function AppointmentsPageClient({ extraTabs={[ { value: "trials", - label: "Trials", + // "Trial requests", not "Trials": the type chip one row below + // is already labelled "Trials" and filters the current bucket + // to TRIAL appointments. This tab is a different thing — the + // TrialSession queue, with its own status filter and a + // schedule action. One word for two results, a row apart. + // The VALUE stays "trials" so ?tab=trials deep-links survive. + label: "Trial requests", content: , }, ]} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx index 6ef2c4e19..261f1b5f1 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import { CalendarClock, Loader2 } from "lucide-react"; import type { AppointmentActionAdapter, OverflowItem, @@ -11,6 +10,7 @@ import type { import { CONSULTANT_JOIN_WINDOW_MS, getJoinableSlot, + slotsAllowReschedule, } from "@/lib/appointments/slots"; import { isApprovedStatus, @@ -18,49 +18,17 @@ import { isInactiveStatus, } from "@/lib/appointments/status"; import type { AppointmentVM } from "@/lib/appointments/view-model"; -import type { - ConsultantTrialLike, - UnscheduledClassLike, - UnscheduledWebinarLike, -} from "@/lib/appointments/map-consultant"; -import type { TAppointment } from "@/types/appointment"; -import type { UnscheduledClass, UnscheduledWebinar } from "../../types"; +import type { ConsultantTrialLike } from "@/lib/appointments/map-consultant"; import { useLazyJoinMeeting } from "@/hooks/scheduling/useLazyJoinMeeting"; -import { - buildUnscheduledClassAppointment, - buildUnscheduledWebinarAppointment, - type UnscheduledAppointment, -} from "./utils/unscheduledAppointments"; import { getParticipantManagementUrl, supportsParticipantManagement, } from "./utils/participantHelpers"; -import { EventTimingsCalendar } from "./components/EventTimingsCalendar"; import { useConsultantEventActions } from "./components/useConsultantEventActions"; import { CancelConfirmationDialog } from "@/components/appointments/consultee/CancelConfirmationDialog"; -import { RescheduleSessionsModal } from "@/components/appointments/consultee/RescheduleSessionsModal"; import { ConsultantResponseUpload } from "../documents/ConsultantResponseUpload"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -interface TimingsTarget { - appointment: TAppointment | UnscheduledAppointment; - groupProgress: { completedSessions: number; totalSessions: number } | null; -} - -type DialogKind = - | "cancel" - | "reschedule-single" - | "reschedule-multi" - | "documents"; +type DialogKind = "cancel" | "documents"; const TYPE_LABEL: Record = { CONSULTATION: "Consultation", @@ -101,9 +69,6 @@ export function useConsultantAppointmentsAdapter( ): AppointmentActionAdapter { const router = useRouter(); const joinMeeting = useLazyJoinMeeting(); - const [timingsTarget, setTimingsTarget] = useState( - null, - ); const [joiningId, setJoiningId] = useState(null); const [activeVm, setActiveVm] = useState(null); const [dialog, setDialog] = useState(null); @@ -175,35 +140,22 @@ export function useConsultantAppointmentsAdapter( if (!navigating) setJoiningId(null); }; + /** + * `vm.id` already carries the `unscheduled-class-`/`unscheduled-webinar-` + * prefix for an offering with no `Appointment` row yet; a scheduled one + * routes on the real appointment id. The timings page resolves either + * shape itself (lib/data/manage-timings-target.ts). + */ const openTimings = (vm: AppointmentVM) => { - if (vm.id.startsWith("unscheduled-class-")) { - setTimingsTarget({ - appointment: buildUnscheduledClassAppointment( - vm.raw.source as UnscheduledClassLike as UnscheduledClass, - ), - groupProgress: null, - }); - return; - } - if (vm.id.startsWith("unscheduled-webinar-")) { - setTimingsTarget({ - appointment: buildUnscheduledWebinarAppointment( - vm.raw.source as UnscheduledWebinarLike as UnscheduledWebinar, - ), - groupProgress: null, - }); - return; - } - if (!vm.raw.appointment) return; - setTimingsTarget({ - appointment: vm.raw.appointment, - groupProgress: vm.group - ? { - completedSessions: vm.group.completed, - totalSessions: vm.group.total, - } - : null, - }); + const targetId = + vm.id.startsWith("unscheduled-class-") || + vm.id.startsWith("unscheduled-webinar-") + ? vm.id + : vm.appointmentId; + if (!targetId) return; + router.push( + `/dashboard/consultant/${consultantId}/appointments/${targetId}/timings`, + ); }; const trialJoinable = (vm: AppointmentVM) => { @@ -253,8 +205,7 @@ export function useConsultantAppointmentsAdapter( const items: OverflowItem[] = []; const appointment = vm.raw.appointment; const inactive = isInactiveStatus(vm.status); - const firstRaw = actionableRawSlots(vm)[0]; - const tentative = firstRaw?.isTentative ?? false; + const rawSlots = actionableRawSlots(vm); const lifecycleOk = canManageBookingLifecycle(vm); const isTrial = vm.kind === "TRIAL"; @@ -279,14 +230,19 @@ export function useConsultantAppointmentsAdapter( !isTrial && lifecycleOk && !inactive && - !tentative && - isApprovedStatus(vm.status) + isApprovedStatus(vm.status) && + slotsAllowReschedule(rawSlots) ) { items.push({ key: "reschedule", label: "Reschedule", + // The consultant's OWN reschedule route. This surface used to mount + // the consultee's dialog, which has no equivalent page a consultant + // may open: that route's consultee ownership check would 403 them. onClick: () => - openDialog(vm, vm.group ? "reschedule-multi" : "reschedule-single"), + router.push( + `/dashboard/consultant/${consultantId}/appointments/${vm.appointmentId}/reschedule`, + ), }); } @@ -331,16 +287,6 @@ export function useConsultantAppointmentsAdapter( const renderDialogs = () => ( <> - {timingsTarget && ( - setTimingsTarget(null)} - appointment={timingsTarget.appointment} - completedSessions={timingsTarget.groupProgress?.completedSessions} - groupTotalSessions={timingsTarget.groupProgress?.totalSessions} - /> - )} - {activeVm && ( <> - !open && closeDialog()} - typeLabel={typeLabel} - rawSlots={rawSlots} - isLoading={actions.isLoading} - // No proposal step here: a consultant-initiated proposal never - // auto-confirms, and this surface already carries the full - // allocation calendar for picking replacement times directly. - onConfirm={({ slotIds }) => { - closeDialog(); - void actions.handleReschedule(slotIds); - }} - /> - - !open && closeDialog()} - > - - - - - Reschedule {typeLabel}? - - -
-

- Are you sure you want to reschedule{" "} - "{activeVm.title}" with{" "} - {activeVm.counterpart.name}? -

-

- The current time slot will be released and the{" "} - {typeLabel.toLowerCase()} status will revert to{" "} - Pending until a new time is allocated. -

-
-
-
- - - Keep Current Time - - { - closeDialog(); - void actions.handleReschedule(); - }} - disabled={actions.isLoading} - className="bg-primary text-primary-foreground hover:bg-primary/90" - > - {actions.isLoading ? ( - - ) : ( - - )} - Reschedule - - -
-
- {activeVm.appointmentId && dialog === "documents" && ( ) { + const router = useRouter(); + // Replaces the generic "reschedule" crumb with the booking's own name (#1064). + useSetBreadcrumbLabel(title); + + const actions = useConsultantEventActions({ + consultantId, + appointmentId, + rawSlots: [], + title, + type: typeLabel, + }); + + const goBack = () => { + router.push(backHref); + router.refresh(); + }; + + const policy = rescheduleConsultantPolicy({ + onSubmit: async ({ slotIds, proposedSlots }) => { + const moved = await actions.handleReschedule(slotIds, proposedSlots); + if (moved) goBack(); + }, + }); + + return ( + + ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx new file mode 100644 index 000000000..74d2a344a --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx @@ -0,0 +1,95 @@ +import { cache } from "react"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { PanelHeader } from "@/components/dashboard/PageScaffold"; +import { readAppointmentDetail } from "@/lib/data/appointment-detail"; +import { resolvePlanOwnerIds } from "@/lib/booking/plan-owners"; +import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; +import { buildRescheduleSubject } from "@/lib/scheduling/slot-picker-subject"; + +import { RescheduleClient } from "./RescheduleClient"; + +/** + * The consultant's reschedule surface. + * + * Required, not extra. The consultant's appointments rows used to open the + * consultee's dialog; the consultee ROUTE checks + * `requirePersonalProfileAccess("consultee", …)`, which a consultant fails — + * so this is what keeps consultants able to reschedule once that dialog is + * gone. Same picker, same policy shape, different auth and copy. + */ +type PageProps = { + params: Promise<{ consultantId: string; appointmentId: string }>; +}; + +// React.cache so generateMetadata() and the page body share one query per request. +const loadDetail = cache(readAppointmentDetail); + +/** + * Names the booking, not the task. A consultant moving sessions for several + * clients has identical "Reschedule" tabs otherwise (#1064). + */ +export async function generateMetadata({ + params, +}: Readonly): Promise { + const { consultantId, appointmentId } = await params; + const detail = await loadDetail(appointmentId).catch(() => null); + // Metadata runs BEFORE the body's guards and is not covered by them, so the + // same ownership check runs here — otherwise the tab title named the + // offering and the client for any appointment id a signed-in consultant + // cared to try. + const owned = + !!detail && resolvePlanOwnerIds(detail.appointment).includes(consultantId); + const resolved = owned && detail ? buildRescheduleSubject(detail) : null; + if (!resolved) return { title: "Reschedule — Familiarise" }; + + const who = resolved.consulteeName ? ` · ${resolved.consulteeName}` : ""; + return { title: `Reschedule: ${resolved.title}${who} — Familiarise` }; +} + +export default async function ConsultantReschedulePage({ + params, +}: Readonly) { + const { consultantId, appointmentId } = await params; + // Enforced here rather than in the layout: the layout is a client component, + // so its check runs only after this server render has already streamed. + await requirePersonalProfileAccess("consultant", consultantId); + + const detail = await loadDetail(appointmentId); + if (!detail) notFound(); + + // The route's consultant must own the plan or be an ACCEPTED collaborator. + // Mirrors the detail page: this binds the appointment to the URL's + // consultant, the guard above binds that consultant to the session. + const { appointment } = detail; + if (!resolvePlanOwnerIds(appointment).includes(consultantId)) notFound(); + + const resolved = buildRescheduleSubject(detail); + if (!resolved) notFound(); + + const backHref = `/dashboard/consultant/${consultantId}/appointments`; + + return ( +
+ {/* The BOOKING now lives in the breadcrumb (RescheduleClient sets it + via useSetBreadcrumbLabel) — see the consultee's twin (#1064). */} + + + +
+ ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/ManageTimingsClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/ManageTimingsClient.tsx new file mode 100644 index 000000000..9b11138bb --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/ManageTimingsClient.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { SlotPicker } from "@/components/scheduling/SlotPicker"; +import { + manageTimingsPolicy, + type SlotPickerSubject, +} from "@/components/scheduling/slot-picker-policy"; +import { useSetBreadcrumbLabel } from "@/components/dashboard/breadcrumb-override"; + +/** + * The consultant half of the manage-timings page — the allocate-mode grid + * owns the submit here (`useEventSlotAllocation` inside it POSTs the + * allocation and raises its own "Timings saved" toast, same as the allocate + * route), so this only decides where to go afterwards. + */ +export function ManageTimingsClient({ + subject, + backHref, + title, +}: Readonly<{ + subject: SlotPickerSubject; + backHref: string; + title: string; +}>) { + const router = useRouter(); + // Replaces the generic "timings" crumb with the offering's own name (#1064). + useSetBreadcrumbLabel(title); + + const goBack = () => { + router.push(backHref); + router.refresh(); + }; + + const policy = manageTimingsPolicy({ onSubmit: goBack }); + + return ( + + ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx new file mode 100644 index 000000000..729ecc0a0 --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx @@ -0,0 +1,113 @@ +import { cache } from "react"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { PanelHeader } from "@/components/dashboard/PageScaffold"; +import { Badge } from "@/components/ui/badge"; +import { readManageTimingsTarget } from "@/lib/data/manage-timings-target"; +import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; +import { buildManageTimingsSubject } from "@/lib/scheduling/manage-timings-subject"; + +import { ManageTimingsClient } from "./ManageTimingsClient"; + +/** + * The consultant setting the times of their own event instance — the fourth + * caller of the shared slot-picker surface, and the one `SlotPicker` was + * generalised FROM. It stayed a dialog the longest because nothing here felt + * per-appointment enough to deserve a URL; cramped on a real calendar grid + * regardless, so it gets the same page treatment as the other three. + * + * `[appointmentId]` carries two different id shapes here, same as the list + * that links here: a real `Appointment` row's id once one exists, or the + * `unscheduled-class-` / `unscheduled-webinar-` synthetic id for an + * offering that has never been scheduled — see manage-timings-target.ts. + */ +type PageProps = { + params: Promise<{ consultantId: string; appointmentId: string }>; +}; + +// React.cache so generateMetadata() and the page body share one query per request. +const loadTarget = cache(readManageTimingsTarget); + +/** + * Names the offering, not the task. A consultant with several unscheduled + * classes had identical "Manage timings" tabs otherwise (#1064). + */ +export async function generateMetadata({ + params, +}: Readonly): Promise { + const { consultantId, appointmentId } = await params; + const target = await loadTarget(appointmentId).catch(() => null); + // Metadata runs BEFORE the body's guards and is not covered by them, so the + // same ownership check runs here — otherwise the tab title named the + // offering for any id a signed-in consultant cared to try. + if (!target || !target.planOwnerIds.includes(consultantId)) { + return { title: "Manage timings — Familiarise" }; + } + + const resolved = buildManageTimingsSubject(consultantId, target.appointment); + return { title: `Manage timings: ${resolved.title} — Familiarise` }; +} + +export default async function ManageTimingsPage({ + params, +}: Readonly) { + const { consultantId, appointmentId } = await params; + // Enforced here rather than in the layout: the layout is a client component, + // so its check runs only after this server render has already streamed. + await requirePersonalProfileAccess("consultant", consultantId); + + const target = await loadTarget(appointmentId); + if (!target) notFound(); + + // The route's consultant must own the plan or be an ACCEPTED collaborator. + // Binds the offering to the URL's consultant; the guard above binds that + // consultant to the session. + if (!target.planOwnerIds.includes(consultantId)) notFound(); + + const resolved = buildManageTimingsSubject( + consultantId, + target.appointment, + target.completedSessions, + target.groupTotalSessions, + ); + + const backHref = `/dashboard/consultant/${consultantId}/appointments`; + + return ( +
+ {/* The OFFERING now lives in the breadcrumb (ManageTimingsClient sets it + via useSetBreadcrumbLabel) — see the reschedule/allocate pages + (#1064). */} + + + {resolved.classInfo && ( +
+ Plan: {resolved.classInfo.planType} + + {resolved.classInfo.sessionsPerWeek} meetings/week ·{" "} + {resolved.classInfo.durationInMonths} month + {resolved.classInfo.durationInMonths !== 1 ? "s" : ""} ·{" "} + {resolved.classInfo.durationInHours}h/session + +
+ )} + + {resolved.classInfo && ( +
+ Tip: Each class is{" "} + {Math.ceil(resolved.classInfo.durationInHours / 0.5)} consecutive + 30-min slots. Complete an in-progress class before starting another. + Max {resolved.classInfo.sessionsPerWeek} classes per day; weekly + limit applies. +
+ )} + + +
+ ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx deleted file mode 100644 index 31c563c44..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx +++ /dev/null @@ -1,257 +0,0 @@ -"use client"; - -import { Badge } from "@/components/ui/badge"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { useParams } from "next/navigation"; -import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar"; -import type { UnscheduledAppointment } from "../utils/unscheduledAppointments"; -import { getClassPlanDefaults, type ClassPlanType } from "@/utils/classPlans"; - -interface EventDetails { - eventType: "consultation" | "subscription" | "webinar" | "class"; - eventId: string; - sessionsPerWeek?: number; - durationInMonths: number; - durationInHours: number; - sessionDurationInHours?: number; - totalSessions?: number; - title: string; - planType?: ClassPlanType; -} - -interface EventTimingsCalendarProps { - isOpen: boolean; - onClose: () => void; - appointment: UnscheduledAppointment; - completedSessions?: number; - groupTotalSessions?: number; -} - -export function EventTimingsCalendar({ - isOpen, - onClose, - appointment, - completedSessions, - groupTotalSessions, -}: EventTimingsCalendarProps) { - const params = useParams(); - - const consultantId = params.consultantId?.toString() || ""; - const getEventDetails = (appointment: UnscheduledAppointment): EventDetails => { - switch (appointment.appointmentType) { - case "CONSULTATION": - return { - eventType: "consultation", - eventId: appointment.consultation?.id || "", - sessionsPerWeek: 1, - durationInMonths: 1, - durationInHours: - appointment.consultation?.consultationPlan?.durationInHours || 1, - title: - appointment.consultation?.consultationPlan?.title || "Consultation", - }; - case "SUBSCRIPTION": - return { - eventType: "subscription", - eventId: appointment.subscription?.id || "", - sessionsPerWeek: - appointment.subscription?.subscriptionPlan?.sessionsPerWeek || 1, - durationInMonths: - appointment.subscription?.subscriptionPlan?.durationInMonths || 1, - durationInHours: - appointment.subscription?.subscriptionPlan - ?.sessionDurationInHours || 1, - sessionDurationInHours: - appointment.subscription?.subscriptionPlan - ?.sessionDurationInHours || 1, - totalSessions: - appointment.subscription?.subscriptionPlan?.totalSessions ?? - undefined, - title: - appointment.subscription?.subscriptionPlan?.title || "Subscription", - }; - case "WEBINAR": - return { - eventType: "webinar", - eventId: appointment.webinar?.id || "", - sessionsPerWeek: 1, - durationInMonths: 1, - durationInHours: - appointment.webinar?.webinarPlan?.durationInHours || 1, - title: appointment.webinar?.webinarPlan?.title || "Webinar", - }; - case "CLASS": { - const classPlan = appointment.class?.classPlan; - const defaults = getClassPlanDefaults(classPlan ?? {}); - return { - eventType: "class", - eventId: appointment.class?.id || "", - sessionsPerWeek: defaults.classesPerWeek, - durationInMonths: defaults.durationInMonths, - durationInHours: - classPlan?.sessionDurationInHours ?? - defaults.sessionDurationInHours, - totalSessions: classPlan?.totalSessions ?? undefined, - title: classPlan?.title || "Class", - planType: defaults.type, - }; - } - default: - return { - eventType: "consultation", - eventId: "", - sessionsPerWeek: 1, - durationInMonths: 1, - durationInHours: 1, - title: "Event", - }; - } - }; - - const eventDetails = getEventDetails(appointment); - - // Removed debug validation logging - production code uses validation directly in calendar - - // Removed debug logging - production code validates dates server-side - - const handleAllocationComplete = () => { - onClose(); - }; - - const appendProgressText = ( - baseText: string, - completed?: number, - total?: number, - ): string => { - if (completed && completed > 0 && total) { - const remaining = total - completed; - return `${baseText} ${completed} of ${total} sessions completed — select times for the remaining ${remaining}.`; - } - return baseText; - }; - - const getDescriptionText = () => { - switch (appointment.appointmentType) { - case "CONSULTATION": - return "Select consecutive time slots for your consultation. All slots must be on the same day."; - case "SUBSCRIPTION": { - const baseText = `Schedule ${eventDetails.sessionsPerWeek} session${eventDetails.sessionsPerWeek !== 1 ? "s" : ""} per week for ${eventDetails.durationInMonths} month${eventDetails.durationInMonths !== 1 ? "s" : ""}. Each session is ${eventDetails.sessionDurationInHours || 1} hour${(eventDetails.sessionDurationInHours || 1) > 1 ? "s" : ""}.`; - return appendProgressText( - baseText, - completedSessions, - groupTotalSessions, - ); - } - case "WEBINAR": - return "Select consecutive time slots for your webinar session."; - case "CLASS": { - const sessionDuration = eventDetails.durationInHours || 1; - const durationText = - sessionDuration === 1 ? "1 hour" : `${sessionDuration} hours`; - const sessionsPerWeek = eventDetails.sessionsPerWeek || 1; - const classBaseText = `Schedule ${sessionsPerWeek} session${sessionsPerWeek !== 1 ? "s" : ""} per week. Each session is ${durationText}.`; - return appendProgressText( - classBaseText, - completedSessions, - groupTotalSessions, - ); - } - default: - return "Select time slots for your event."; - } - }; - - return ( - - - - - {appointment.appointmentType === "CLASS" - ? "Manage Class Timings" - : `Manage ${eventDetails.title} Timings`} - - {getDescriptionText()} - {appointment.appointmentType === "CLASS" && ( -
- - Plan: {eventDetails.planType || "Custom"} - - - {eventDetails.sessionsPerWeek} meetings/week ·{" "} - {eventDetails.durationInMonths} month - {eventDetails.durationInMonths !== 1 ? "s" : ""} ·{" "} - {eventDetails.durationInHours || 1}h/session - -
- )} -
- - {/* Guidance prompt for class rules */} - {appointment.appointmentType === "CLASS" && ( -
- Tip: Each class is{" "} - {Math.ceil((eventDetails.durationInHours || 1) / 0.5)} consecutive - 30‑min slots. Complete an in‑progress class before starting another. - Max {eventDetails.sessionsPerWeek || 2} classes per day; weekly - limit applies. -
- )} - - -
-
- ); -} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts index 7c0f9dc86..45f48028d 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts @@ -47,14 +47,26 @@ export function useConsultantEventActions({ } }; - const handleReschedule = async (slotIds?: string[]) => { + /** + * Release sessions, optionally naming the times to replace them with. + * + * A consultant proposal never auto-confirms — publishing availability is + * standing consent to be booked inside it, but merely being free is not + * consent to be moved — so these times are always an offer to the consultee. + * Resolves true only when the release landed, which is what lets the + * reschedule page navigate on success and stay put on failure. + */ + const handleReschedule = async ( + slotIds?: string[], + proposedSlots?: { startsAt: string; endsAt: string }[], + ): Promise => { if (!appointmentId) { toast({ title: "Error", description: "Appointment ID is missing", variant: "destructive", }); - return; + return false; } setIsLoading(true); @@ -64,13 +76,16 @@ export function useConsultantEventActions({ ? `/api/appointments/${appointmentId}/reschedule?type=SUBSCRIPTION` : `/api/appointments/${appointmentId}/reschedule`; + const payload: Record = {}; + if (slotIds && slotIds.length > 0) payload.slotIds = slotIds; + if (proposedSlots?.length) payload.proposedSlots = proposedSlots; + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: - slotIds && slotIds.length > 0 - ? JSON.stringify({ slotIds }) - : undefined, + body: Object.keys(payload).length + ? JSON.stringify(payload) + : undefined, }); const data = await response.json(); @@ -81,15 +96,24 @@ export function useConsultantEventActions({ const sessionsAffected = data.sessionsAffected ?? data.slotsAffected ?? slotIds?.length ?? 1; - toast({ - title: "Ready to reschedule", - description: - sessionsAffected === 1 - ? "Select a new time for this session." - : `Select new times for ${sessionsAffected} sessions.`, - }); + toast( + proposedSlots?.length + ? { + title: "Times proposed", + description: + "The consultee has been asked to accept the new time.", + } + : { + title: "Ready to reschedule", + description: + sessionsAffected === 1 + ? "Select a new time for this session." + : `Select new times for ${sessionsAffected} sessions.`, + }, + ); invalidateBookingData(); + return true; } catch (error) { Sentry.captureException( error instanceof Error ? error : new Error(String(error)), @@ -103,6 +127,7 @@ export function useConsultantEventActions({ : "Failed to request reschedule", variant: "destructive", }); + return false; } finally { setIsLoading(false); } diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/appointmentTimingHelpers.ts b/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/appointmentTimingHelpers.ts deleted file mode 100644 index 043116242..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/appointmentTimingHelpers.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { TAppointment } from "@/types/appointment"; - -interface AppointmentTimingDetails { - eventType: "consultation" | "subscription" | "webinar" | "class"; - eventId: string; - /** Sessions per week; 1 for the one-off event types. */ - sessionsPerWeek: number; - durationInMonths: number; - durationInHours: number; - title: string; - canManageTimings: boolean; -} - -/** - * Extracts timing details from an appointment for use with EventTimingsCalendar - */ -function getAppointmentTimingDetails( - appointment: TAppointment, -): AppointmentTimingDetails { - switch (appointment.appointmentType) { - case "CONSULTATION": - return { - eventType: "consultation", - eventId: appointment.consultation?.id || "", - sessionsPerWeek: 1, // Consultations are one-time events - durationInMonths: 1, // Consultations are one-time events - durationInHours: - appointment.consultation?.consultationPlan?.durationInHours || 1, - title: - appointment.consultation?.consultationPlan?.title || "Consultation", - canManageTimings: !!appointment.consultation?.id, - }; - case "SUBSCRIPTION": { - const sessionsPerWeek = - appointment.subscription?.subscriptionPlan?.sessionsPerWeek || 1; - return { - eventType: "subscription", - eventId: appointment.subscription?.id || "", - sessionsPerWeek, - durationInMonths: - appointment.subscription?.subscriptionPlan?.durationInMonths || 1, - durationInHours: - appointment.subscription?.subscriptionPlan?.sessionDurationInHours || - 1, - title: - appointment.subscription?.subscriptionPlan?.title || "Subscription", - canManageTimings: !!appointment.subscription?.id, - }; - } - case "WEBINAR": - return { - eventType: "webinar", - eventId: appointment.webinar?.id || "", - sessionsPerWeek: 1, // Webinars are one-time events - durationInMonths: 1, // Webinars are one-time events - durationInHours: appointment.webinar?.webinarPlan?.durationInHours || 1, - title: appointment.webinar?.webinarPlan?.title || "Webinar", - canManageTimings: !!appointment.webinar?.id, - }; - case "CLASS": { - // Calculate session duration for classes - const classPlan = appointment.class?.classPlan; - const sessionsPerWeek = classPlan?.sessionsPerWeek || 1; - const durationInMonths = classPlan?.durationInMonths || 1; - - // Use a reasonable default session duration - // Since classContents is not available in TAppointment type, use smart defaults - let sessionDurationInHours = 1.5; // Default for classes - - // For classes, we can estimate based on duration and frequency - if (durationInMonths > 1 && sessionsPerWeek > 1) { - // For longer, more frequent classes, use slightly longer sessions - sessionDurationInHours = 2.0; - } else if (durationInMonths > 6) { - // For very long courses, use standard session length - sessionDurationInHours = 1.5; - } - - return { - eventType: "class", - eventId: appointment.class?.id || "", - sessionsPerWeek, - durationInMonths, - durationInHours: sessionDurationInHours, - title: appointment.class?.classPlan?.title || "Class", - canManageTimings: !!appointment.class?.id, - }; - } - default: - return { - eventType: "consultation", - eventId: "", - sessionsPerWeek: 1, - durationInMonths: 1, - durationInHours: 1, - title: "Event", - canManageTimings: false, - }; - } -} - -/** - * Checks if an appointment supports timing management - */ -export function canManageAppointmentTimings( - appointment: TAppointment, -): boolean { - const details = getAppointmentTimingDetails(appointment); - return details.canManageTimings; -} - -/** - * Checks if a group of appointments supports timing management - * Used for recurring events (subscriptions, classes) to show timing management at group level - */ -export function canManageGroupTimings( - groupAppointments: TAppointment[], -): boolean { - if (!groupAppointments || groupAppointments.length === 0) { - return false; - } - - // Check if any appointment in the group supports timing management - // Use the first appointment as representative of the group - const firstAppointment = groupAppointments[0]; - return canManageAppointmentTimings(firstAppointment); -} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/unscheduledAppointments.ts b/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/unscheduledAppointments.ts deleted file mode 100644 index a63bdf0fd..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/unscheduledAppointments.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Builders for partial appointment objects used to open EventTimingsCalendar - * for events that exist in the DB but have no Appointment rows yet. - * - * Returns Partial with the required id + appointmentType fields. - * EventTimingsCalendar accesses all fields via optional chaining, so partial - * data is safe. - */ - -import type { TAppointment } from "@/types/appointment"; -import { UnscheduledClass, UnscheduledWebinar } from "../../../types"; - -/** Minimum required fields for EventTimingsCalendar */ -export type UnscheduledAppointment = Partial & - Pick; - -export function buildUnscheduledClassAppointment( - classEvent: UnscheduledClass, -): UnscheduledAppointment { - return { - id: `unscheduled-class-${classEvent.id}`, - appointmentType: "CLASS", - class: { - id: classEvent.id, - status: classEvent.status, - schedulingPeriodStartsAt: classEvent.schedulingPeriodStartsAt - ? new Date(classEvent.schedulingPeriodStartsAt) - : null, - schedulingPeriodEndsAt: classEvent.schedulingPeriodEndsAt - ? new Date(classEvent.schedulingPeriodEndsAt) - : null, - classPlan: classEvent.classPlan, - } as TAppointment["class"], - slotsOfAppointment: [], - }; -} - -export function buildUnscheduledWebinarAppointment( - webinarEvent: UnscheduledWebinar, -): UnscheduledAppointment { - return { - id: `unscheduled-webinar-${webinarEvent.id}`, - appointmentType: "WEBINAR", - webinar: { - id: webinarEvent.id, - status: webinarEvent.status, - webinarPlan: webinarEvent.webinarPlan, - } as TAppointment["webinar"], - slotsOfAppointment: [], - }; -} diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/[appointmentId]/allocate/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/requests/[appointmentId]/allocate/page.tsx deleted file mode 100644 index 02e4361ed..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/requests/[appointmentId]/allocate/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import Link from "next/link"; -import { DashboardHeader } from "@/components/dashboard/PageScaffold"; -import { DesktopOnlyNotice } from "@/components/scheduling/DesktopOnlyNotice"; -import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar"; -import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; - -/** - * The consultant's slot-allocation surface. - * - * Placing N sessions across a scheduling period under per-day and per-week - * caps is a page-sized task that has been living in a dialog. The heatmap - * stays — it is the right tool here, unlike on the buyer side — it simply gets - * room to breathe, plus a URL that survives a refresh and can be linked from - * the notification that says a request is waiting. - * - * Read-only for now: the Requests table links here so the full-width grid can - * be judged against the dialog it replaces, but every allocation action still - * lives in that dialog. The next PR moves the actions across and retires it. - */ -type PageProps = { - params: Promise<{ consultantId: string; appointmentId: string }>; - searchParams: Promise<{ type?: string }>; -}; - -export default async function AllocateSlotsPage({ - params, - searchParams, -}: Readonly) { - // `appointmentId` carries the CONSULTATION/SUBSCRIPTION id, which is what the - // allocation endpoints and the calendar's event lookup are keyed by — the - // Appointment row is downstream of it and does not exist for a request that - // has never been scheduled. - const { consultantId, appointmentId } = await params; - const { type } = await searchParams; - // Enforced here rather than in the layout: the layout is a client component, - // so its check runs only after this server render has already streamed. - await requirePersonalProfileAccess("consultant", consultantId); - - // The route cannot say which product this is, and every calendar fetch is - // keyed by it. The caller already knows, so it travels in the link rather - // than costing this page a query to re-derive. - const eventType = type === "subscription" ? "subscription" : "consultation"; - - return ( -
- - - - Back to requests - - - - {/* A min-height, not `flex-1`: this page is a plain block column, so - the calendar's own `flex-1` has nothing to fill without one. */} - - -
- ); -} diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx new file mode 100644 index 000000000..70c66d79c --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { SlotPicker } from "@/components/scheduling/SlotPicker"; +import { + allocatePolicy, + type SlotPickerSubject, +} from "@/components/scheduling/slot-picker-policy"; +import { toast } from "@/components/ui/use-toast"; +import { allocatedElsewhere } from "@/lib/scheduling/allocationMessages"; +import { useSetBreadcrumbLabel } from "@/components/dashboard/breadcrumb-override"; + +/** + * The consultant's allocation surface, replacing the dialog the requests table + * used to open. The grid owns the submit here — `useEventSlotAllocation` + * inside it POSTs the allocation — so this only decides where to go + * afterwards. + */ +export function AllocateClient({ + subject, + backHref, + title, +}: Readonly<{ + subject: SlotPickerSubject; + backHref: string; + title: string; +}>) { + const router = useRouter(); + // Replaces the generic "allocate" crumb with the booking's own name (#1064). + useSetBreadcrumbLabel(title); + + const goBack = () => { + router.push(backHref); + router.refresh(); + }; + + const policy = allocatePolicy({ + onSubmit: () => { + toast({ + title: "Schedule confirmed", + description: "All session times have been scheduled.", + }); + goBack(); + }, + // 409: another session allocated this request first. The list IS the + // answer — the row will simply be gone. + onConflict: () => { + toast(allocatedElsewhere()); + goBack(); + }, + }); + + return ( + + ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/page.tsx new file mode 100644 index 000000000..84c2335a2 --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/page.tsx @@ -0,0 +1,126 @@ +import { cache } from "react"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { PanelHeader } from "@/components/dashboard/PageScaffold"; +import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; +import { ALLOCATION_APPROVABLE_FROM } from "@/lib/booking/transitions"; +import { readAllocationRequest } from "@/lib/data/allocation-request"; + +import { AllocateClient } from "./AllocateClient"; + +/** + * The consultant's slot-allocation surface. + * + * Placing N sessions across a scheduling period under per-day and per-week + * caps is a page-sized task that was living in a dialog. The heatmap stays — + * it is the right tool here, unlike on the buyer side — it simply gets room to + * breathe, plus a URL that survives a refresh and can be linked from the + * notification that says a request is waiting. + */ +type PageProps = { + // `requestId` is the CONSULTATION/SUBSCRIPTION id, which is what the + // allocation endpoints and the grid's event lookup are keyed by. The + // `Appointment` row is downstream of it and does not exist at all for a + // request that has never been scheduled — the ordinary case here. + params: Promise<{ consultantId: string; requestId: string }>; + searchParams: Promise<{ type?: string }>; +}; + +// React.cache so generateMetadata() and the page body share one query per request. +const loadRequest = cache(readAllocationRequest); + +/** + * Names the booking, not the task. A consultant working three requests has + * three of these tabs open, and "Allocate slots" on all of them tells them + * nothing (#1064). + */ +export async function generateMetadata({ + params, + searchParams, +}: Readonly): Promise { + const { consultantId, requestId } = await params; + const { type } = await searchParams; + const request = await loadRequest( + requestId, + type === "subscription" ? "subscription" : "consultation", + ).catch(() => null); + // Metadata runs BEFORE the body's guards and is not covered by them, so the + // same ownership check runs here — otherwise the tab title named the + // offering and the buyer for any request id a signed-in consultant tried. + if (!request || request.consultantProfileId !== consultantId) { + return { title: "Allocate slots — Familiarise" }; + } + + const who = request.consulteeName ? ` · ${request.consulteeName}` : ""; + return { title: `Allocate: ${request.title}${who} — Familiarise` }; +} + +export default async function AllocateSlotsPage({ + params, + searchParams, +}: Readonly) { + const { consultantId, requestId } = await params; + const { type } = await searchParams; + // Enforced here rather than in the layout: the layout is a client component, + // so its check runs only after this server render has already streamed. + await requirePersonalProfileAccess("consultant", consultantId); + + // The route cannot say which product this is, and every grid fetch is keyed + // by it. The caller already knows, so it travels in the link. + const eventType = type === "subscription" ? "subscription" : "consultation"; + + const request = await loadRequest(requestId, eventType); + if (!request) notFound(); + // Binds the request to the URL's consultant; the guard above binds that + // consultant to the session. + if (request.consultantProfileId !== consultantId) notFound(); + // This URL outlives the work: it is linkable from a notification, survives a + // refresh, and goBack() pushes, so the back button returns here once the + // allocation is done. Without this a consultant reopens a live grid for a + // request that is already cancelled, rejected or fully placed. + // + // ALLOCATION_APPROVABLE_FROM, not `=== PENDING`: a partial reschedule is + // allocated from this same page, and a subscription is deliberately NOT + // flipped back to PENDING when one of its sessions is released (#448), so it + // arrives here still APPROVED. + if (!ALLOCATION_APPROVABLE_FROM.includes(request.status)) notFound(); + + const backHref = `/dashboard/consultant/${consultantId}/requests`; + + return ( +
+ {/* The BOOKING now lives in the breadcrumb itself (AllocateClient sets + it via useSetBreadcrumbLabel) — the back link is the breadcrumb's + own parent crumb. This line keeps the one thing the breadcrumb + can't say: who the task is for (#1064). */} + + + +
+ ); +} diff --git a/app/dashboard/consultant/[consultantId]/layout.tsx b/app/dashboard/consultant/[consultantId]/layout.tsx index 27e5ef29a..5e8568ffb 100644 --- a/app/dashboard/consultant/[consultantId]/layout.tsx +++ b/app/dashboard/consultant/[consultantId]/layout.tsx @@ -140,6 +140,11 @@ const PAGE_LABELS: Record = { webinars: "Webinar", planner: "Event Planner", requests: "Requests", + // Task routes hanging off a record id. Without these the trail ends on the + // raw lowercase segment ("timings"). + timings: "Timings", + allocate: "Allocate", + reschedule: "Reschedule", collaborations: "Collaborations", recordings: "Recordings", documents: "Documents", @@ -509,25 +514,24 @@ function ConsultantLayoutInner({ const crumbs: { label: string; href?: string }[] = []; let acc = basePath; - let lastSegWasRecordId = false; for (const seg of parts) { acc = `${acc}/${seg}`; if (looksLikeRecordId(seg)) { - lastSegWasRecordId = true; + // The label goes HERE, in the id's own position — that segment IS the + // record, so its human name belongs where the id was. Previously this + // was deferred to after the loop and only applied when the id was the + // LAST segment, so a task route (…//timings) reset the flag on its + // way past and the override never rendered at all. + if (overrideLabel) crumbs.push({ label: overrideLabel, href: acc }); continue; } - lastSegWasRecordId = false; crumbs.push({ label: PAGE_LABELS[seg] ?? seg, href: acc, }); } - if (lastSegWasRecordId && overrideLabel) { - crumbs.push({ label: overrideLabel }); - } - return crumbs.map((crumb, index) => { const isLast = index === crumbs.length - 1; // Keep a link when the visible crumb is still a parent of the URL diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx new file mode 100644 index 000000000..10f763035 --- /dev/null +++ b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { SlotPicker } from "@/components/scheduling/SlotPicker"; +import { + rescheduleConsulteePolicy, + type SlotPickerSubject, +} from "@/components/scheduling/slot-picker-policy"; +import { useEventActions } from "@/components/appointments/consultee/useEventActions"; +import type { BookingTypeLabel } from "@/lib/scheduling/slot-picker-subject"; +import { useSetBreadcrumbLabel } from "@/components/dashboard/breadcrumb-override"; + +/** + * The consultee half of the reschedule page: the shared picker, the consultee + * policy, and the mutation hook that already owns this API call and its cache + * invalidation. + */ +export function RescheduleClient({ + appointmentId, + title, + typeLabel, + subject, + backHref, +}: Readonly<{ + appointmentId: string; + title: string; + typeLabel: BookingTypeLabel; + subject: SlotPickerSubject; + backHref: string; +}>) { + const router = useRouter(); + // Replaces the generic "reschedule" crumb with the booking's own name (#1064). + useSetBreadcrumbLabel(title); + + const actions = useEventActions({ + appointmentId, + rawSlots: [], + title, + consultant: "", + type: typeLabel, + }); + + const goBack = () => { + // The list this returns to was rendered before the release; refresh it or + // the row still offers "Reschedule" for a booking now awaiting a time. + router.push(backHref); + router.refresh(); + }; + + const policy = rescheduleConsulteePolicy({ + onSubmit: async ({ slotIds, proposedSlots }) => { + const moved = await actions.handleReschedule(slotIds, proposedSlots); + // A failure keeps the page (and the user's selection) so they can retry; + // the hook has already explained why in a toast. + if (moved) goBack(); + }, + }); + + return ( + + ); +} diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx index 5a92d8d38..c8a564c76 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx @@ -1,26 +1,86 @@ -import Link from "next/link"; -import { DashboardHeader } from "@/components/dashboard/PageScaffold"; -import { DesktopOnlyNotice } from "@/components/scheduling/DesktopOnlyNotice"; +import { cache } from "react"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { PanelHeader } from "@/components/dashboard/PageScaffold"; +import prisma from "@/lib/prisma"; +import { readAppointmentDetail } from "@/lib/data/appointment-detail"; import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; +import { buildRescheduleSubject } from "@/lib/scheduling/slot-picker-subject"; + +import { RescheduleClient } from "./RescheduleClient"; /** - * Skeleton for the consultee's reschedule surface. - * - * The picker lives in a modal today, and every defect found reviewing it — - * labels overflowing their column, a legend that would not fit, a selection - * lost on reload — traced back to width. A route also gives the flow a URL, - * so "pick a new time" in a notification can link straight here, and a - * half-finished choice survives a refresh. + * The consultee's reschedule surface. * - * Deliberately not wired up yet: nothing links to this route, so it cannot - * affect the working modal. The next PR moves the picker in and adds the - * navigation. What is real here is the route, the ownership check and the - * mobile gate — the parts worth settling before any UI is built on them. + * A page rather than the dialog it replaces: every defect found reviewing that + * dialog — labels overflowing their column, a legend that would not fit, a + * selection lost on reload — traced back to width. The route also gives the + * flow a URL, so "pick a new time" in a notification can link straight here. */ type PageProps = { params: Promise<{ consulteeId: string; appointmentId: string }>; }; +// React.cache so generateMetadata() and the page body share one query per request. +const loadDetail = cache(readAppointmentDetail); +const loadConsulteeUserId = cache(async (consulteeId: string) => + prisma.consulteeProfile.findUnique({ + where: { id: consulteeId }, + select: { userId: true }, + }), +); + +/** + * Binds the appointment to the URL's consultee. Mirrors the detail page. + * + * Shared with generateMetadata deliberately: metadata runs BEFORE the body's + * guards and is not covered by them, so without this the page `` named + * the offering and the consultant for any appointment id a signed-in user + * cared to try. + */ +function consulteeOwns( + appointment: NonNullable< + Awaited<ReturnType<typeof loadDetail>> + >["appointment"], + consulteeId: string, + userId: string, +): boolean { + return ( + appointment.consultation?.requestedBy?.id === consulteeId || + appointment.subscription?.requestedBy?.id === consulteeId || + appointment.trialSession?.consulteeProfile?.id === consulteeId || + appointment.slotsOfAppointment.some((slot) => + slot.user.some((user) => user.id === userId), + ) + ); +} + +/** + * Names the booking, not the task. "Reschedule" on a backgrounded tab does not + * say which of a consultee's sessions is being moved (#1064). + */ +export async function generateMetadata({ + params, +}: Readonly<PageProps>): Promise<Metadata> { + const { consulteeId, appointmentId } = await params; + const [detail, profile] = await Promise.all([ + loadDetail(appointmentId).catch(() => null), + loadConsulteeUserId(consulteeId).catch(() => null), + ]); + const generic = { title: "Reschedule — Familiarise" }; + if (!detail || !profile) return generic; + if (!consulteeOwns(detail.appointment, consulteeId, profile.userId)) { + return generic; + } + + const resolved = buildRescheduleSubject(detail); + if (!resolved) return generic; + + const who = resolved.consultantName ? ` with ${resolved.consultantName}` : ""; + return { title: `Reschedule: ${resolved.title}${who} — Familiarise` }; +} + export default async function RescheduleAppointmentPage({ params, }: Readonly<PageProps>) { @@ -29,31 +89,47 @@ export default async function RescheduleAppointmentPage({ // so its check runs only after this server render has already streamed. await requirePersonalProfileAccess("consultee", consulteeId); + const [detail, profile] = await Promise.all([ + loadDetail(appointmentId), + loadConsulteeUserId(consulteeId), + ]); + if (!detail || !profile) notFound(); + + // Binds the appointment to the URL's consultee; binding that consultee to + // the SESSION is the guard above. + if (!consulteeOwns(detail.appointment, consulteeId, profile.userId)) { + notFound(); + } + + // The picker draws the CONSULTANT's availability, and this route's params + // carry no consultant — so it is resolved from the booking, here, rather + // than shipped to the client to look up. + const resolved = buildRescheduleSubject(detail); + if (!resolved) notFound(); + + const backHref = `/dashboard/consultee/${consulteeId}/appointments`; + return ( - <div className="flex flex-col gap-4"> - <DashboardHeader - title="Reschedule" - subtitle="Choose a new time for your session" + <div className="flex min-h-0 flex-1 flex-col gap-4"> + {/* The BOOKING now lives in the breadcrumb (RescheduleClient sets it via + useSetBreadcrumbLabel); every reschedule page used to render an + identical "Reschedule" heading, so the consultee could not tell + which session they were moving (#1064). */} + <PanelHeader + description={ + resolved.consultantName + ? `Choose a new time for your ${resolved.typeLabel.toLowerCase()} with ${resolved.consultantName}` + : `Choose a new time for your ${resolved.typeLabel.toLowerCase()}` + } /> - <DesktopOnlyNotice> - <div className="rounded-lg border border-dashed border-border px-6 py-16 text-center"> - <p className="text-sm text-muted-foreground"> - The time picker is moving here from the dialog. For now, reschedule - from the booking's menu on the appointments page. - </p> - <Link - href={`/dashboard/consultee/${consulteeId}/appointments`} - className="mt-4 inline-block text-sm font-medium underline underline-offset-4" - > - Back to appointments - </Link> - </div> - </DesktopOnlyNotice> - - {/* Kept in the DOM so the route's contract is visible while it is a - skeleton: this page is per-appointment, not a generic picker. */} - <span className="sr-only">Appointment {appointmentId}</span> + <RescheduleClient + appointmentId={appointmentId} + title={resolved.title} + typeLabel={resolved.typeLabel} + subject={resolved.subject} + backHref={backHref} + /> </div> ); } diff --git a/app/dashboard/consultee/[consulteeId]/layout.tsx b/app/dashboard/consultee/[consulteeId]/layout.tsx index ef23d3e8c..bf472a997 100644 --- a/app/dashboard/consultee/[consulteeId]/layout.tsx +++ b/app/dashboard/consultee/[consulteeId]/layout.tsx @@ -112,6 +112,9 @@ const PAGE_LABELS: Record<string, string> = { recordings: "Recordings", feedback: "Feedback", help: "Help", + // Task route hanging off a record id; without this the trail ends on the + // raw lowercase segment. + reschedule: "Reschedule", }; // Opaque record ids (cuid / uuid) in nested routes carry no meaning as crumbs. @@ -277,25 +280,23 @@ function ConsulteeLayoutInner({ children, params }: Readonly<PageProps>) { const crumbs: { label: string; href?: string }[] = []; let acc = basePath; - let lastSegWasRecordId = false; for (const seg of parts) { acc = `${acc}/${seg}`; if (looksLikeRecordId(seg)) { - lastSegWasRecordId = true; + // The label goes HERE, in the id's own position — that segment IS the + // record. Deferring it to after the loop only worked when the id was + // the LAST segment, so a task route (…/<id>/reschedule) reset the flag + // on its way past and the override never rendered. + if (overrideLabel) crumbs.push({ label: overrideLabel, href: acc }); continue; } - lastSegWasRecordId = false; crumbs.push({ label: PAGE_LABELS[seg] ?? seg, href: acc, }); } - if (lastSegWasRecordId && overrideLabel) { - crumbs.push({ label: overrideLabel }); - } - return crumbs.map((crumb, index) => { const isLast = index === crumbs.length - 1; if (isLast && crumb.href && pathname === crumb.href) { diff --git a/components/appointments/AppointmentsFilterBar.tsx b/components/appointments/AppointmentsFilterBar.tsx index 79469b368..4d4017675 100644 --- a/components/appointments/AppointmentsFilterBar.tsx +++ b/components/appointments/AppointmentsFilterBar.tsx @@ -97,7 +97,10 @@ export function AppointmentsFilterBar({ onChange={(e) => onDateRangeChange({ ...dateRange, from: e.target.value }) } - className="h-9 w-[140px] text-xs" + // Wider and tighter than the default Input: at 140px with px-3, + // "dd/mm/yyyy" plus Chrome's own calendar-picker indicator + // overflows the content box and the icon sits on the border. + className="h-9 w-[158px] px-2.5 text-xs" aria-label="From date" /> <span className="text-xs text-muted-foreground">–</span> @@ -107,7 +110,10 @@ export function AppointmentsFilterBar({ onChange={(e) => onDateRangeChange({ ...dateRange, to: e.target.value }) } - className="h-9 w-[140px] text-xs" + // Wider and tighter than the default Input: at 140px with px-3, + // "dd/mm/yyyy" plus Chrome's own calendar-picker indicator + // overflows the content box and the icon sits on the border. + className="h-9 w-[158px] px-2.5 text-xs" aria-label="To date" /> {hasRange && ( diff --git a/components/appointments/AppointmentsShell.tsx b/components/appointments/AppointmentsShell.tsx index eece8a796..2a4123cd9 100644 --- a/components/appointments/AppointmentsShell.tsx +++ b/components/appointments/AppointmentsShell.tsx @@ -35,12 +35,16 @@ import { NextUpHero, type HeroStat } from "./NextUpHero"; type TabValue = AppointmentBucket | "all"; +// "All" leads, matching AppointmentsFilterBar's "All types" chip: one rule for +// where the everything-option sits, so the two rows share a left edge. Order +// here is presentation only — `initialTab` switches on the value, and the +// default selection is still Upcoming. const TABS: Array<{ value: TabValue; label: string }> = [ + { value: "all", label: "All" }, { value: "upcoming", label: "Upcoming" }, { value: "needsAction", label: "Needs action" }, { value: "past", label: "Past" }, { value: "cancelled", label: "Cancelled" }, - { value: "all", label: "All" }, ]; const EMPTY_COPY: Record<TabValue, { title: string; description: string }> = { diff --git a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx index d2f27ece2..d47acf42d 100644 --- a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx +++ b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx @@ -17,6 +17,7 @@ import type { import { CONSULTEE_JOIN_WINDOW_MS, getJoinableSlot, + slotsAllowReschedule, } from "@/lib/appointments/slots"; import { isApprovedStatus, @@ -29,17 +30,11 @@ import type { SlotLike, } from "@/lib/appointments/view-model"; import { useEventActions } from "@/components/appointments/consultee/useEventActions"; -import { RescheduleSessionsModal } from "@/components/appointments/consultee/RescheduleSessionsModal"; -import { useSession } from "@/lib/auth-client"; import { CancelConfirmationDialog } from "@/components/appointments/consultee/CancelConfirmationDialog"; import { ReportIssueDialog } from "@/components/appointments/consultee/ReportIssueDialog"; import { DocumentUpload } from "@/components/appointments/DocumentUpload"; -type DialogKind = - | "cancel" - | "reschedule-multi" - | "report" - | "documents"; +type DialogKind = "cancel" | "report" | "documents"; const KIND_TO_TYPE: Record< AppointmentVM["kind"], @@ -70,10 +65,6 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { const client = useStreamVideoClient(); const params = useParams<{ consulteeId: string }>(); const consulteeId = params?.consulteeId; - // The viewer's USER id (not the consultee-profile id in the route) — the - // availability grid keys occupancy off slot participation, and the route's - // `isSelf` gate compares against session.user.id. - const { data: session } = useSession(); // ONE set of dialogs, keyed off the row that opened them. const [activeVm, setActiveVm] = useState<AppointmentVM | null>(null); @@ -178,35 +169,24 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { const items: OverflowItem[] = []; const inactive = isInactiveStatus(vm.status); const slots = vm.raw.rawSlots ?? []; - const firstRaw = slots[0]; - const tentative = firstRaw?.isTentative ?? false; - // A released slot awaiting a new time IS the open reschedule: at most one - // may be live per appointment (the nullable-unique openForAppointmentId), - // so offering the action again only earns a 409. - const rescheduleInFlight = slots.some( - (slot) => slot.completionStatus === "RESCHEDULED", - ); - if ( vm.appointmentId && + // The reschedule page lives under this route's consultee; off that route + // there is no id to send them to. + consulteeId && !inactive && - !tentative && isApprovedStatus(vm.status) && - // An APPROVED booking with nothing allocated yet ("Not scheduled · 0/0") - // has no time to move. The proposal window is derived from the earliest - // released session, so this would fail with PROPOSAL_WINDOW_CLOSED. - slots.length > 0 && - !rescheduleInFlight + slotsAllowReschedule(slots) ) { items.push({ key: "reschedule", label: "Reschedule", + // A page, not a dialog: choosing a time is a full-width task, and a + // URL means a half-finished choice survives a refresh. onClick: () => - // One surface for every reschedule. The modal skips its - // session-picker step when there is only one session, so a - // consultation opens straight on "pick a new time" — a second dialog - // for the same action is how the four planner dialogs started. - openDialog(vm, "reschedule-multi"), + router.push( + `/dashboard/consultee/${consulteeId}/appointments/${vm.appointmentId}/reschedule`, + ), }); } if (vm.appointmentId && !inactive) { @@ -279,24 +259,6 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { isPendingPayment={isPendingPayment} /> - <RescheduleSessionsModal - open={dialog === "reschedule-multi"} - onOpenChange={(open) => !open && closeDialog()} - typeLabel={typeLabel} - rawSlots={activeVm.raw.rawSlots ?? []} - isLoading={actions.isLoading} - consultantProfileId={activeVm.consultantProfileId} - consulteeUserId={session?.user?.id} - sessionDurationInHours={ - activeVm.raw.appointment?.subscription?.subscriptionPlan - ?.sessionDurationInHours ?? undefined - } - onConfirm={({ slotIds, proposedSlots }) => { - closeDialog(); - void actions.handleReschedule(slotIds, proposedSlots); - }} - /> - {activeVm.appointmentId && dialog === "report" && ( <ReportIssueDialog open diff --git a/components/appointments/consultee/RescheduleSessionsModal.tsx b/components/appointments/consultee/RescheduleSessionsModal.tsx deleted file mode 100644 index 65c0ef5dd..000000000 --- a/components/appointments/consultee/RescheduleSessionsModal.tsx +++ /dev/null @@ -1,589 +0,0 @@ -"use client"; - -import React from "react"; -import { Button } from "@/components/ui/button"; -import { - ResponsiveModal, - ResponsiveModalContent, - ResponsiveModalDescription, - ResponsiveModalFooter, - ResponsiveModalHeader, - ResponsiveModalTitle, -} from "@/components/ui/responsive-modal"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Label } from "@/components/ui/label"; -import { - Clock, - CalendarClock, - CalendarRange, - CheckSquare, - Check, - Loader2, -} from "lucide-react"; -import { format } from "date-fns"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { cn } from "@/utils/tailwind"; -import { toDate, type SlotLike } from "@/lib/appointments/view-model"; -import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar"; - -/** - * Multi-session reschedule dialog (one / multiple / entire program with the - * 24-hour lock), extracted verbatim from MultiSessionEventCard so the shared - * appointments surface can mount it once, adapter-controlled. - */ - -interface RescheduleSessionsModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - /** "Subscription" | "Class" — copy only. */ - typeLabel: string; - /** Future/ongoing slots of the program (useEventActions rawSlots shape). */ - rawSlots: SlotLike[]; - isLoading: boolean; - /** - * The consultant delivering this program. When present the dialog offers a - * second step where the consultee picks the times they actually want; without - * it the dialog degrades to release-only, which is the old behaviour. - */ - consultantProfileId?: string | null; - /** The viewer's own user id. The picker greys out times they are ALREADY - * booked at with any consultant — allocation rejects those, so painting - * them green would only fail at submit. */ - consulteeUserId?: string; - /** Session length in hours, so the picker asks for the right block size. */ - sessionDurationInHours?: number; - /** - * Called with the selected slot ids (undefined = the entire program) and, - * optionally, the times the consultee would like instead. - */ - onConfirm: (args: { - slotIds?: string[]; - proposedSlots?: { startsAt: string; endsAt: string }[]; - }) => void; -} - -function formatSlotDate(date: Date | string): string { - return format(toDate(date), "EEE, d MMM yyyy"); -} - -function formatSlotTime(date: Date | string): string { - return format(toDate(date), "h:mm a"); -} - -type SessionWithProps = { - slots: SlotLike[]; - isWithin24Hours: boolean; - startTime: Date; - endTime: Date; -}; - -/** - * The which-sessions picker. - * - * Extracted because the modal now carries a second step, and holding both the - * step machinery and this list in one function put it past the complexity - * budget — the list is self-contained, so it moves rather than the step logic - * being contorted around it. - */ -function SessionSelector({ - rescheduleType, - sessions, - selectedSlotIds, - selectedSessionCount, - onToggle, -}: Readonly<{ - rescheduleType: "individual" | "multiple"; - sessions: SessionWithProps[]; - selectedSlotIds: string[]; - selectedSessionCount: number; - onToggle: React.Dispatch<React.SetStateAction<string[]>>; -}>) { - return ( - <div className="mt-4 space-y-2"> - <Label className="text-sm font-medium text-foreground"> - {rescheduleType === "individual" - ? "Select the session to reschedule:" - : "Select sessions to reschedule:"} - </Label> - <div className="max-h-48 overflow-y-auto space-y-2 rounded-lg border border-border p-2"> - {sessions.map((session, sessionIndex) => { - const sessionSlotIds = session.slots.map((s) => s.id); - const isSelected = sessionSlotIds.every((id) => - selectedSlotIds.includes(id), - ); - - const handleSessionClick = () => { - if (session.isWithin24Hours) return; - if (rescheduleType === "individual") { - onToggle(sessionSlotIds); - } else if (isSelected) { - onToggle((prev) => - prev.filter((id) => !sessionSlotIds.includes(id)), - ); - } else { - onToggle((prev) => - Array.from(new Set([...prev, ...sessionSlotIds])), - ); - } - }; - - return ( - <button - key={`session-${sessionIndex}`} - type="button" - onClick={handleSessionClick} - disabled={session.isWithin24Hours} - className={cn( - "w-full flex items-center justify-between p-2.5 rounded-md text-left transition-colors", - isSelected - ? "bg-primary text-primary-foreground" - : session.isWithin24Hours - ? "bg-muted text-muted-foreground/70 cursor-not-allowed" - : "bg-muted hover:bg-muted/80 text-foreground", - )} - > - <div className="flex items-center gap-2"> - {rescheduleType === "multiple" && ( - <div - className={cn( - "w-4 h-4 rounded border flex items-center justify-center", - isSelected - ? "bg-primary-foreground border-primary-foreground" - : session.isWithin24Hours - ? "border-border" - : "border-muted-foreground", - )} - > - {isSelected && <Check className="h-3 w-3 text-primary" />} - </div> - )} - <div> - <div className="text-sm font-medium"> - {formatSlotDate(session.startTime)} - </div> - <div - className={cn( - "text-xs", - isSelected - ? "text-primary-foreground/70" - : "text-muted-foreground", - )} - > - {formatSlotTime(session.startTime)} -{" "} - {formatSlotTime(session.endTime)} - </div> - </div> - </div> - {session.isWithin24Hours && ( - <span className="text-xs bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-300 px-2 py-0.5 rounded"> - Within 24h - </span> - )} - </button> - ); - })} - </div> - {rescheduleType === "multiple" && selectedSlotIds.length > 0 && ( - <p className="text-sm text-muted-foreground font-medium"> - {selectedSessionCount} session - {selectedSessionCount > 1 ? "s" : ""} selected - </p> - )} - </div> - ); -} - -export function RescheduleSessionsModal({ - open, - onOpenChange, - typeLabel, - rawSlots, - isLoading, - consultantProfileId, - consulteeUserId, - sessionDurationInHours, - onConfirm, -}: Readonly<RescheduleSessionsModalProps>) { - // Two steps: which sessions to move, then (optionally) when to move them to. - const [step, setStep] = React.useState<"sessions" | "times">("sessions"); - const [proposedSlots, setProposedSlots] = React.useState< - { startsAt: string; endsAt: string }[] - >([]); - const [rescheduleType, setRescheduleType] = React.useState< - "individual" | "multiple" | "entire" - >("entire"); - const [selectedSlotIds, setSelectedSlotIds] = React.useState<string[]>([]); - - // The dedicated reschedule page is per-appointment and lives in the consultee - // tree; the consultant adapter mounts this same modal and has neither id, so - // both being present is also the check that we are on the right surface. - const routeParams = useParams(); - const routeConsulteeId = routeParams.consulteeId as string | undefined; - const pageAppointmentId = rawSlots.find((slot) => slot.appointmentId) - ?.appointmentId; - - // Group slots by appointmentId — one session can span multiple slots. - const groupedSessions = React.useMemo(() => { - const nonTentative = rawSlots.filter((slot) => !slot.isTentative); - const groups = new Map<string, SlotLike[]>(); - for (const slot of nonTentative) { - const key = slot.appointmentId ?? slot.id; - const bucket = groups.get(key); - if (bucket) bucket.push(slot); - else groups.set(key, [slot]); - } - return Array.from(groups.values()) - .map((slots) => { - const sorted = [...slots].sort( - (a, b) => toDate(a.startsAt).getTime() - toDate(b.startsAt).getTime(), - ); - const last = sorted[sorted.length - 1]; - return { - slots: sorted, - startTime: toDate(sorted[0].startsAt), - endTime: toDate(last.endsAt ?? last.startsAt), - }; - }) - .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); - }, [rawSlots]); - - const sessionsWithDynamicProps = groupedSessions.map((session) => ({ - ...session, - isWithin24Hours: - (session.startTime.getTime() - Date.now()) / (1000 * 60 * 60) < 24, - })); - - const selectedSessionCount = React.useMemo(() => { - return groupedSessions.filter((session) => - session.slots.every((slot) => selectedSlotIds.includes(slot.id)), - ).length; - }, [groupedSessions, selectedSlotIds]); - - /** "individual" and "multiple" both pick specific sessions; "entire" does not. */ - const picksSpecificSessions = - rescheduleType === "individual" || rescheduleType === "multiple"; - - /** Nothing to submit: a picking mode is active but no session is ticked. */ - const selectionIncomplete = - picksSpecificSessions && selectedSlotIds.length === 0; - - const releasedSlotIds = - picksSpecificSessions && selectedSlotIds.length > 0 - ? selectedSlotIds - : undefined; - - /** How many sessions the consultee is moving, so the picker asks for that many. */ - const sessionsBeingMoved = - releasedSlotIds === undefined - ? groupedSessions.length - : Math.max(selectedSessionCount, 1); - - const canProposeTimes = !!consultantProfileId; - - /** - * A one-session booking has nothing to choose between, so the "which - * sessions" step is noise: there is only ever one answer. Skipping it is what - * lets this modal serve consultations too, replacing the separate - * confirm-only dialog they used to get — which offered no way to propose a - * time at all. - */ - const isSingleSession = groupedSessions.length <= 1; - - - // Reset whenever the dialog (re)opens, and start on the step that actually - // has a question to answer: a one-session booking has nothing to choose - // between, so it opens straight on "when would you like it instead". - React.useEffect(() => { - if (open) { - setRescheduleType("entire"); - setSelectedSlotIds([]); - setStep(isSingleSession && canProposeTimes ? "times" : "sessions"); - setProposedSlots([]); - } - }, [open, isSingleSession, canProposeTimes]); - - /** - * Releasing without naming a time is still valid — it hands the consultant a - * request with no stated preference, which is what every reschedule used to - * do — so the secondary button says so rather than reading like a cancel. - */ - const submitLabel = (() => { - if (step === "times") return "Request this time"; - if (canProposeTimes) return "Any time works"; - return "Confirm Reschedule"; - })(); - - const submit = (withTimes: boolean) => { - onConfirm({ - slotIds: releasedSlotIds, - proposedSlots: - withTimes && proposedSlots.length ? proposedSlots : undefined, - }); - }; - - return ( - <ResponsiveModal open={open} onOpenChange={onOpenChange}> - <ResponsiveModalContent className="sm:max-w-md max-h-[90vh] overflow-y-auto scrollbar-hide"> - <ResponsiveModalHeader> - <ResponsiveModalTitle className="flex items-center gap-2"> - <Clock className="h-5 w-5 text-muted-foreground" /> - {isSingleSession ? "Reschedule Session" : "Reschedule Options"} - </ResponsiveModalTitle> - <ResponsiveModalDescription> - {isSingleSession - ? `Pick a new time for your ${typeLabel.toLowerCase()}.` - : `Choose how you'd like to reschedule your ${typeLabel.toLowerCase()} sessions.`} - </ResponsiveModalDescription> - </ResponsiveModalHeader> - - <div className="py-4 space-y-4"> - {step === "sessions" && ( - <> - {/* Every session-selection control hangs off this ONE check. - Gating the step but not its chrome is how a one-session - consultation ended up being asked to "Reschedule Multiple - Sessions" and warned that "All 1 sessions will be released". */} - {!isSingleSession && ( - <> - <RadioGroup - value={rescheduleType} - onValueChange={(value) => { - setRescheduleType( - value as "individual" | "multiple" | "entire", - ); - if (value === "entire") { - setSelectedSlotIds([]); - } else if (value === "individual") { - // Auto-select the session containing the first selected slot - const firstSelectedId = selectedSlotIds[0]; - const sessionWithFirst = groupedSessions.find((session) => - session.slots.some((s) => s.id === firstSelectedId), - ); - const first = sessionWithFirst - ? sessionWithFirst.slots.map((s) => s.id) - : groupedSessions.length > 0 - ? groupedSessions[0].slots.map((s) => s.id) - : []; - setSelectedSlotIds(first); - } - }} - className="space-y-3" - > - <div - className={cn( - "flex items-start space-x-3 p-3 rounded-lg border transition-colors", - rescheduleType === "individual" - ? "border-primary bg-muted" - : "border-border hover:border-foreground/30", - )} - > - <RadioGroupItem - value="individual" - id="individual" - className="mt-1" - /> - <Label htmlFor="individual" className="flex-1 cursor-pointer"> - <div className="flex items-center gap-2 font-medium text-foreground"> - <CalendarClock className="h-4 w-4" /> - Reschedule One Session - </div> - <p className="text-xs text-muted-foreground mt-1"> - Only the selected session will be rescheduled. - </p> - </Label> - </div> - - <div - className={cn( - "flex items-start space-x-3 p-3 rounded-lg border transition-colors", - rescheduleType === "multiple" - ? "border-primary bg-muted" - : "border-border hover:border-foreground/30", - )} - > - <RadioGroupItem - value="multiple" - id="multiple" - className="mt-1" - /> - <Label htmlFor="multiple" className="flex-1 cursor-pointer"> - <div className="flex items-center gap-2 font-medium text-foreground"> - <CheckSquare className="h-4 w-4" /> - Reschedule Multiple Sessions - </div> - <p className="text-xs text-muted-foreground mt-1"> - Select specific sessions to reschedule. - </p> - </Label> - </div> - - <div - className={cn( - "flex items-start space-x-3 p-3 rounded-lg border transition-colors", - rescheduleType === "entire" - ? "border-primary bg-muted" - : "border-border hover:border-foreground/30", - )} - > - <RadioGroupItem value="entire" id="entire" className="mt-1" /> - <Label htmlFor="entire" className="flex-1 cursor-pointer"> - <div className="flex items-center gap-2 font-medium text-foreground"> - <CalendarRange className="h-4 w-4" /> - Reschedule Entire {typeLabel} - </div> - <p className="text-xs text-muted-foreground mt-1"> - All {groupedSessions.length} sessions will be released. - </p> - </Label> - </div> - </RadioGroup> - - {picksSpecificSessions && ( - <SessionSelector - rescheduleType={rescheduleType} - sessions={sessionsWithDynamicProps} - selectedSlotIds={selectedSlotIds} - selectedSessionCount={selectedSessionCount} - onToggle={setSelectedSlotIds} - /> - )} - </> - )} - - <div className="bg-amber-50 border border-amber-200 dark:bg-amber-900/20 dark:border-amber-900/40 rounded-lg p-3"> - <p className="text-xs text-amber-800 dark:text-amber-300"> - <strong>Note:</strong> Sessions cannot be rescheduled within - 24 hours of the start time. No refunds are provided for - rescheduling. - </p> - </div> - </> - )} - - {step === "times" && consultantProfileId && ( - <div className="space-y-3"> - <p className="text-sm text-muted-foreground"> - Pick{" "} - {sessionsBeingMoved === 1 - ? "a time" - : `${sessionsBeingMoved} times`}{" "} - that suit you. Green means the consultant is free — choosing one - of those confirms straight away. Anything else is sent to them - to approve. - </p> - - <SafeUnifiedCalendar - consultantId={consultantProfileId} - consulteeUserId={consulteeUserId} - eventType="consultation" - mode="select" - sessionDurationInHours={sessionDurationInHours} - onSlotsSelected={(slots) => - setProposedSlots( - slots.map((slot) => ({ - startsAt: slot.startTime.toISOString(), - endsAt: slot.endTime.toISOString(), - })), - ) - } - /> - - {proposedSlots.length > 0 && ( - <p className="text-sm"> - {proposedSlots.length} slot - {proposedSlots.length === 1 ? "" : "s"} selected. - </p> - )} - </div> - )} - </div> - - <ResponsiveModalFooter className="gap-2 sm:gap-0"> - {/* Preview of the full-width picker this modal moves to next; the - modal stays authoritative until it does. */} - {routeConsulteeId && pageAppointmentId && ( - <Button - asChild - variant="ghost" - size="sm" - className="sm:mr-auto text-muted-foreground" - > - <Link - href={`/dashboard/consultee/${routeConsulteeId}/appointments/${pageAppointmentId}/reschedule`} - > - Open as page - </Link> - </Button> - )} - - <Button - variant="outline" - onClick={() => - // A single-session booking never had a sessions step to go back - // to, so "Back" there would strand the user on a dead button. - step === "times" && !isSingleSession - ? setStep("sessions") - : onOpenChange(false) - } - disabled={isLoading} - > - {step === "times" && !isSingleSession ? "Back" : "Cancel"} - </Button> - - {step === "sessions" && canProposeTimes && !isSingleSession && ( - <Button - onClick={() => setStep("times")} - disabled={isLoading || selectionIncomplete} - className="bg-primary text-primary-foreground hover:bg-primary/90" - > - Pick a new time - </Button> - )} - - {/* Naming a time is an OPTION, never a requirement. A one-session - booking opens straight on the picker and has no sessions step to - go back to, so without this its only submit is "Request this - time" — disabled until a time is picked. That removed the - release-only path the old confirm-only dialog always offered, and - releasing without a preference is still perfectly valid: it hands - the consultant a request to allocate, which is what every - reschedule did before proposals existed. */} - {step === "times" && isSingleSession && ( - <Button - variant="outline" - onClick={() => submit(false)} - disabled={isLoading} - > - Any time works - </Button> - )} - - <Button - variant={step === "times" ? "default" : "outline"} - onClick={() => submit(step === "times")} - disabled={ - isLoading || - selectionIncomplete || - (step === "times" && proposedSlots.length === 0) - } - className={ - step === "times" - ? "bg-primary text-primary-foreground hover:bg-primary/90" - : undefined - } - > - {isLoading ? ( - <> - <Loader2 className="h-4 w-4 mr-2 animate-spin" /> - Processing... - </> - ) : ( - submitLabel - )} - </Button> - </ResponsiveModalFooter> - </ResponsiveModalContent> - </ResponsiveModal> - ); -} diff --git a/components/appointments/consultee/useEventActions.ts b/components/appointments/consultee/useEventActions.ts index 789f032a9..be6e4fa1b 100644 --- a/components/appointments/consultee/useEventActions.ts +++ b/components/appointments/consultee/useEventActions.ts @@ -109,17 +109,20 @@ export function useEventActions({ } }; + /** Resolves true only when the release actually landed — the reschedule page + * navigates away on that, and must stay put (with the selection) on a + * failure the user can retry. */ const handleReschedule = async ( slotIds?: string[], proposedSlots?: { startsAt: string; endsAt: string }[], - ) => { + ): Promise<boolean> => { if (!appointmentId) { toast({ title: "Error", description: "Appointment ID is missing", variant: "destructive", }); - return; + return false; } setIsLoading(true); @@ -163,6 +166,7 @@ export function useEventActions({ ); invalidateBookingData(); + return true; } catch (error) { Sentry.captureException( error instanceof Error ? error : new Error(String(error)), @@ -177,6 +181,7 @@ export function useEventActions({ : "Failed to request reschedule", variant: "destructive", }); + return false; } finally { setIsLoading(false); } diff --git a/components/dashboard/CollapsibleSidebar.tsx b/components/dashboard/CollapsibleSidebar.tsx index a542fe284..51002281a 100644 --- a/components/dashboard/CollapsibleSidebar.tsx +++ b/components/dashboard/CollapsibleSidebar.tsx @@ -157,6 +157,14 @@ export function CollapsibleSidebar({ const router = useRouter(); const [collapsed, setCollapsed] = useState(false); + // An EMPTY array is truthy, so a consultant with no org memberships still got + // the switcher chrome — a chevron opening a menu containing nothing. Labels + // and separators alone are not something to switch TO either, so the chip + // only becomes a dropdown when there is at least one real destination in it. + const hasChipActions = !!bottomUserChipActions?.some( + (action) => action.type === "item", + ); + // Per-group collapsed map, keyed by the group's label. Defaults to // each group's `defaultCollapsed` on first render so callers can // hint "Operations" closed for OWNER while leaving People + Commerce @@ -472,12 +480,12 @@ export function CollapsibleSidebar({ </TooltipProvider> </nav> - {/* Footer */} +{/* Footer */} <div className="border-t border-zinc-200 dark:border-zinc-800 p-2 space-y-1"> - {/* Personal chip — clickable dropdown when `bottomUserChipActions` - is provided (org switcher lives here), otherwise a static strip. - Sign Out remains as a separate standalone red button below. */} - {bottomUserChip && bottomUserChipActions ? ( + {/* Personal chip — a dropdown only when there is somewhere to go (the + org switcher lives here), otherwise a static strip. Sign Out remains + a separate standalone red button below. */} + {bottomUserChip && hasChipActions ? ( <DropdownMenu> <DropdownMenuTrigger asChild> <button @@ -509,7 +517,7 @@ export function CollapsibleSidebar({ </button> </DropdownMenuTrigger> <DropdownMenuContent side="top" align="start" className="w-60"> - {bottomUserChipActions.map((action, i) => { + {(bottomUserChipActions ?? []).map((action, i) => { if (action.type === "separator") { return <DropdownMenuSeparator key={i} />; } diff --git a/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx b/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx index d4c22ab0d..eef776a31 100644 --- a/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx +++ b/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx @@ -8,13 +8,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { - ResponsiveModal, - ResponsiveModalContent, - ResponsiveModalDescription, - ResponsiveModalHeader, - ResponsiveModalTitle, -} from "@/components/ui/responsive-modal"; import { ResponsiveTable, type ResponsiveColumn, @@ -25,14 +18,13 @@ import { AlertTriangle, CalendarClock, CheckCircle2, + ChevronDown, RefreshCw, } from "lucide-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { RequestedSlotsDialog } from "./components/RequestedSlotsDialog"; import { PaymentRequiredBadge } from "./components/PaymentRequiredBadge"; -import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar"; import { ConsultationApiResponse, RequestedBy, @@ -202,6 +194,75 @@ function formatDateTime(value: string | Date): string { /** Beyond this the list stops being scannable and starts being a wall. */ const MAX_VISIBLE_SLOTS = 3; +/** + * The consultee's note, clamped to 3 lines with an expand toggle underneath. + * + * The toggle appears only when the clamp is actually cutting text off — a + * permanent "Read more" under a two-line note is noise nobody asked for. And + * the overflow is measured rather than guessed from a character count: this + * column is `max-w-[26rem]` but flexes narrower, so the same string clips at + * one width and not another. + * + * `components/ui/collapsible` is the wrong primitive here — it hides its + * content outright, and the point of this cell is that three lines stay + * readable while collapsed. Same chevron-and-"Show less" shape as the other + * in-place expanders (`SessionTimeline`, `FacetGroup`). + */ +function RequestNote({ notes }: Readonly<{ notes: string }>) { + const textRef = useRef<HTMLParagraphElement>(null); + const [isClamped, setIsClamped] = useState(false); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + const el = textRef.current; + // Measured only while COLLAPSED. Expanding drops the clamp, so an expanded + // paragraph always reports scrollHeight === clientHeight; re-measuring + // then would decide it no longer overflows and remove the only control + // that collapses it again. + if (!el || expanded) return; + + // +1 absorbs the subpixel rounding between scrollHeight and clientHeight + // that would otherwise flag an exactly-3-line note as clipped. + const measure = () => setIsClamped(el.scrollHeight > el.clientHeight + 1); + measure(); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [notes, expanded]); + + return ( + <div className="max-w-[26rem] text-left"> + <p + ref={textRef} + className={cn( + "text-xs italic text-muted-foreground", + !expanded && "line-clamp-3", + )} + > + “{notes}” + </p> + {isClamped && ( + <button + type="button" + aria-expanded={expanded} + className="mt-1 flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground" + onClick={() => setExpanded((prev) => !prev)} + > + <ChevronDown + className={cn( + "h-3 w-3 transition-transform", + expanded && "rotate-180", + )} + /> + {expanded ? "Show less" : "Read more"} + </button> + )} + </div> + ); +} + /** * The live offer: who wants what, and until when. * @@ -399,18 +460,18 @@ export function RequestSlotAllocationTab({ orgScope = "personal", }: RequestSlotAllocationTabProps) { const params = useParams(); - // Only the consultant tree has this param, and the dedicated allocate page - // lives inside that tree behind a personal-profile check — so an org admin - // viewing someone else's requests must not be offered a link that 403s. + const router = useRouter(); const routeConsultantId = params.consultantId as string | undefined; + // The allocate page lives in the consultant tree behind a personal-profile + // check, and this id passes it on both mount points: the consultant route + // supplies its own, and the org route supplies the VIEWER's own consultant + // profile (allocation is a delivery act — only the deliverer allocates). const consultantId = consultantProfileId ?? (routeConsultantId as string); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [requests, setRequests] = useState<Request[]>([]); /** When the rows on screen were last successfully read. */ const [lastUpdated, setLastUpdated] = useState<Date | null>(null); - const [selectedRequest, setSelectedRequest] = useState<Request | null>(null); - const [dialogOpen, setDialogOpen] = useState(false); const [requestedSlotsDialogOpen, setRequestedSlotsDialogOpen] = useState(false); const [selectedRequestForDialog, setSelectedRequestForDialog] = @@ -420,6 +481,10 @@ export function RequestSlotAllocationTab({ const fetchData = useCallback(async () => { setLoading(true); setError(null); + // Local, not the `error` state: reading that in `finally` sees the value + // from render time, not the one just set, so a failed fetch still stamped + // "Updated" with a timestamp it had not earned. + let succeeded = false; try { // Fetch data in parallel (only PENDING requests). @@ -437,10 +502,9 @@ export function RequestSlotAllocationTab({ for (const result of results) { if (!result.ok && result.error) { - // Set the first encountered error and stop + // Set the first encountered error and stop. `finally` clears loading. setError(result.error); - setLoading(false); // Ensure loading state is updated - return; // Exit fetchData early + return; } } @@ -599,6 +663,7 @@ export function RequestSlotAllocationTab({ // --- Update State --- setRequests(processedRequests); + succeeded = true; } catch (err) { // This catch block now primarily handles errors during data *processing* Sentry.captureException( @@ -612,32 +677,37 @@ export function RequestSlotAllocationTab({ : "An unexpected error occurred while processing data.", ); } finally { - // setLoading(false) is handled earlier in case of fetch errors - // Only set it here if no fetch error occurred - if (!error) { - setLoading(false); - setLastUpdated(new Date()); - } + // Loading always clears; the timestamp only moves on a real success. + setLoading(false); + if (succeeded) setLastUpdated(new Date()); } // orgScope belongs here: fetchData builds both URLs from it, so without it // a scope change without a remount keeps refetching the previous org's rows. - }, [consultantId, type, error, orgScope]); - + // + // `error` must NOT: it is written by this callback and read by the effect + // that calls it, so a failing endpoint looped — fail, set error, new + // identity, refire, clear error, new identity, refire — hammering the API + // and never letting the error view settle. + }, [consultantId, type, orgScope]); + + // Fetches once. Nothing refetches on its own — not a timer, not focus. + // + // This went 5-minute interval -> focus -> neither, and the last step is the + // one that mattered: focus fires on every alt-tab, which is far MORE often + // than the timer it replaced for anyone actually working. It also reached + // the calendar, because this tab used to host it — a repaint mid-selection, + // caused by data nobody asked for. + // + // Staleness is safe to leave. This grid is a hint; allocation re-validates + // server-side under a Redis lock against SlotValidationService and the + // btree_gist exclusion constraint, so a stale view cannot double-book — at + // worst a submit is rejected with a clear message. Refreshing bought no + // correctness and cost a selection. + // + // The Refresh button and the "Updated" label carry it instead: the user + // decides when, and staleness is stated rather than implied. useEffect(() => { fetchData(); - - // Refetch when the tab regains focus, and ONLY then. - // - // The 5-minute interval this replaces bought almost nothing: requests - // arrive when a consultee books, which no consultant sits watching, so - // minutes of staleness cost nothing — while every open tab hit two - // paginated endpoints with four-level includes, forever, for data nobody - // was reading. Focus is when someone actually looks, so it is both cheaper - // and better timed. The explicit Refresh button covers the rest, and the - // "updated" label makes staleness visible instead of implying it is live. - const onFocus = () => fetchData(); - window.addEventListener("focus", onFocus); - return () => window.removeEventListener("focus", onFocus); }, [fetchData]); // Idempotency key for the requested-times flow; a retry of the same request @@ -649,8 +719,6 @@ export function RequestSlotAllocationTab({ const handleConflict = useCallback( (requestId?: string) => { toast(allocatedElsewhere()); - setDialogOpen(false); - setSelectedRequest(null); setRequestedSlotsDialogOpen(false); setSelectedRequestForDialog(null); if (requestId) { @@ -773,25 +841,6 @@ export function RequestSlotAllocationTab({ } }; - // Handle allocation complete from UnifiedCalendar - const handleAllocationComplete = async () => { - toast({ - title: "Schedule confirmed", - description: "All session times have been scheduled.", - variant: "default", - }); - - // Close dialog and reset state - setDialogOpen(false); - setSelectedRequest(null); - - // Remove request from list - setRequests((prev) => prev.filter((r) => r.id !== selectedRequest?.id)); - - // Notify parent - onUpdate(); - }; - // No heading here: both mount points already render a "Requests" page header, // so anything this component titles itself is the second copy of it. if (loading) { @@ -915,9 +964,7 @@ export function RequestSlotAllocationTab({ className: "align-top", cell: (request) => request.requestNotes?.trim() ? ( - <p className="line-clamp-3 max-w-[26rem] text-left text-xs italic text-muted-foreground"> - “{request.requestNotes.trim()}” - </p> + <RequestNote notes={request.requestNotes.trim()} /> ) : ( // An em dash rather than blank: "they said nothing" and "we failed to // load it" should not look identical. @@ -954,13 +1001,17 @@ export function RequestSlotAllocationTab({ </p> ) : ( <> + {/* A page, not a dialog: placing N sessions across a + scheduling period under per-day and per-week caps needs + the width, and a URL the notification can link to. */} <Button size="sm" className="w-full" - onClick={() => { - setSelectedRequest(request); - setDialogOpen(true); - }} + onClick={() => + router.push( + `/dashboard/consultant/${consultantId}/requests/${request.id}/allocate?type=${request.type.toLowerCase()}`, + ) + } > Allocate Slots </Button> @@ -986,23 +1037,6 @@ export function RequestSlotAllocationTab({ )} </> )} - {/* Preview of the full-width allocation surface that replaces this - dialog next. Read-only, so it sits beside the real actions - rather than competing with them. */} - {routeConsultantId && ( - <Button - asChild - variant="ghost" - size="sm" - className="w-full text-muted-foreground" - > - <Link - href={`/dashboard/consultant/${routeConsultantId}/requests/${request.id}/allocate?type=${request.type.toLowerCase()}`} - > - Open as page - </Link> - </Button> - )} {/* Quiet by design: declining is the rarer branch, and nothing is destroyed until the request is actually rejected. */} {request.type === AppointmentsType.CONSULTATION && ( @@ -1075,105 +1109,6 @@ export function RequestSlotAllocationTab({ /> )} - {/* Single Allocation Dialog - moved outside map loop to prevent multiple dialogs */} - <ResponsiveModal open={dialogOpen} onOpenChange={setDialogOpen}> - <ResponsiveModalContent className="max-w-[95vw] w-full lg:max-w-[1400px] max-h-[90dvh] overflow-hidden flex flex-col"> - <ResponsiveModalHeader className="shrink-0"> - <ResponsiveModalTitle>Allocate Slots</ResponsiveModalTitle> - <ResponsiveModalDescription asChild> - {selectedRequest && ( - <div className="space-y-1 text-sm text-muted-foreground"> - <p> - Choose {selectedRequest.requiredSlots} slots for{" "} - {selectedRequest.type.toLowerCase()} - </p> - {selectedRequest.type === "SUBSCRIPTION" && - selectedRequest.sessionDurationInHours && ( - <p className="text-xs"> - Each call is{" "} - {selectedRequest.sessionDurationInHours === 1 - ? "1 hour" - : `${selectedRequest.sessionDurationInHours} hours`}{" "} - ( - {Math.ceil( - selectedRequest.sessionDurationInHours / 0.5, - )}{" "} - consecutive slots per call) - </p> - )} - {selectedRequest.type === "CONSULTATION" && - selectedRequest.durationInHours && ( - <p className="text-xs"> - Consultation is{" "} - {selectedRequest.durationInHours === 1 - ? "1 hour" - : `${selectedRequest.durationInHours} hours`}{" "} - ({Math.ceil(selectedRequest.durationInHours / 0.5)}{" "} - consecutive slots) - </p> - )} - {selectedRequest.startDate && selectedRequest.endDate && ( - <p className="text-xs text-muted-foreground"> - Scheduling period:{" "} - {selectedRequest.startDate.toLocaleDateString()} -{" "} - {selectedRequest.endDate.toLocaleDateString()} - </p> - )} - </div> - )} - </ResponsiveModalDescription> - </ResponsiveModalHeader> - {selectedRequest && ( - <SafeUnifiedCalendar - className="min-h-0 flex-1" - consultantId={consultantId} - eventType={ - selectedRequest.type.toLowerCase() as - | "consultation" - | "subscription" - } - eventId={selectedRequest.id} - consulteeUserId={selectedRequest.requestedBy?.user?.id} - mode="allocate" - onAllocationComplete={handleAllocationComplete} - onAllocationConflict={() => handleConflict(selectedRequest.id)} - initialAllocation={ - (selectedRequest.tentativeSlotCount ?? 0) === 0 - } - showAllocationButtons={true} - durationInMonths={ - selectedRequest.type === "SUBSCRIPTION" - ? selectedRequest.durationInMonths - : undefined - } - durationInHours={ - selectedRequest.type === "CONSULTATION" - ? selectedRequest.durationInHours - : undefined - } - sessionsPerWeek={ - selectedRequest.type === "SUBSCRIPTION" - ? selectedRequest.sessionsPerWeek - : undefined - } - sessionDurationInHours={ - selectedRequest.type === "SUBSCRIPTION" - ? selectedRequest.sessionDurationInHours - : undefined - } - allowedStart={selectedRequest.startDate} - allowedEnd={selectedRequest.endDate} - schedulingTimezone={selectedRequest.schedulingTimezone} - totalSessions={ - selectedRequest.type === "SUBSCRIPTION" - ? selectedRequest.totalSessions - : undefined - } - /> - )} - </ResponsiveModalContent> - </ResponsiveModal> - <RequestedSlotsDialog open={requestedSlotsDialogOpen} onOpenChange={setRequestedSlotsDialogOpen} diff --git a/components/scheduling/DesktopOnlyNotice.tsx b/components/scheduling/DesktopOnlyNotice.tsx index 96c237a1b..71f311768 100644 --- a/components/scheduling/DesktopOnlyNotice.tsx +++ b/components/scheduling/DesktopOnlyNotice.tsx @@ -1,19 +1,50 @@ +"use client"; + +import { useEffect, useState } from "react"; import { Monitor } from "lucide-react"; /** * Gate for surfaces that genuinely need a wide viewport — the slot heatmap is * seven day-columns of 30-minute rows, which does not survive a phone. * - * CSS-only on purpose. Detecting the viewport in JS would either render the - * wrong branch on the server and correct it after hydration (a visible flash, - * and a hydration mismatch), or force the whole page to be client-only. Two - * siblings and a Tailwind breakpoint have neither problem: the browser picks - * before first paint and there is nothing to reconcile. + * Two gates, deliberately, because they answer different questions. + * + * CSS decides what is VISIBLE. Detecting the viewport in JS to pick the branch + * would either render the wrong one on the server and correct it after + * hydration (a visible flash, and a mismatch), or force the whole page to be + * client-only. Two siblings and a Tailwind breakpoint have neither problem. + * + * JS decides what is MOUNTED, which CSS cannot: `hidden` still mounts its + * subtree, so the calendar ran its availability fetch on phones for a grid + * nobody could see. `isDesktop` starts false so the server and the first + * client render agree; the children mount one effect later, and the calendar + * has a loading state for exactly that gap. */ export function DesktopOnlyNotice({ children, className, }: Readonly<{ children: React.ReactNode; className?: string }>) { + const [isDesktop, setIsDesktop] = useState(false); + + useEffect(() => { + // Tailwind's `lg`. Kept in sync by hand: a media query string cannot read + // the theme, and the alternative is measuring on every resize. + const query = window.matchMedia("(min-width: 1024px)"); + // Latches: once true it never goes back. Unmounting on a shrink would + // destroy the calendar's own slot selection while SlotPicker keeps its + // copy, so a resize, a split screen or a browser zoom leaves the footer + // claiming "N slots selected" over a grid showing none — and submitting + // sends times the user can no longer see. The CSS gate still hides the + // subtree below `lg`, which is what the notice is actually for; this only + // decides whether the fetch-on-mount ever happens. + const sync = () => { + if (query.matches) setIsDesktop(true); + }; + sync(); + query.addEventListener("change", sync); + return () => query.removeEventListener("change", sync); + }, []); + return ( <> <div className="lg:hidden flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border px-6 py-16 text-center"> @@ -27,7 +58,11 @@ export function DesktopOnlyNotice({ </p> </div> - <div className={`hidden lg:block ${className ?? ""}`}>{children}</div> + {/* Flex, not block: the calendar sizes itself with `flex-1`, which needs + a flex parent to fill. */} + <div className={`hidden lg:flex lg:flex-col ${className ?? ""}`}> + {isDesktop ? children : null} + </div> </> ); } diff --git a/components/scheduling/SafeUnifiedCalendar.tsx b/components/scheduling/SafeUnifiedCalendar.tsx index 9a4a2f033..1ffc46397 100644 --- a/components/scheduling/SafeUnifiedCalendar.tsx +++ b/components/scheduling/SafeUnifiedCalendar.tsx @@ -28,13 +28,14 @@ export function SafeUnifiedCalendar({ flex-1` on the inner element sized a child of a plain block box and the calendar stopped filling its dialog. */} <div className={cn("flex min-h-0 flex-col gap-3", className)}> - <UnifiedCalendar {...props} className="min-h-0 flex-1" /> - {/* A buyer picking a time has no use for "This booking" or "Being - moved" — those name states of an allocation they are not doing, and - one of them refers to a slot the picker does not even display. - Follows `mode` for the same reason includeAppointmentDetails does: - "allocate" is the consultant's surface, everything else is a - buyer's. */} + {/* Above the grid, not below: a key you can only reach by scrolling + past the thing it explains is backwards, and on a laptop it sat + below the fold entirely (#1064). A buyer picking a time has no use + for "This booking" or "Being moved" — those name states of an + allocation they are not doing, and one of them refers to a slot + the picker does not even display. Follows `mode` for the same + reason includeAppointmentDetails does: "allocate" is the + consultant's surface, everything else is a buyer's. */} <SlotStatusLegend keys={ props.mode === "allocate" @@ -43,6 +44,7 @@ export function SafeUnifiedCalendar({ } className="shrink-0" /> + <UnifiedCalendar {...props} className="min-h-0 flex-1" /> </div> </CalendarErrorBoundary> ); diff --git a/components/scheduling/SessionReleasePicker.tsx b/components/scheduling/SessionReleasePicker.tsx new file mode 100644 index 000000000..7eb4be7ef --- /dev/null +++ b/components/scheduling/SessionReleasePicker.tsx @@ -0,0 +1,281 @@ +"use client"; + +import React from "react"; +import { format } from "date-fns"; +import { CalendarClock, CalendarRange, Check, CheckSquare } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { toDate, type SlotLike } from "@/lib/appointments/view-model"; +import { cn } from "@/utils/tailwind"; + +/** + * Which sessions of a booking are being released, for the reschedule + * surfaces. Lifted out of the reschedule modal it replaced, unchanged in behaviour; + * `SlotPicker` shows it beside the grid rather than as a first step, because a + * page has the width the dialog did not. + */ + +export type ReleaseMode = "individual" | "multiple" | "entire"; + +export interface ReleasableSession { + slots: SlotLike[]; + startTime: Date; + endTime: Date; +} + +/** + * Sessions of a booking, newest last. One session can span several 30-minute + * slots (ADR B1), so they group by owning appointment; a slot with no + * appointment id stands alone. + */ +export function groupReleasableSessions( + slots: readonly SlotLike[], +): ReleasableSession[] { + const groups = new Map<string, SlotLike[]>(); + for (const slot of slots) { + if (slot.isTentative) continue; + const key = slot.appointmentId ?? slot.id; + const bucket = groups.get(key); + if (bucket) bucket.push(slot); + else groups.set(key, [slot]); + } + return Array.from(groups.values()) + .map((sessionSlots) => { + const sorted = [...sessionSlots].sort( + (a, b) => toDate(a.startsAt).getTime() - toDate(b.startsAt).getTime(), + ); + const last = sorted[sorted.length - 1]; + return { + slots: sorted, + startTime: toDate(sorted[0].startsAt), + endTime: toDate(last.endsAt ?? last.startsAt), + }; + }) + .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); +} + +function isWithinLeadTime(session: ReleasableSession, minLeadHours: number) { + if (minLeadHours <= 0) return false; + return ( + (session.startTime.getTime() - Date.now()) / (1000 * 60 * 60) < + minLeadHours + ); +} + +function SessionList({ + mode, + sessions, + minLeadHours, + selectedSlotIds, + onChange, +}: Readonly<{ + mode: Exclude<ReleaseMode, "entire">; + sessions: ReleasableSession[]; + minLeadHours: number; + selectedSlotIds: string[]; + onChange: React.Dispatch<React.SetStateAction<string[]>>; +}>) { + return ( + <div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-border p-2"> + {sessions.map((session) => { + const sessionSlotIds = session.slots.map((s) => s.id); + const isSelected = sessionSlotIds.every((id) => + selectedSlotIds.includes(id), + ); + const locked = isWithinLeadTime(session, minLeadHours); + + const toggle = () => { + if (locked) return; + if (mode === "individual") { + onChange(sessionSlotIds); + } else if (isSelected) { + onChange((prev) => + prev.filter((id) => !sessionSlotIds.includes(id)), + ); + } else { + onChange((prev) => + Array.from(new Set([...prev, ...sessionSlotIds])), + ); + } + }; + + return ( + <button + // The sorted list reorders when subject.slots changes; the first + // slot's id is stable across that, an index is not. + key={sessionSlotIds[0]} + type="button" + onClick={toggle} + disabled={locked} + className={cn( + "flex w-full items-center justify-between rounded-md p-2.5 text-left transition-colors", + isSelected + ? "bg-primary text-primary-foreground" + : locked + ? "cursor-not-allowed bg-muted text-muted-foreground/70" + : "bg-muted text-foreground hover:bg-muted/80", + )} + > + <div className="flex items-center gap-2"> + {mode === "multiple" && ( + <div + className={cn( + "flex h-4 w-4 items-center justify-center rounded border", + isSelected + ? "border-primary-foreground bg-primary-foreground" + : locked + ? "border-border" + : "border-muted-foreground", + )} + > + {isSelected && <Check className="h-3 w-3 text-primary" />} + </div> + )} + <div> + <div className="text-sm font-medium"> + {format(session.startTime, "EEE, d MMM yyyy")} + </div> + <div + className={cn( + "text-xs", + isSelected + ? "text-primary-foreground/70" + : "text-muted-foreground", + )} + > + {format(session.startTime, "h:mm a")} –{" "} + {format(session.endTime, "h:mm a")} + </div> + </div> + </div> + {locked && ( + <span className="rounded bg-orange-100 px-2 py-0.5 text-xs text-orange-600 dark:bg-orange-900/30 dark:text-orange-300"> + Within {minLeadHours}h + </span> + )} + </button> + ); + })} + </div> + ); +} + +const MODE_OPTIONS: { + value: ReleaseMode; + icon: typeof CalendarClock; + label: string; + hint: (sessionCount: number) => string; +}[] = [ + { + value: "individual", + icon: CalendarClock, + label: "One session", + hint: () => "Only the session you pick moves.", + }, + { + value: "multiple", + icon: CheckSquare, + label: "Several sessions", + hint: () => "Pick exactly which sessions move.", + }, + { + value: "entire", + icon: CalendarRange, + label: "Every session", + hint: (count) => `All ${count} sessions are released.`, + }, +]; + +export function SessionReleasePicker({ + sessions, + minLeadHours, + mode, + onModeChange, + selectedSlotIds, + onSelectionChange, +}: Readonly<{ + sessions: ReleasableSession[]; + minLeadHours: number; + mode: ReleaseMode; + onModeChange: (mode: ReleaseMode) => void; + selectedSlotIds: string[]; + onSelectionChange: React.Dispatch<React.SetStateAction<string[]>>; +}>) { + const handleModeChange = (next: ReleaseMode) => { + onModeChange(next); + if (next === "entire") { + onSelectionChange([]); + return; + } + if (next === "individual") { + // Narrowing from "several" must leave exactly one session ticked, or the + // submit sits disabled with every box still visibly checked. + const alreadyPicked = sessions.find((session) => + session.slots.some((slot) => selectedSlotIds.includes(slot.id)), + ); + // NOT sessions[0]: the list is earliest-first, so the first entry is the + // one most likely to be inside the lead window — and SessionList + // disables that row, leaving it ticked and untickable. The submit stays + // enabled (it only counts selected ids), so the server rejects a release + // the user cannot change. + const target = + alreadyPicked ?? + sessions.find((session) => !isWithinLeadTime(session, minLeadHours)); + onSelectionChange(target ? target.slots.map((s) => s.id) : []); + } + }; + + return ( + <div className="space-y-3"> + <Label className="text-sm font-medium text-foreground"> + What is moving? + </Label> + + <RadioGroup + value={mode} + onValueChange={(value) => handleModeChange(value as ReleaseMode)} + className="grid gap-2 sm:grid-cols-3" + > + {MODE_OPTIONS.map((option) => ( + <div + key={option.value} + className={cn( + "flex items-start gap-2 rounded-lg border p-3 transition-colors", + mode === option.value + ? "border-primary bg-muted" + : "border-border hover:border-foreground/30", + )} + > + <RadioGroupItem + value={option.value} + id={`release-${option.value}`} + className="mt-1" + /> + <Label + htmlFor={`release-${option.value}`} + className="flex-1 cursor-pointer" + > + <span className="flex items-center gap-2 font-medium text-foreground"> + <option.icon className="h-4 w-4" /> + {option.label} + </span> + <span className="mt-1 block text-xs text-muted-foreground"> + {option.hint(sessions.length)} + </span> + </Label> + </div> + ))} + </RadioGroup> + + {mode !== "entire" && ( + <SessionList + mode={mode} + sessions={sessions} + minLeadHours={minLeadHours} + selectedSlotIds={selectedSlotIds} + onChange={onSelectionChange} + /> + )} + </div> + ); +} diff --git a/components/scheduling/SlotPicker.tsx b/components/scheduling/SlotPicker.tsx new file mode 100644 index 000000000..c4f1936ac --- /dev/null +++ b/components/scheduling/SlotPicker.tsx @@ -0,0 +1,214 @@ +"use client"; + +import React from "react"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DesktopOnlyNotice } from "@/components/scheduling/DesktopOnlyNotice"; +import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar"; +import { + groupReleasableSessions, + SessionReleasePicker, + type ReleaseMode, +} from "@/components/scheduling/SessionReleasePicker"; +import type { + SlotPickerPolicy, + SlotPickerSubject, +} from "@/components/scheduling/slot-picker-policy"; +import { cn } from "@/utils/tailwind"; + +/** + * The one surface for choosing times, shared by allocation, both reschedule + * roles and a consultant's own event timings. + * + * It takes a policy object rather than a prop per difference. The four callers + * disagree about lead time, whether anything is being released and who owns + * the submit; all of that is data in `slot-picker-policy.ts`, so nothing here + * branches on which caller it is. + */ + +export interface SlotPickerProps { + policy: SlotPickerPolicy; + subject: SlotPickerSubject; + /** A submit is in flight; the policy's owner knows, this component does not. */ + isSubmitting?: boolean; + /** Back out. Also wired to the allocate grid's own Cancel button. */ + onCancel?: () => void; + className?: string; +} + +export function SlotPicker({ + policy, + subject, + isSubmitting = false, + onCancel, + className, +}: Readonly<SlotPickerProps>) { + const sessions = React.useMemo( + () => groupReleasableSessions(subject.slots ?? []), + [subject.slots], + ); + + const [releaseMode, setReleaseMode] = React.useState<ReleaseMode>("entire"); + const [selectedSlotIds, setSelectedSlotIds] = React.useState<string[]>([]); + const [proposedSlots, setProposedSlots] = React.useState< + { startsAt: string; endsAt: string }[] + >([]); + + /** A one-session booking has nothing to choose between. */ + const showReleaseStep = policy.showReleasedSlots && sessions.length > 1; + const picksSpecificSessions = showReleaseStep && releaseMode !== "entire"; + + /** A picking mode is active but nothing is ticked — there is no request yet. */ + const selectionIncomplete = + picksSpecificSessions && selectedSlotIds.length === 0; + + const releasedSlotIds = picksSpecificSessions + ? selectedSlotIds + : /* Every session; the API reads an absent list as "all of them". */ + undefined; + + const selectedSessionCount = React.useMemo( + () => + sessions.filter((session) => + session.slots.every((slot) => selectedSlotIds.includes(slot.id)), + ).length, + [sessions, selectedSlotIds], + ); + + /** How many times to ask for, so the hint matches what is actually moving. */ + const sessionsBeingMoved = picksSpecificSessions + ? Math.max(selectedSessionCount, 1) + : Math.max(sessions.length, 1); + + const submit = (withTimes: boolean) => { + void policy.onSubmit({ + slotIds: releasedSlotIds, + proposedSlots: + withTimes && proposedSlots.length > 0 ? proposedSlots : undefined, + }); + }; + + const isSelectMode = policy.calendarMode === "select"; + + return ( + <DesktopOnlyNotice className={cn("min-h-0 gap-4", className)}> + {policy.minLeadHours > 0 && ( + <div className="shrink-0 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20"> + <p className="text-xs text-amber-800 dark:text-amber-300"> + <strong>Note:</strong> sessions cannot be moved within{" "} + {policy.minLeadHours} hours of their start time, and rescheduling + is not refunded. + </p> + </div> + )} + + {showReleaseStep && ( + <div className="shrink-0"> + <SessionReleasePicker + sessions={sessions} + minLeadHours={policy.minLeadHours} + mode={releaseMode} + onModeChange={setReleaseMode} + selectedSlotIds={selectedSlotIds} + onSelectionChange={setSelectedSlotIds} + /> + </div> + )} + + <p className="shrink-0 text-sm text-muted-foreground"> + {isSelectMode && ( + <span className="font-medium text-foreground"> + {sessionsBeingMoved === 1 + ? "Pick a time. " + : `Pick ${sessionsBeingMoved} times. `} + </span> + )} + {policy.pickerHint} + </p> + + <SafeUnifiedCalendar + className="min-h-0 flex-1" + consultantId={subject.consultantProfileId} + eventType={subject.eventType} + eventId={subject.eventId} + consulteeUserId={subject.counterpartUserId} + mode={policy.calendarMode} + sessionDurationInHours={subject.sessionDurationInHours} + durationInHours={subject.durationInHours} + sessionsPerWeek={subject.sessionsPerWeek} + durationInMonths={subject.durationInMonths} + totalSessions={subject.totalSessions} + schedulingTimezone={subject.schedulingTimezone} + allowedStart={subject.allowedStart} + allowedEnd={subject.allowedEnd} + // Fresh allocations only: a partial reschedule legitimately keeps + // confirmed slots and must not trip the guard. + initialAllocation={ + policy.appliesInitialAllocationGuard + ? !subject.hasReleasedSlots + : undefined + } + showAllocationButtons={!isSelectMode} + onSlotsSelected={(slots) => + setProposedSlots( + slots.map((slot) => ({ + startsAt: slot.startTime.toISOString(), + endsAt: slot.endTime.toISOString(), + })), + ) + } + onAllocationComplete={() => void policy.onSubmit({})} + onAllocationConflict={policy.onConflict} + onClose={onCancel} + /> + + {/* Only "select" needs a footer — the allocate grid renders its own. */} + {isSelectMode && ( + <div className="flex shrink-0 flex-wrap items-center justify-end gap-2"> + {proposedSlots.length > 0 && ( + <p className="mr-auto text-sm text-muted-foreground"> + {proposedSlots.length} slot + {proposedSlots.length === 1 ? "" : "s"} selected. + </p> + )} + + {onCancel && ( + <Button variant="outline" onClick={onCancel} disabled={isSubmitting}> + Cancel + </Button> + )} + + {/* Naming a time is an OPTION, never a requirement. Releasing without + one hands the counterparty a request to place, which is how every + reschedule worked before proposals existed — so this stays on + every reschedule surface and at every session count. */} + {policy.allowReleaseWithoutTime && ( + <Button + variant="outline" + onClick={() => submit(false)} + disabled={isSubmitting || selectionIncomplete} + > + Any time works + </Button> + )} + + <Button + onClick={() => submit(true)} + disabled={ + isSubmitting || selectionIncomplete || proposedSlots.length === 0 + } + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Processing... + </> + ) : ( + policy.submitLabel + )} + </Button> + </div> + )} + </DesktopOnlyNotice> + ); +} diff --git a/components/scheduling/SlotStatusLegend.tsx b/components/scheduling/SlotStatusLegend.tsx index 55f87c18d..4dd301261 100644 --- a/components/scheduling/SlotStatusLegend.tsx +++ b/components/scheduling/SlotStatusLegend.tsx @@ -37,10 +37,13 @@ export function SlotStatusLegend({ className="flex items-center gap-1.5 text-xs text-muted-foreground" title={token.hint} > + {/* No border-COLOUR of its own: `swatchClassName` carries the + cell's, and a hardcoded one here would win or lose by + stylesheet order rather than by intent (#1064). */} <span aria-hidden className={cn( - "h-3 w-3 shrink-0 rounded-sm border border-black/10", + "h-3 w-3 shrink-0 rounded-sm border", token.swatchClassName, )} /> diff --git a/components/scheduling/UnifiedCalendar.tsx b/components/scheduling/UnifiedCalendar.tsx index cefc13a51..57f3cdce0 100644 --- a/components/scheduling/UnifiedCalendar.tsx +++ b/components/scheduling/UnifiedCalendar.tsx @@ -56,6 +56,11 @@ import { notEnoughConsecutive, } from "@/lib/scheduling/allocationMessages"; import { useToast } from "@/hooks/use-toast"; +import { + SLOT_STATUS_TOKENS, + resolveSlotStatusKey, + slotCellClassName, +} from "@/lib/scheduling/slot-status-tokens"; /** * Small pure helpers for clarity and reuse. These do not cause side effects. @@ -440,7 +445,10 @@ export function UnifiedCalendar({ // allocated slots appear correctly. await Promise.all([refetchEventSlots(), refetchAvailability()]); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "client" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "client" } }, + ); console.error( "Error refetching calendar data after allocation:", error, @@ -539,7 +547,7 @@ export function UnifiedCalendar({ // re-renders. // // The callback is held in a ref and kept out of the dependency array on - // purpose. RescheduleSessionsModal passes an inline arrow, so depending on + // purpose. SlotPicker passes an inline arrow, so depending on // it meant a new identity every render: effect fires, parent setState, // re-render, new identity, fire again, forever — React error #185. Nothing // caught it because "select" is the consultee picker's mode and the picker @@ -899,17 +907,33 @@ export function UnifiedCalendar({ (allowedStart && intervalEnd <= allowedStart) || (allowedEnd && intervalStart >= allowedEnd); - // Fast-exit: avoid rendering a clickable button for cells that have no - // availability **and** are disabled (e.g. past date). Rendering a - // lightweight placeholder saves performance. + // Fast-exit: a cell with nothing published, nothing booked and already + // past is never interactive, so it renders as a plain block instead of a + // button. It paints from the same `unavailable` token as every other + // dead cell — it used to be its own gray-100/gray-200 pair, which is how + // past days and future days ended up disagreeing about what "nothing + // here" looks like (#1064). if (!status.isAvailable && !status.isBooked && status.isInPast) { return ( - <div className="h-8 w-full bg-gray-100 border border-gray-200 rounded-sm" /> + <div className={slotCellClassName("unavailable", { faded: true })} /> ); } - let cellClassName = - "h-8 w-full relative transition-colors duration-75 ease-in-out border border-transparent rounded-sm text-[10px] leading-tight px-1 py-0.5 disabled:pointer-events-none disabled:opacity-50"; + const statusKey = resolveSlotStatusKey({ + isSelected: isCurrentlySelected, + isThisEventSlot: isCurrentEventSlot, + isRescheduling: isCurrentEventTentative, + isBookedForDisplay: status.isBookedForDisplay, + isPartiallyBooked: status.isPartiallyBooked, + isAvailable: status.isAvailable, + isInPast: status.isInPast, + }); + // ONE token, appended once, on top of a base string that carries no + // border-COLOUR. Every branch below adds cursor/opacity only — a second + // border-color utility on this element is structurally impossible now, + // which is what the reverted attempt got wrong (#1064). + let cellClassName = slotCellClassName(statusKey); + let buttonText = ""; const showTooltip = ((status.isBookedForDisplay || status.isPartiallyBooked) && @@ -917,62 +941,51 @@ export function UnifiedCalendar({ isCurrentEventSlot; if (isCurrentlySelected) { - // Dark green for manually selected slots - cellClassName += - " bg-green-700 text-white hover:bg-green-800 border-green-900"; - buttonText = "Selected"; + cellClassName += " cursor-pointer"; + buttonText = SLOT_STATUS_TOKENS.selected.label; } else if (isCurrentEventSlot) { - // Black for this event's already booked slots - cellClassName += - " bg-black text-white cursor-pointer hover:bg-gray-900 border-gray-800"; + cellClassName += " cursor-pointer"; cellClassName += status.isInPast ? " opacity-60" : ""; - buttonText = status.isInPast ? "Past Session" : "This Event"; + buttonText = status.isInPast + ? "Past session" + : SLOT_STATUS_TOKENS.thisEvent.label; } else if (isCurrentEventTentative) { - // Amber for THIS event's slot being rescheduled — distinct from the gray - // "Booked" used for foreign appointments. It's the consultant's own slot - // pending a new time, not someone else's booking. - cellClassName += - " bg-amber-400 text-amber-900 cursor-pointer hover:bg-amber-500 border-amber-500"; + // THIS event's slot being rescheduled — distinct from the "Booked" + // state used for foreign appointments below. + cellClassName += " cursor-pointer"; cellClassName += status.isInPast ? " opacity-50" : ""; - buttonText = "Rescheduling"; + buttonText = SLOT_STATUS_TOKENS.rescheduling.label; } else if (status.isBookedForDisplay) { - // Grey for other appointments - cellClassName += - " bg-slate-400 text-slate-800 cursor-pointer hover:bg-slate-500"; + cellClassName += " cursor-pointer"; cellClassName += status.isInPast ? " opacity-50" : ""; - buttonText = "Booked"; + buttonText = SLOT_STATUS_TOKENS.fullyBooked.label; } else if (status.isPartiallyBooked) { - cellClassName += - " bg-yellow-400 text-yellow-900 cursor-pointer hover:bg-yellow-500"; + cellClassName += " cursor-pointer"; cellClassName += status.isInPast ? " opacity-50" : ""; - buttonText = "Partially Booked"; + buttonText = SLOT_STATUS_TOKENS.partiallyBooked.label; } else if (status.isAvailable) { - // Available slot - check if past for fading if (status.isInPast) { // A past slot is NOT available, whatever the consultant published. // It used to render green and say "Available", differing from a real // opening only by opacity — so a picker opened on the current week // offered times that had already happened. Still clickable, because // the toast explains why; it just no longer claims to be bookable. - cellClassName += - " bg-slate-100 text-slate-400 border-slate-200 cursor-pointer hover:bg-slate-200"; + cellClassName += " cursor-pointer"; buttonText = isOutsideAllowedRange ? "Outside Period" : "Past"; } else { - // Future available slot - unfaded, clickable - cellClassName += - " bg-green-300 text-green-950 cursor-pointer hover:bg-green-400 border-green-400"; + cellClassName += " cursor-pointer"; if (eventType === "consultation") { cellClassName += " hover:shadow-md"; } - buttonText = isOutsideAllowedRange ? "Outside Period" : "Available"; + buttonText = isOutsideAllowedRange + ? "Outside Period" + : SLOT_STATUS_TOKENS.available.label; } } else { - if (status.isInPast) { - cellClassName += - " bg-gray-300 text-gray-700 cursor-not-allowed opacity-70"; - } else { - cellClassName += " bg-slate-200 cursor-not-allowed"; - } + // Genuinely unpublished interval — never carried button text, before + // or after this change; only the colour source moved. + cellClassName += " cursor-not-allowed"; + cellClassName += status.isInPast ? " opacity-70" : ""; } // Only disable in view mode or if no availability at all (gray slots) @@ -1414,7 +1427,10 @@ export function UnifiedCalendar({ return `${formatDurationLabel(totalMinutes)} selected`; return `${formatDurationLabel(chosenMinutes)} of ${formatDurationLabel(totalMinutes)} selected`; } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "client" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "client" } }, + ); console.error("Error calculating footer stats:", error); if (error instanceof Error) { return error.message; diff --git a/components/scheduling/slot-picker-policy.ts b/components/scheduling/slot-picker-policy.ts new file mode 100644 index 000000000..2f9d9ceee --- /dev/null +++ b/components/scheduling/slot-picker-policy.ts @@ -0,0 +1,180 @@ +import type { SlotLike } from "@/lib/appointments/view-model"; + +/** + * The four surfaces that place slots on a consultant's calendar, expressed as + * data. + * + * They differ in how much notice a session needs, whether anything is being + * released, and who owns the submit — not in structure. Threading those as + * boolean props through one component is how `EventPlanner` reached 3,890 + * lines, so the differences live here and `SlotPicker` only ever reads fields. + */ + +export type SlotPickerPolicyKind = + | "ALLOCATE" + | "RESCHEDULE_CONSULTEE" + | "RESCHEDULE_CONSULTANT" + | "MANAGE_TIMINGS"; + +export interface SlotPickerSubmission { + /** Sessions being released. Undefined = every session of the booking. */ + slotIds?: string[]; + /** Times asked for. Absent = "any time works". */ + proposedSlots?: { startsAt: string; endsAt: string }[]; +} + +export interface SlotPickerPolicy { + kind: SlotPickerPolicyKind; + /** + * Notice a session needs before it may be moved. Zero is not "no rule": a + * pending request was never scheduled and a consultant's own event instance + * strands nobody, so neither has a counterparty to give notice to. + */ + minLeadHours: number; + /** + * Whether this surface releases sessions that already exist. Showing + * released slots and offering a release step are the same condition, so it + * is one field rather than two that can disagree. + */ + showReleasedSlots: boolean; + /** + * Primary submit copy. Read only when `calendarMode` is "select" — the + * allocate grid ships its own footer and labels its own buttons. + */ + submitLabel: string; + /** + * Release WITHOUT naming a time. Predates proposals and is still the whole + * contract when the counterparty should be the one choosing, so it survives + * on every reschedule surface and at every session count. + */ + allowReleaseWithoutTime: boolean; + /** + * "allocate" hands the submit to the grid's own buttons, which POST the + * allocation themselves; "select" collects times and leaves the POST here. + */ + calendarMode: "select" | "allocate"; + /** + * Refuse the allocation when the event already holds confirmed slots + * (#837). Fresh allocations only — a partial reschedule legitimately has + * some, which is why the subject supplies the "is it fresh" half. + */ + appliesInitialAllocationGuard: boolean; + /** One line above the grid; the only place the two roles read differently. */ + pickerHint: string; + onSubmit: (submission: SlotPickerSubmission) => void | Promise<void>; + /** The grid reported a 409 — someone else allocated this first. */ + onConflict?: () => void; +} + +/** + * What is being placed. Pure scheduling data: the surrounding page or dialog + * owns its own title and description, so no copy lives here. + */ +export interface SlotPickerSubject { + /** Whose availability grid is drawn. */ + consultantProfileId: string; + eventType: "consultation" | "subscription" | "webinar" | "class"; + /** Consultation/Subscription/Webinar/Class id — what the grid keys on. */ + eventId?: string; + /** + * The other party, so the grid greys out times they are already booked at: + * allocation rejects those, and painting them green only fails at submit. + * Absent under MANAGE_TIMINGS — a consultant's own instance has no + * counterparty to conflict with. + */ + counterpartUserId?: string; + /** Consultations and webinars: the length of the one session. */ + durationInHours?: number; + /** Subscriptions and classes: the length of each session. */ + sessionDurationInHours?: number; + sessionsPerWeek?: number; + durationInMonths?: number; + totalSessions?: number; + /** Defines the limit day/week buckets (ADR B9). */ + schedulingTimezone?: string; + /** The plan's scheduling period; the grid clamps selection to it. */ + allowedStart?: Date; + allowedEnd?: Date; + /** Existing sessions, for the release step. */ + slots?: SlotLike[]; + /** A session is already released and awaiting a new time. */ + hasReleasedSlots?: boolean; +} + +/** Lead time the reschedule API enforces server-side; mirrored for the UI. */ +const RESCHEDULE_MIN_LEAD_HOURS = 24; + +type PolicyHandlers = Pick<SlotPickerPolicy, "onSubmit"> & + Partial<Pick<SlotPickerPolicy, "onConflict">>; + +/** A consultant placing a pending request that has never been scheduled. */ +export function allocatePolicy(handlers: PolicyHandlers): SlotPickerPolicy { + return { + kind: "ALLOCATE", + minLeadHours: 0, + showReleasedSlots: false, + submitLabel: "Allocate slots", + allowReleaseWithoutTime: false, + calendarMode: "allocate", + appliesInitialAllocationGuard: true, + pickerHint: + "Choose the times for this booking. Green is free for both of you; anything else is already taken.", + ...handlers, + }; +} + +/** A consultee moving a live booking of theirs. */ +export function rescheduleConsulteePolicy( + handlers: PolicyHandlers, +): SlotPickerPolicy { + return { + kind: "RESCHEDULE_CONSULTEE", + minLeadHours: RESCHEDULE_MIN_LEAD_HOURS, + showReleasedSlots: true, + submitLabel: "Request this time", + allowReleaseWithoutTime: true, + calendarMode: "select", + appliesInitialAllocationGuard: false, + pickerHint: + "Green means your consultant is free — choosing one of those confirms straight away. Anything else is sent to them to approve.", + ...handlers, + }; +} + +/** A consultant moving a booking they deliver. */ +export function rescheduleConsultantPolicy( + handlers: PolicyHandlers, +): SlotPickerPolicy { + return { + kind: "RESCHEDULE_CONSULTANT", + minLeadHours: RESCHEDULE_MIN_LEAD_HOURS, + showReleasedSlots: true, + submitLabel: "Propose this time", + allowReleaseWithoutTime: true, + calendarMode: "select", + appliesInitialAllocationGuard: false, + // Never auto-confirms: publishing availability is standing consent to be + // booked inside it, but merely being free is not consent to be moved. + pickerHint: + "Green means you are free. Whatever you propose is sent to the consultee to accept.", + ...handlers, + }; +} + +/** A consultant setting the times of their own event instance. */ +export function manageTimingsPolicy( + handlers: PolicyHandlers, +): SlotPickerPolicy { + return { + kind: "MANAGE_TIMINGS", + minLeadHours: 0, + showReleasedSlots: false, + submitLabel: "Save timings", + allowReleaseWithoutTime: false, + calendarMode: "allocate", + appliesInitialAllocationGuard: false, + pickerHint: + "Choose when this runs. Green is free on your calendar; anything else is already taken.", + ...handlers, + }; +} diff --git a/hooks/scheduling/useCalendarData.ts b/hooks/scheduling/useCalendarData.ts index 4ed4c248b..8714e07e8 100644 --- a/hooks/scheduling/useCalendarData.ts +++ b/hooks/scheduling/useCalendarData.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useEffect } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { reportSentryError } from "@/lib/observability/report"; import { startOfWeek, @@ -242,6 +242,11 @@ export function useCalendarData( >({}); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); + // Flicker fix — a fetch for week N can resolve after the user has already + // navigated to week N+1 (no AbortController here). Only the response + // matching the MOST RECENTLY issued request may commit state; a stale one + // is silently dropped instead of repainting the wrong week. + const availabilityRequestIdRef = useRef(0); // PERFORMANCE: Computed available slots from raw data using useMemo const availableSlots = useMemo((): TimeSlot[] => { @@ -290,6 +295,8 @@ export function useCalendarData( const fetchAvailabilitySlots = useCallback(async (): Promise<void> => { if (!consultantId) return; + const requestId = ++availabilityRequestIdRef.current; + try { // Always start from the view's natural start so pre-period weeks have // availability data (allows "Outside Period" label on consultant's actual @@ -317,6 +324,10 @@ export function useCalendarData( consulteeUserId, ); + // A newer request was issued (user moved on) while this one was in + // flight — its result is stale, discard rather than repaint. + if (requestId !== availabilityRequestIdRef.current) return; + // Defensive: Validate data structure before using if (!data || typeof data !== "object") { console.warn( @@ -354,6 +365,10 @@ export function useCalendarData( setRawAvailabilitySlots(validatedData); } catch (error) { + // A stale request's failure must not clobber the error state of + // whatever the user has since navigated to. + if (requestId !== availabilityRequestIdRef.current) return; + console.error("Error fetching availability slots:", error); // Not captured here — AllocationService.fetchAvailabilitySlots already // reports this exact error (see fetchConsultantDetails above). @@ -720,13 +735,12 @@ export function useCalendarData( setLoading(false); }); } - }, [ - autoLoad, - consultantId, - consultantDetails, - weeklySlotCount, - fetchAvailabilitySlots, - ]); + // consultantDetails/weeklySlotCount deliberately excluded: both are SET + // BY this effect's own fetch, so listing them re-fires it every time the + // fetch it just ran completes — a self-triggering refetch loop on every + // week navigation (read via closure for isInitialLoad, not as triggers). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoLoad, consultantId, fetchAvailabilitySlots]); // ENHANCEMENT: Individual refetch functions for granular control const refetchConsultant = useCallback(async (): Promise<void> => { diff --git a/lib/appointments/slots.ts b/lib/appointments/slots.ts index 2abc8e637..ea1fce737 100644 --- a/lib/appointments/slots.ts +++ b/lib/appointments/slots.ts @@ -31,6 +31,33 @@ export function isDeadSlot(slot: { ); } +/** + * Slot-derived half of "may this booking be rescheduled". + * + * The status, role and route checks genuinely differ per side and stay with + * their adapter. These three do not, and they drifted: the consultant's menu + * offered Reschedule on a booking with nothing allocated and on one already + * awaiting a new time, both of which the API then rejects. + */ +export function slotsAllowReschedule( + slots: Array<{ + isTentative?: boolean | null; + completionStatus?: string | null; + }>, +): boolean { + // An APPROVED booking with nothing allocated ("Not scheduled · 0/0") has no + // time to move, and the proposal window is derived from the earliest released + // session — so this fails with PROPOSAL_WINDOW_CLOSED rather than opening an + // empty picker. + if (slots.length === 0) return false; + // Tentative means the request is still awaiting allocation, not booked. + if (slots[0]?.isTentative) return false; + // A released slot awaiting a new time IS the open reschedule: at most one may + // be live per appointment (the nullable-unique openForAppointmentId), so + // offering the action again only earns a 409. + return !slots.some((slot) => slot.completionStatus === "RESCHEDULED"); +} + function slotTimes(slot: SlotLike): { start: number; end: number } { const start = toDate(slot.startsAt).getTime(); const endsAt = toDateOrNull(slot.endsAt ?? null); diff --git a/lib/booking/plan-owners.ts b/lib/booking/plan-owners.ts new file mode 100644 index 000000000..3c14d3f98 --- /dev/null +++ b/lib/booking/plan-owners.ts @@ -0,0 +1,47 @@ +/** + * Who may act on a booking as its consultant. + * + * This was written out by hand at every route that needed it, and the copies + * drifted: the manage-timings read omitted `trialSession`, so a consultant + * opening the timings of their own trial was refused by a check the reschedule + * route passed. Two hand-maintained ownership predicates is one too many — + * the failure mode is silent, and it is an authorization decision. + * + * Collaborators count only for webinars and classes. Consultation and + * subscription plans have no collaborator relation on this read, and widening + * the set here would grant access the other surfaces do not. + */ + +interface PlanOwner { + consultantProfile?: { id: string } | null; +} + +interface PlanWithCollaborators extends PlanOwner { + collaborators?: { consultantProfile?: { id: string } | null }[] | null; +} + +export interface AppointmentPlanOwnership { + consultation?: { consultationPlan?: PlanOwner | null } | null; + subscription?: { subscriptionPlan?: PlanOwner | null } | null; + webinar?: { webinarPlan?: PlanWithCollaborators | null } | null; + class?: { classPlan?: PlanWithCollaborators | null } | null; + trialSession?: { subscriptionPlan?: PlanOwner | null } | null; +} + +export function resolvePlanOwnerIds( + appointment: AppointmentPlanOwnership, +): string[] { + return [ + appointment.consultation?.consultationPlan?.consultantProfile?.id, + appointment.subscription?.subscriptionPlan?.consultantProfile?.id, + appointment.webinar?.webinarPlan?.consultantProfile?.id, + appointment.class?.classPlan?.consultantProfile?.id, + appointment.trialSession?.subscriptionPlan?.consultantProfile?.id, + ...(appointment.webinar?.webinarPlan?.collaborators ?? []).map( + (collaborator) => collaborator.consultantProfile?.id, + ), + ...(appointment.class?.classPlan?.collaborators ?? []).map( + (collaborator) => collaborator.consultantProfile?.id, + ), + ].filter((id): id is string => Boolean(id)); +} diff --git a/lib/booking/reschedule-auto-confirm.ts b/lib/booking/reschedule-auto-confirm.ts index cc114a715..74987f60e 100644 --- a/lib/booking/reschedule-auto-confirm.ts +++ b/lib/booking/reschedule-auto-confirm.ts @@ -60,105 +60,43 @@ export async function tryAutoConfirmProposal( if (!mayAutoConfirm(request.initiatorRole)) { return { confirmed: false, reason: "CONSULTANT_INITIATED" }; } - if (request.proposedSlots.length !== request.releasedSlotIds.length) { - return { confirmed: false, reason: "COUNT_MISMATCH" }; - } - - // Snapshot the originals in memory so a rejected proposal can be put back - // exactly as it was. A reschedule never rewrites startsAt, so these rows still - // hold the times the consultee is trying to move away from. - const originals = await prisma.slotOfAppointment.findMany({ - where: { id: { in: request.releasedSlotIds } }, - orderBy: { startsAt: "asc" }, - select: { - id: true, - startsAt: true, - endsAt: true, - isTentative: true, - completionStatus: true, - }, - }); - - if (originals.length !== request.proposedSlots.length) { - return { confirmed: false, reason: "SLOTS_MISSING" }; - } - - // Pair by chronological order: the Nth-earliest released session takes the - // Nth-earliest proposed time. - const pairs = originals.map((slot, i) => ({ - slot, - proposed: request.proposedSlots[i], - })); - - try { - await prisma.$transaction(async (tx) => { - for (const { slot, proposed } of pairs) { - await tx.slotOfAppointment.update({ - where: { id: slot.id }, - data: { - startsAt: proposed.startsAt, - endsAt: proposed.endsAt, - // Back to SCHEDULED so the requested-mode guard (which refuses to - // reuse times that are still awaiting reschedule) does not block the - // very times we just wrote. Still tentative — allocation confirms. - completionStatus: "SCHEDULED", - }, - }); - } - }); - } catch (err) { - reportSentryError(err, { - subsystem: "bookings", - op: "reschedule-auto-confirm", - extra: { - phase: "stamp", - rescheduleRequestId, - releasedSlotIds: request.releasedSlotIds, - }, - }); - throw err; - } + // Deliberately NOT comparing proposed count to released count. The allocator + // is handed the times as a set and validates them as one; pairing them + // one-to-one only ever made sense while we were writing each proposed time + // onto a specific released row, which we no longer do. const result = await SlotAllocationService.allocate({ eventType, eventId, - mode: "requested", + // The proposed times go STRAIGHT to the allocator. Nothing is written until + // it commits. + // + // This used to stamp the times onto the released slot rows, run the + // allocator in "requested" mode so it would read them back, and restore the + // originals from an in-memory snapshot if validation rejected them. Two + // ways that broke: the finalize step below ran in its own transaction, so + // failing it left a confirmed booking whose proposal never closed — + // openForAppointmentId still set, blocking every later reschedule of that + // appointment. And a crash anywhere in between left the rows holding + // proposed times with the originals only ever in RAM, unrecoverable. + // + // Manual mode already accepts explicit times, and on a reschedule + // deleteExistingAppointments removes only TENTATIVE slots — which is + // exactly the released ones — so confirmed sessions elsewhere in the + // booking survive untouched. + mode: "manual", + slots: request.proposedSlots.map((p) => p.startsAt.toISOString()), + // Consultant-wide lock: these times were not picked per-day by a human, so + // the day-sharded manual key would let two concurrent confirmations each + // pass the per-week cap on a stale count (#860 shards for throughput; GiST + // backstops overlap, but a cap is a count, not an overlap). + wideLock: true, }); if (!result.success) { - // Put the booking back the way the consultee left it and let the consultant - // decide. Nothing was confirmed, so there is no half-applied state — unless - // this restore itself fails, in which case the slots are stuck holding the - // rejected proposed times with no pending allocation to justify them. That - // is a corrupted booking with no other trace, so it always reports. - try { - await prisma.$transaction(async (tx) => { - for (const { slot } of pairs) { - await tx.slotOfAppointment.update({ - where: { id: slot.id }, - data: { - startsAt: slot.startsAt, - endsAt: slot.endsAt, - isTentative: slot.isTentative, - completionStatus: slot.completionStatus, - }, - }); - } - }); - } catch (err) { - reportSentryError(err, { - subsystem: "bookings", - op: "reschedule-auto-confirm", - level: "fatal", - extra: { - phase: "restore", - rescheduleRequestId, - releasedSlotIds: request.releasedSlotIds, - originalSlotIds: pairs.map((p) => p.slot.id), - }, - }); - throw err; - } + // Nothing was written, so there is nothing to undo. The released slots are + // untouched and still carry their original times; the request stays open + // for the consultant to answer. return { confirmed: false, reason: result.errorCode ?? "VALIDATION_FAILED", @@ -178,11 +116,9 @@ export async function tryAutoConfirmProposal( }); } catch (err) { // A lost race (the proposal was answered or expired concurrently) is the - // ordinary outcome this CAS guard models by throwing — not an error, but - // still reported at info/expected so its rate is visible (full-coverage - // policy). Anything else means the reallocation above already succeeded - // but the proposal's own bookkeeping never caught up, which is worth - // knowing at the default fault level. + // ordinary outcome this CAS guard models by throwing. Anything else means + // the reallocation above already succeeded but the proposal's bookkeeping + // never caught up — the booking is correct, its paperwork is not. const isLostRace = err instanceof IllegalTransitionError; reportSentryError(err, { subsystem: "bookings", diff --git a/lib/booking/reschedule-proposals.ts b/lib/booking/reschedule-proposals.ts index 4964c8826..6c02edc12 100644 --- a/lib/booking/reschedule-proposals.ts +++ b/lib/booking/reschedule-proposals.ts @@ -17,10 +17,7 @@ * database; the route owns the writes. */ -import type { - AppointmentsType, - RescheduleInitiatorRole, -} from "@prisma/client"; +import type { AppointmentsType, RescheduleInitiatorRole } from "@prisma/client"; /** Ceiling on how long an unanswered proposal may sit. */ export const PROPOSAL_MAX_LIFETIME_HOURS = 72; @@ -61,7 +58,9 @@ export function computeProposalExpiry( const mustResolveBy = new Date( earliest.getTime() - PROPOSAL_RESOLVE_BEFORE_SESSION_HOURS * HOUR_MS, ); - const lifetimeCap = new Date(now.getTime() + PROPOSAL_MAX_LIFETIME_HOURS * HOUR_MS); + const lifetimeCap = new Date( + now.getTime() + PROPOSAL_MAX_LIFETIME_HOURS * HOUR_MS, + ); const expiry = mustResolveBy < lifetimeCap ? mustResolveBy : lifetimeCap; return expiry <= now ? null : expiry; @@ -75,7 +74,9 @@ export function computeProposalExpiry( * Landing inside published availability and finding both calendars free is * necessary too, but that is the validator's job, not this predicate's. */ -export function mayAutoConfirm(initiatorRole: RescheduleInitiatorRole): boolean { +export function mayAutoConfirm( + initiatorRole: RescheduleInitiatorRole, +): boolean { return initiatorRole === "CONSULTEE"; } @@ -90,7 +91,9 @@ export function supportsProposals( // from the row and may not find one. appointmentType: AppointmentsType | null | undefined, ): boolean { - return appointmentType === "CONSULTATION" || appointmentType === "SUBSCRIPTION"; + return ( + appointmentType === "CONSULTATION" || appointmentType === "SUBSCRIPTION" + ); } /** @@ -104,9 +107,14 @@ export function proposalCountMatches( return releasedSlotCount === proposedSlotCount; } -/** 1 = the opening proposal, 2 = the single permitted counter. */ -export const MAX_PROPOSAL_ROUNDS = 2; - -export function mayCounter(currentRound: number): boolean { - return currentRound < MAX_PROPOSAL_ROUNDS; -} +/* + * There is deliberately no counter-round. + * + * MAX_PROPOSAL_ROUNDS and mayCounter lived here, and the COUNTERED status is + * still in the enum and the transition map — but nothing ever wrote it. The + * round-2 path was specified and never built, so removing it costs nothing and + * leaves one fewer half-implemented state to reason about. + * + * Propose -> accept or decline is the whole flow. A decline already falls back + * to the consultant allocating, so nothing dead-ends without it. + */ diff --git a/lib/booking/reschedule-withdraw.ts b/lib/booking/reschedule-withdraw.ts new file mode 100644 index 000000000..8c21abd31 --- /dev/null +++ b/lib/booking/reschedule-withdraw.ts @@ -0,0 +1,142 @@ +import prisma from "@/lib/prisma"; +import { reportSentryError } from "@/lib/observability/report"; +import { + RESCHEDULE_OPEN_STATUSES, + transitionConsultationRequest, + transitionRescheduleRequest, +} from "@/lib/booking/transitions"; +import { IllegalTransitionError } from "@/lib/enterprise/transitions"; + +/** + * The initiator takes their own reschedule back, and the booking returns to + * exactly what it was. + * + * ONLY withdrawal restores. Decline and expiry deliberately leave the slots + * released: in both of those the consultee still wants to move and the + * consultant simply has not agreed a time, so the booking belongs in their + * allocate queue. A withdrawal is the opposite — the person who asked no + * longer wants it, so nothing should have moved. + * + * This is cheap for one reason worth stating: a reschedule never rewrites + * `startsAt`. The released rows still carry their original times, so restoring + * is flipping two flags, not replaying data from a snapshot. (Auto-confirm is + * the only path that ever wrote proposed times onto rows, and it no longer + * does — it hands them to the allocator instead.) + */ +export async function withdrawRescheduleRequest(args: { + rescheduleRequestId: string; + /** Must be the initiator. The caller is responsible for proving that. */ + withdrawnById: string; +}): Promise<{ withdrawn: boolean; reason?: string }> { + const { rescheduleRequestId, withdrawnById } = args; + + const request = await prisma.rescheduleRequest.findUnique({ + where: { id: rescheduleRequestId }, + select: { + id: true, + status: true, + initiatedById: true, + releasedSlotIds: true, + appointmentId: true, + appointment: { + select: { + consultationId: true, + subscriptionId: true, + }, + }, + }, + }); + + if (!request) return { withdrawn: false, reason: "PROPOSAL_NOT_FOUND" }; + + // Withdrawal is the initiator's alone. The other side already has Decline, + // which ends the same request with a different meaning and a different + // outcome for the slots — giving them this too would just be a second + // Decline wearing a friendlier word. + if (request.initiatedById !== withdrawnById) { + return { withdrawn: false, reason: "NOT_INITIATOR" }; + } + if (!RESCHEDULE_OPEN_STATUSES.includes(request.status)) { + return { withdrawn: false, reason: "PROPOSAL_NOT_OPEN" }; + } + + let restored = 0; + try { + await prisma.$transaction(async (tx) => { + // The CAS is the guard: if the other party answered while we were + // deciding, this matches zero rows and throws rather than un-releasing + // slots that a concurrent accept has already re-confirmed. + await transitionRescheduleRequest(tx, { + where: { id: request.id }, + to: "WITHDRAWN", + data: { resolvedById: withdrawnById }, + }); + + // Reverses exactly what the reschedule did to these rows. + const result = await tx.slotOfAppointment.updateMany({ + where: { + id: { in: request.releasedSlotIds }, + completionStatus: "RESCHEDULED", + }, + data: { isTentative: false, completionStatus: "SCHEDULED" }, + }); + restored = result.count; + + // A consultation reschedule sends the booking back to PENDING so it + // re-enters the consultant's queue; withdrawing has to undo that or the + // consultee is left with a confirmed-looking booking still sitting in + // someone's inbox. A subscription is deliberately NOT flipped on + // reschedule (#448 — it has no per-session status), so there is nothing + // to put back there. + // + // fromIn narrows to PENDING rather than the map's default: this edge is + // only ever undoing the reschedule's own flip, so an APPROVED booking + // reaching here means the state moved under us and should throw, not be + // re-stamped. + if (request.appointment?.consultationId) { + await transitionConsultationRequest(tx, { + where: { id: request.appointment.consultationId }, + to: "APPROVED", + fromIn: ["PENDING"], + }); + } + }); + } catch (err) { + // A lost CAS is a MODELLED outcome, not a fault: the other party accepted + // or declined while this withdrawal was in flight. Reporting it as an error + // would page on ordinary two-party contention, and the route would answer + // 500 instead of the 409 this actually is. + if (err instanceof IllegalTransitionError) { + return { withdrawn: false, reason: "PROPOSAL_NOT_OPEN" }; + } + reportSentryError(err, { + subsystem: "bookings", + op: "reschedule-withdraw", + extra: { rescheduleRequestId, releasedSlotIds: request.releasedSlotIds }, + }); + throw err; + } + + // The updateMany filters on RESCHEDULED, so a row whose status drifted stays + // released while the request is already WITHDRAWN — a half-restored booking + // that otherwise reports success and shows nothing anywhere. The withdrawal + // itself is committed and correct, so this reports rather than throws. + if (restored !== request.releasedSlotIds.length) { + reportSentryError( + new Error( + `Withdrawal restored ${restored} of ${request.releasedSlotIds.length} released slots.`, + ), + { + subsystem: "bookings", + op: "reschedule-withdraw-partial", + extra: { + rescheduleRequestId, + releasedSlotIds: request.releasedSlotIds, + restored, + }, + }, + ); + } + + return { withdrawn: true }; +} diff --git a/lib/booking/transitions.ts b/lib/booking/transitions.ts index f943d89fd..77c7c139c 100644 --- a/lib/booking/transitions.ts +++ b/lib/booking/transitions.ts @@ -195,6 +195,7 @@ export const RESCHEDULE_ALLOWED_FROM: Record< COUNTERED: ["PENDING_REVIEW"], ACCEPTED: ["PENDING_REVIEW", "COUNTERED"], DECLINED: ["PENDING_REVIEW", "COUNTERED"], + WITHDRAWN: ["PENDING_REVIEW", "COUNTERED"], EXPIRED: ["PENDING_REVIEW", "COUNTERED"], }; @@ -209,6 +210,7 @@ export const RESCHEDULE_TERMINAL_STATUSES: RescheduleRequestStatus[] = [ "AUTO_ACCEPTED", "ACCEPTED", "DECLINED", + "WITHDRAWN", "EXPIRED", ]; diff --git a/lib/data/allocation-request.ts b/lib/data/allocation-request.ts new file mode 100644 index 000000000..dd1085968 --- /dev/null +++ b/lib/data/allocation-request.ts @@ -0,0 +1,132 @@ +import prisma from "@/lib/prisma"; +import { toPlain } from "@/lib/data/serialize"; +import type { AppointmentStatus } from "@prisma/client"; + +/** + * The one pending request the allocate page is placing. + * + * Keyed by the CONSULTATION/SUBSCRIPTION id, not an appointment id: the + * `Appointment` row is downstream of the request and does not exist at all for + * one that has never been scheduled, which is the ordinary case here. + */ + +export type AllocationEventType = "consultation" | "subscription"; + +export interface AllocationRequest { + id: string; + eventType: AllocationEventType; + title: string; + status: AppointmentStatus; + /** Owner of the plan — the page's ownership check. */ + consultantProfileId: string; + /** The buyer, so the grid greys out times they are already booked at. */ + consulteeUserId?: string; + consulteeName?: string; + durationInHours?: number; + sessionDurationInHours?: number; + sessionsPerWeek?: number; + durationInMonths?: number; + totalSessions?: number; + schedulingTimezone?: string; + allowedStart?: Date; + allowedEnd?: Date; + /** + * Some slot is released and awaiting a new time, i.e. this is a partial + * reschedule rather than a fresh allocation — which is exactly when the + * `initialAllocation` guard must NOT fire (#837). + */ + hasReleasedSlots: boolean; +} + +const requestedBySelect = { + select: { userId: true, user: { select: { name: true } } }, +} as const; + +export async function readAllocationRequest( + requestId: string, + eventType: AllocationEventType, +): Promise<AllocationRequest | null> { + if (eventType === "subscription") { + const subscription = await prisma.subscription.findUnique({ + where: { id: requestId }, + select: { + id: true, + status: true, + schedulingPeriodStartsAt: true, + schedulingPeriodEndsAt: true, + schedulingTimezone: true, + requestedBy: requestedBySelect, + subscriptionPlan: { + select: { + title: true, + consultantProfileId: true, + sessionsPerWeek: true, + sessionDurationInHours: true, + durationInMonths: true, + totalSessions: true, + }, + }, + appointments: { + select: { slotsOfAppointment: { select: { isTentative: true } } }, + }, + }, + }); + if (!subscription?.subscriptionPlan) return null; + + const plan = subscription.subscriptionPlan; + return toPlain<AllocationRequest>({ + id: subscription.id, + eventType: "subscription", + title: plan.title, + status: subscription.status, + consultantProfileId: plan.consultantProfileId, + consulteeUserId: subscription.requestedBy?.userId, + consulteeName: subscription.requestedBy?.user?.name, + sessionDurationInHours: plan.sessionDurationInHours, + sessionsPerWeek: plan.sessionsPerWeek, + durationInMonths: plan.durationInMonths, + totalSessions: plan.totalSessions, + schedulingTimezone: subscription.schedulingTimezone, + allowedStart: subscription.schedulingPeriodStartsAt, + allowedEnd: subscription.schedulingPeriodEndsAt, + hasReleasedSlots: subscription.appointments.some((appointment) => + appointment.slotsOfAppointment.some((slot) => slot.isTentative), + ), + }); + } + + const consultation = await prisma.consultation.findUnique({ + where: { id: requestId }, + select: { + id: true, + status: true, + requestedBy: requestedBySelect, + consultationPlan: { + select: { + title: true, + consultantProfileId: true, + durationInHours: true, + }, + }, + appointment: { + select: { slotsOfAppointment: { select: { isTentative: true } } }, + }, + }, + }); + if (!consultation?.consultationPlan) return null; + + const plan = consultation.consultationPlan; + return toPlain<AllocationRequest>({ + id: consultation.id, + eventType: "consultation", + title: plan.title, + status: consultation.status, + consultantProfileId: plan.consultantProfileId, + consulteeUserId: consultation.requestedBy?.userId, + consulteeName: consultation.requestedBy?.user?.name, + durationInHours: plan.durationInHours, + hasReleasedSlots: ( + consultation.appointment?.slotsOfAppointment ?? [] + ).some((slot) => slot.isTentative), + }); +} diff --git a/lib/data/manage-timings-target.ts b/lib/data/manage-timings-target.ts new file mode 100644 index 000000000..2a8392e55 --- /dev/null +++ b/lib/data/manage-timings-target.ts @@ -0,0 +1,168 @@ +import prisma from "@/lib/prisma"; +import { toPlain } from "@/lib/data/serialize"; +import { readAppointmentDetail } from "@/lib/data/appointment-detail"; +import { resolvePlanOwnerIds } from "@/lib/booking/plan-owners"; +import type { ManageTimingsAppointmentLike } from "@/lib/scheduling/manage-timings-subject"; + +/** + * Resolves the id in `/appointments/[appointmentId]/timings` to the data the + * page needs, for both of that segment's meanings. + * + * A scheduled instance (consultation/subscription/webinar/class that already + * has an `Appointment` row) is looked up by that row's id, same as the + * sibling reschedule route. An UNSCHEDULED webinar/class has no `Appointment` + * row at all — that is what "unscheduled" means — so it travels as a + * synthetic id (`unscheduled-class-<id>` / `unscheduled-webinar-<id>`), the + * same prefix the consultant appointments list already used client-side + * before this route existed. + */ + +const UNSCHEDULED_CLASS_PREFIX = "unscheduled-class-"; +const UNSCHEDULED_WEBINAR_PREFIX = "unscheduled-webinar-"; + +const collaboratorsInclude = { + where: { status: "ACCEPTED" as const }, + select: { consultantProfileId: true }, +} as const; + +export interface ManageTimingsTarget { + appointment: ManageTimingsAppointmentLike; + /** Plan owner + ACCEPTED collaborators — the page's ownership check. */ + planOwnerIds: string[]; + /** Program progress; only set when this id groups several sessions + * (subscription/class with siblings). */ + completedSessions?: number; + groupTotalSessions?: number; +} + +function isCompleted(slots: { endsAt: string | Date }[]): boolean { + if (slots.length === 0) return false; + const now = Date.now(); + return slots.every((slot) => new Date(slot.endsAt).getTime() < now); +} + +function ownerIds(...ids: (string | null | undefined)[]): string[] { + return ids.filter((id): id is string => Boolean(id)); +} + +export async function readManageTimingsTarget( + targetId: string, +): Promise<ManageTimingsTarget | null> { + if (targetId.startsWith(UNSCHEDULED_CLASS_PREFIX)) { + const id = targetId.slice(UNSCHEDULED_CLASS_PREFIX.length); + const classRow = await prisma.class.findUnique({ + where: { id }, + include: { + classPlan: { include: { collaborators: collaboratorsInclude } }, + }, + }); + if (!classRow?.classPlan) return null; + + return toPlain<ManageTimingsTarget>({ + appointment: { + appointmentType: "CLASS", + class: { + id: classRow.id, + schedulingPeriodStartsAt: classRow.schedulingPeriodStartsAt, + schedulingPeriodEndsAt: classRow.schedulingPeriodEndsAt, + classPlan: classRow.classPlan, + }, + }, + planOwnerIds: ownerIds( + classRow.classPlan.consultantProfileId, + ...classRow.classPlan.collaborators.map((c) => c.consultantProfileId), + ), + }); + } + + if (targetId.startsWith(UNSCHEDULED_WEBINAR_PREFIX)) { + const id = targetId.slice(UNSCHEDULED_WEBINAR_PREFIX.length); + const webinarRow = await prisma.webinar.findUnique({ + where: { id }, + include: { + webinarPlan: { include: { collaborators: collaboratorsInclude } }, + }, + }); + if (!webinarRow?.webinarPlan) return null; + + return toPlain<ManageTimingsTarget>({ + appointment: { + appointmentType: "WEBINAR", + webinar: { id: webinarRow.id, webinarPlan: webinarRow.webinarPlan }, + }, + planOwnerIds: ownerIds( + webinarRow.webinarPlan.consultantProfileId, + ...webinarRow.webinarPlan.collaborators.map( + (c) => c.consultantProfileId, + ), + ), + }); + } + + // A real Appointment row: same read the reschedule/detail pages already + // use, so eligibility and the ownership shape stay in one place. + const detail = await readAppointmentDetail(targetId); + if (!detail) return null; + const { appointment, siblings } = detail; + + // TRIAL sessions never open this surface — the appointments list never + // renders a "Timings" action for one — but the type is wider than the + // four this route understands, so guard rather than assume. + if ( + appointment.appointmentType !== "CONSULTATION" && + appointment.appointmentType !== "SUBSCRIPTION" && + appointment.appointmentType !== "WEBINAR" && + appointment.appointmentType !== "CLASS" + ) { + return null; + } + + const planOwnerIds = resolvePlanOwnerIds(appointment); + + // A subscription/class session is one Appointment among several; progress + // is only meaningful across the whole program, same as the consultant + // appointments list's group card (map-consultant.ts's mapGroup). + const program = + appointment.appointmentType === "SUBSCRIPTION" || + appointment.appointmentType === "CLASS" + ? [appointment, ...siblings] + : null; + const scheduled = program?.filter( + (row) => row.slotsOfAppointment.length > 0, + ); + + // The PLAN's count, not the number of Appointment rows that happen to carry + // slots. Siblings are real appointments, not session placeholders, so an + // unscheduled one is simply absent — counting rows made the total shrink to + // whatever was already scheduled, and "remaining" then folded completed + // sessions in with future ones. + const groupTotalSessions = + appointment.appointmentType === "SUBSCRIPTION" + ? (appointment.subscription?.subscriptionPlan?.totalSessions ?? undefined) + : appointment.appointmentType === "CLASS" + ? (appointment.class?.classPlan?.totalSessions ?? undefined) + : undefined; + + return { + // Narrowed shape only: the guard above already ruled out TRIAL, but Prisma's + // enum comparison doesn't narrow `appointment.appointmentType` for TS, and + // this also keeps payment/organization/trialSession off a type this route + // never reads. + appointment: { + appointmentType: appointment.appointmentType as + | "CONSULTATION" + | "SUBSCRIPTION" + | "WEBINAR" + | "CLASS", + consultation: appointment.consultation, + subscription: appointment.subscription, + webinar: appointment.webinar, + class: appointment.class, + }, + planOwnerIds, + completedSessions: scheduled + ? scheduled.filter((row) => isCompleted(row.slotsOfAppointment)).length + : undefined, + groupTotalSessions, + }; +} diff --git a/lib/scheduling/manage-timings-subject.ts b/lib/scheduling/manage-timings-subject.ts new file mode 100644 index 000000000..ae93575e5 --- /dev/null +++ b/lib/scheduling/manage-timings-subject.ts @@ -0,0 +1,277 @@ +import type { SlotPickerSubject } from "@/components/scheduling/slot-picker-policy"; +import { getClassPlanDefaults, type ClassPlanType } from "@/utils/classPlans"; + +/** + * Turns one of the four offering types into what the "manage timings" page + * needs — ported from the dialog this route replaced (EventTimingsCalendar). + * + * Structural, not a Prisma payload type: the same fields come from a real + * Appointment (already has some slots) or a bare Class/Webinar row (nothing + * scheduled yet), see lib/data/manage-timings-target.ts for which. This only + * ever reads what both can supply. + */ + +export type ManageTimingsAppointmentType = + | "CONSULTATION" + | "SUBSCRIPTION" + | "WEBINAR" + | "CLASS"; + +export interface ManageTimingsAppointmentLike { + appointmentType: ManageTimingsAppointmentType; + consultation?: { + id?: string | null; + consultationPlan?: { + durationInHours?: number | null; + title?: string | null; + } | null; + } | null; + subscription?: { + id?: string | null; + schedulingPeriodStartsAt?: string | Date | null; + schedulingPeriodEndsAt?: string | Date | null; + subscriptionPlan?: { + sessionsPerWeek?: number | null; + durationInMonths?: number | null; + sessionDurationInHours?: number | null; + totalSessions?: number | null; + title?: string | null; + } | null; + } | null; + webinar?: { + id?: string | null; + webinarPlan?: { + durationInHours?: number | null; + title?: string | null; + } | null; + } | null; + class?: { + id?: string | null; + schedulingPeriodStartsAt?: string | Date | null; + schedulingPeriodEndsAt?: string | Date | null; + classPlan?: { + title?: string | null; + sessionsPerWeek?: number | null; + durationInMonths?: number | null; + sessionDurationInHours?: number | null; + totalSessions?: number | null; + } | null; + } | null; +} + +interface EventDetails { + eventType: "consultation" | "subscription" | "webinar" | "class"; + eventId: string; + sessionsPerWeek?: number; + durationInMonths: number; + durationInHours: number; + sessionDurationInHours?: number; + totalSessions?: number; + title: string; + planType?: ClassPlanType; +} + +function getEventDetails( + appointment: ManageTimingsAppointmentLike, +): EventDetails { + switch (appointment.appointmentType) { + case "CONSULTATION": + return { + eventType: "consultation", + eventId: appointment.consultation?.id || "", + sessionsPerWeek: 1, + durationInMonths: 1, + durationInHours: + appointment.consultation?.consultationPlan?.durationInHours || 1, + title: + appointment.consultation?.consultationPlan?.title || "Consultation", + }; + case "SUBSCRIPTION": + return { + eventType: "subscription", + eventId: appointment.subscription?.id || "", + sessionsPerWeek: + appointment.subscription?.subscriptionPlan?.sessionsPerWeek || 1, + durationInMonths: + appointment.subscription?.subscriptionPlan?.durationInMonths || 1, + durationInHours: + appointment.subscription?.subscriptionPlan + ?.sessionDurationInHours || 1, + sessionDurationInHours: + appointment.subscription?.subscriptionPlan + ?.sessionDurationInHours || 1, + totalSessions: + appointment.subscription?.subscriptionPlan?.totalSessions ?? + undefined, + title: + appointment.subscription?.subscriptionPlan?.title || "Subscription", + }; + case "WEBINAR": + return { + eventType: "webinar", + eventId: appointment.webinar?.id || "", + sessionsPerWeek: 1, + durationInMonths: 1, + durationInHours: + appointment.webinar?.webinarPlan?.durationInHours || 1, + title: appointment.webinar?.webinarPlan?.title || "Webinar", + }; + case "CLASS": { + const classPlan = appointment.class?.classPlan; + // getClassPlanDefaults predates nullable Prisma fields — null and + // "not provided" mean the same thing here, so normalize to undefined. + const defaults = getClassPlanDefaults({ + title: classPlan?.title ?? undefined, + sessionsPerWeek: classPlan?.sessionsPerWeek ?? undefined, + durationInMonths: classPlan?.durationInMonths ?? undefined, + sessionDurationInHours: classPlan?.sessionDurationInHours ?? undefined, + }); + return { + eventType: "class", + eventId: appointment.class?.id || "", + sessionsPerWeek: defaults.classesPerWeek, + durationInMonths: defaults.durationInMonths, + durationInHours: + classPlan?.sessionDurationInHours ?? defaults.sessionDurationInHours, + totalSessions: classPlan?.totalSessions ?? undefined, + title: classPlan?.title || "Class", + planType: defaults.type, + }; + } + } +} + +/** Only the recurring types carry a scheduling period to clamp to. */ +function getSchedulingPeriod(appointment: ManageTimingsAppointmentLike): { + start?: Date; + end?: Date; +} { + const event = + appointment.appointmentType === "SUBSCRIPTION" + ? appointment.subscription + : appointment.appointmentType === "CLASS" + ? appointment.class + : null; + return { + start: event?.schedulingPeriodStartsAt + ? new Date(event.schedulingPeriodStartsAt) + : undefined, + end: event?.schedulingPeriodEndsAt + ? new Date(event.schedulingPeriodEndsAt) + : undefined, + }; +} + +function appendProgressText( + baseText: string, + completed?: number, + total?: number, +): string { + if (completed && completed > 0 && total) { + const remaining = total - completed; + return `${baseText} ${completed} of ${total} sessions completed — select times for the remaining ${remaining}.`; + } + return baseText; +} + +function getDescription( + appointment: ManageTimingsAppointmentLike, + eventDetails: EventDetails, + completedSessions?: number, + groupTotalSessions?: number, +): string { + switch (appointment.appointmentType) { + case "CONSULTATION": + return "Select consecutive time slots for your consultation. All slots must be on the same day."; + case "SUBSCRIPTION": { + const baseText = `Schedule ${eventDetails.sessionsPerWeek} session${eventDetails.sessionsPerWeek !== 1 ? "s" : ""} per week for ${eventDetails.durationInMonths} month${eventDetails.durationInMonths !== 1 ? "s" : ""}. Each session is ${eventDetails.sessionDurationInHours || 1} hour${(eventDetails.sessionDurationInHours || 1) > 1 ? "s" : ""}.`; + return appendProgressText( + baseText, + completedSessions, + groupTotalSessions, + ); + } + case "WEBINAR": + return "Select consecutive time slots for your webinar session."; + case "CLASS": { + const sessionDuration = eventDetails.durationInHours || 1; + const durationText = + sessionDuration === 1 ? "1 hour" : `${sessionDuration} hours`; + const sessionsPerWeek = eventDetails.sessionsPerWeek || 1; + const classBaseText = `Schedule ${sessionsPerWeek} session${sessionsPerWeek !== 1 ? "s" : ""} per week. Each session is ${durationText}.`; + return appendProgressText( + classBaseText, + completedSessions, + groupTotalSessions, + ); + } + } +} + +export interface ManageTimingsClassInfo { + planType: ClassPlanType; + sessionsPerWeek: number; + durationInMonths: number; + durationInHours: number; +} + +export interface ManageTimingsSubject { + subject: SlotPickerSubject; + /** The offering's own title, for the page header — not "Manage timings" + * on every tab (#1064 pattern, matches the other three routes). */ + title: string; + description: string; + /** Only classes show the plan-type badge + scheduling tip. */ + classInfo?: ManageTimingsClassInfo; +} + +export function buildManageTimingsSubject( + consultantId: string, + appointment: ManageTimingsAppointmentLike, + completedSessions?: number, + groupTotalSessions?: number, +): ManageTimingsSubject { + const eventDetails = getEventDetails(appointment); + const schedulingPeriod = getSchedulingPeriod(appointment); + + return { + title: eventDetails.title, + description: getDescription( + appointment, + eventDetails, + completedSessions, + groupTotalSessions, + ), + classInfo: + appointment.appointmentType === "CLASS" + ? { + planType: eventDetails.planType ?? "Custom", + sessionsPerWeek: eventDetails.sessionsPerWeek ?? 1, + durationInMonths: eventDetails.durationInMonths, + durationInHours: eventDetails.durationInHours, + } + : undefined, + subject: { + consultantProfileId: consultantId, + eventType: eventDetails.eventType, + eventId: eventDetails.eventId, + durationInMonths: eventDetails.durationInMonths, + sessionsPerWeek: eventDetails.sessionsPerWeek, + // One duration field, two meanings — see slot-picker-subject.ts. + durationInHours: + eventDetails.eventType === "webinar" || + eventDetails.eventType === "consultation" + ? eventDetails.durationInHours + : undefined, + sessionDurationInHours: + eventDetails.eventType === "subscription" || + eventDetails.eventType === "class" + ? eventDetails.durationInHours + : undefined, + totalSessions: eventDetails.totalSessions, + // Guard rails: keep selection inside the plan's scheduling period. + allowedStart: schedulingPeriod.start, + allowedEnd: schedulingPeriod.end, + }, + }; +} diff --git a/lib/scheduling/slot-picker-subject.ts b/lib/scheduling/slot-picker-subject.ts new file mode 100644 index 000000000..a67c46865 --- /dev/null +++ b/lib/scheduling/slot-picker-subject.ts @@ -0,0 +1,180 @@ +import type { SlotPickerSubject } from "@/components/scheduling/slot-picker-policy"; +import type { TAppointmentDetail } from "@/lib/data/appointment-detail"; +import type { SlotLike } from "@/lib/appointments/view-model"; + +/** + * Turns one appointment into everything a reschedule page needs. + * + * The consultee's route carries `[consulteeId]` and `[appointmentId]` and + * nothing else, so the consultant whose grid the picker draws has to be + * resolved from the booking — this is where that happens, server-side, before + * anything reaches the client. + */ + +export type BookingTypeLabel = + | "Consultation" + | "Subscription" + | "Webinar" + | "Class" + | "Trial"; + +export interface RescheduleSubject { + subject: SlotPickerSubject; + /** Plan title, for the page header. */ + title: string; + /** Drives copy and the reschedule endpoint's `?type=` discriminator. */ + typeLabel: BookingTypeLabel; + /** Owner of the grid — the answer the consultee's route params cannot give. */ + consultantProfileId: string; + /** The booking's consultee, for the page's ownership check. */ + consulteeProfileId?: string; + consulteeUserId?: string; + /** Counterpart names for the page header — already on the plan/requestedBy + * includes `readAppointmentDetail` selects, so no second query. */ + consultantName?: string; + consulteeName?: string; +} + +/** Slots that a reschedule could still act on: ahead of now, and not already dead. */ +function liveFutureSlots(detail: TAppointmentDetail): SlotLike[] { + const { appointment, siblings } = detail; + // Program-wide. A subscription or class session is one Appointment among + // many, and "every session" has to mean all of them — the reschedule API + // reads the whole program off a single appointment id for the same reason. + const all = [ + ...appointment.slotsOfAppointment, + ...siblings.flatMap((sibling) => sibling.slotsOfAppointment), + ]; + const now = Date.now(); + return all + .filter( + (slot) => + new Date(slot.endsAt).getTime() >= now && + slot.completionStatus !== "CANCELLED" && + slot.completionStatus !== "RESCHEDULED", + ) + .map((slot) => ({ + id: slot.id, + appointmentId: slot.appointmentId, + startsAt: slot.startsAt, + endsAt: slot.endsAt, + isTentative: slot.isTentative, + completionStatus: slot.completionStatus, + })); +} + +/** + * Consultant, consultee, session length and copy for one booking. + * + * Returns null when the booking has no consultant to draw a grid for, which + * the caller should treat as a 404 rather than render an empty calendar. + */ +export function buildRescheduleSubject( + detail: TAppointmentDetail, +): RescheduleSubject | null { + const { appointment } = detail; + + const resolved = ((): { + consultantProfileId?: string; + consulteeProfileId?: string; + consulteeUserId?: string; + consultantName?: string; + consulteeName?: string; + title: string; + typeLabel: BookingTypeLabel; + sessionDurationInHours?: number; + } | null => { + switch (appointment.appointmentType) { + case "CONSULTATION": { + const plan = appointment.consultation?.consultationPlan; + return { + consultantProfileId: plan?.consultantProfile?.id, + consulteeProfileId: appointment.consultation?.requestedBy?.id, + consulteeUserId: appointment.consultation?.requestedBy?.userId, + consultantName: plan?.consultantProfile?.user?.name, + consulteeName: appointment.consultation?.requestedBy?.user?.name, + title: plan?.title ?? "Consultation", + typeLabel: "Consultation", + sessionDurationInHours: plan?.durationInHours, + }; + } + case "SUBSCRIPTION": { + const plan = appointment.subscription?.subscriptionPlan; + return { + consultantProfileId: plan?.consultantProfile?.id, + consulteeProfileId: appointment.subscription?.requestedBy?.id, + consulteeUserId: appointment.subscription?.requestedBy?.userId, + consultantName: plan?.consultantProfile?.user?.name, + consulteeName: appointment.subscription?.requestedBy?.user?.name, + title: plan?.title ?? "Subscription", + typeLabel: "Subscription", + sessionDurationInHours: plan?.sessionDurationInHours, + }; + } + case "WEBINAR": { + const plan = appointment.webinar?.webinarPlan; + return { + consultantProfileId: plan?.consultantProfile?.id, + consultantName: plan?.consultantProfile?.user?.name, + title: plan?.title ?? "Webinar", + typeLabel: "Webinar", + sessionDurationInHours: plan?.durationInHours, + }; + } + case "CLASS": { + const plan = appointment.class?.classPlan; + return { + consultantProfileId: plan?.consultantProfile?.id, + consultantName: plan?.consultantProfile?.user?.name, + title: plan?.title ?? "Class", + typeLabel: "Class", + sessionDurationInHours: plan?.sessionDurationInHours ?? undefined, + }; + } + case "TRIAL": { + const plan = appointment.trialSession?.subscriptionPlan; + return { + consultantProfileId: plan?.consultantProfile?.id, + consulteeProfileId: appointment.trialSession?.consulteeProfile?.id, + consulteeUserId: appointment.trialSession?.consulteeProfile?.userId, + consultantName: plan?.consultantProfile?.user?.name, + consulteeName: appointment.trialSession?.consulteeProfile?.user?.name, + title: plan?.title ?? "Trial session", + typeLabel: "Trial", + sessionDurationInHours: plan?.sessionDurationInHours, + }; + } + default: + return null; + } + })(); + + if (!resolved?.consultantProfileId) return null; + + return { + title: resolved.title, + typeLabel: resolved.typeLabel, + consultantProfileId: resolved.consultantProfileId, + consulteeProfileId: resolved.consulteeProfileId, + consulteeUserId: resolved.consulteeUserId, + consultantName: resolved.consultantName, + consulteeName: resolved.consulteeName, + subject: { + consultantProfileId: resolved.consultantProfileId, + // Availability only, keyed to no event: a reschedule asks "when is this + // consultant free", not "how does this plan allocate". The picker has + // drawn it this way since it lived in the dialog; the event-shaped + // fetches belong to the allocate surface. + eventType: "consultation", + counterpartUserId: resolved.consulteeUserId, + // Both, deliberately: the grid reads `durationInHours` for the + // consultation shape it is drawing and `sessionDurationInHours` for the + // block size, and for a session being MOVED those are the same number. + // Leaving the first unset is what made the picker warn "duration not + // configured" and fall back to an hour. + durationInHours: resolved.sessionDurationInHours, + sessionDurationInHours: resolved.sessionDurationInHours, + slots: liveFutureSlots(detail), + }, + }; +} diff --git a/lib/scheduling/slot-status-tokens.ts b/lib/scheduling/slot-status-tokens.ts index 52e51a1bb..b20a12305 100644 --- a/lib/scheduling/slot-status-tokens.ts +++ b/lib/scheduling/slot-status-tokens.ts @@ -12,10 +12,14 @@ * reading two different languages for the same information. Worse, none of the * three surfaces carried a legend, so the vocabulary was never stated anywhere. * - * These tokens are the single source. `SlotStatusLegend` renders them, so a new - * state cannot be added without the legend gaining an entry. + * These tokens are the single source. Cells and legend swatches are both + * DERIVED from one `fill`/`border` pair per state, so a swatch cannot document + * a colour the grid does not paint — which is exactly what happened while the + * two were written out by hand (#1064). */ +import { cn } from "@/utils/tailwind"; + export type SlotStatusKey = | "available" | "partiallyBooked" @@ -25,63 +29,197 @@ export type SlotStatusKey = | "rescheduling" | "unavailable"; +/** The hand-authored half of a token: one colour per state, written once. */ +interface SlotStatusPaint { + label: string; + hint: string; + /** Background. Shared by the cell and its legend swatch. */ + fill: string; + /** Border colour. Shared likewise — see `SLOT_CELL_BASE_CLASS` on why the + * token owns this and the base string must not. */ + border: string; + /** Cell label colour. Swatches carry no text. */ + text: string; + /** Cells only, and only where the cell does something when clicked. */ + hover?: string; +} + export interface SlotStatusToken { /** Shown in the legend and in cell tooltips. */ label: string; + /** What the state means, for the legend's title attribute. */ + hint: string; + /** The state's background. Asserted equal across cell and swatch in tests. */ + fill: string; + /** The state's border colour. */ + border: string; /** Cell classes on a light surface. */ className: string; - /** Legend swatch — a solid block, so it reads at 12px. */ + /** Legend swatch — the cell's own fill and border at 12px. */ swatchClassName: string; - /** What the state means, for the legend's title attribute. */ - hint: string; } -export const SLOT_STATUS_TOKENS: Record<SlotStatusKey, SlotStatusToken> = { +const SLOT_STATUS_PAINT: Record<SlotStatusKey, SlotStatusPaint> = { available: { label: "Available", - className: - "bg-emerald-100 text-emerald-900 border-emerald-300 hover:bg-emerald-200", - swatchClassName: "bg-emerald-300", hint: "Free to book.", + // Deliberately the most saturated light tone on the grid. Free time is the + // one thing a consultant scans for, and at emerald-100 it came out paler + // than the slate greys around it — the useful cells receding behind the + // useless ones (#1064). + fill: "bg-emerald-200", + border: "border-emerald-500", + text: "text-emerald-900", + hover: "hover:bg-emerald-300", }, partiallyBooked: { label: "Partly booked", - className: "bg-amber-100 text-amber-900 border-amber-300", - swatchClassName: "bg-amber-300", hint: "Some of this interval is taken; a full session may not fit.", + fill: "bg-amber-200", + border: "border-amber-400", + text: "text-amber-900", + hover: "hover:bg-amber-300", }, fullyBooked: { label: "Booked", - className: "bg-slate-200 text-slate-600 border-slate-300", - swatchClassName: "bg-slate-400", hint: "Already taken.", + // One shade darker than `unavailable` on purpose — a taken slot is a + // stronger signal ("someone has this") than an unpublished one ("nobody + // offered this"), and the same grey on both would collapse the two states + // once `unavailable` was darkened for visibility (#1064). + fill: "bg-slate-300", + border: "border-slate-400", + text: "text-slate-700", + hover: "hover:bg-slate-400", }, selected: { label: "Selected", - className: "bg-emerald-700 text-white border-emerald-800", - swatchClassName: "bg-emerald-700", hint: "You have chosen this time.", + fill: "bg-emerald-700", + border: "border-emerald-800", + text: "text-white", + hover: "hover:bg-emerald-800", }, thisEvent: { label: "This booking", - className: "bg-zinc-900 text-white border-zinc-900", - swatchClassName: "bg-zinc-900", hint: "Belongs to the booking you are editing.", + fill: "bg-zinc-900", + border: "border-zinc-900", + text: "text-white", + hover: "hover:bg-zinc-800", }, rescheduling: { label: "Being moved", - className: "bg-amber-400 text-amber-950 border-amber-500", - swatchClassName: "bg-amber-400", hint: "Released by a reschedule and awaiting a new time.", + fill: "bg-amber-400", + border: "border-amber-500", + text: "text-amber-950", + hover: "hover:bg-amber-500", }, unavailable: { label: "Unavailable", - className: "bg-slate-100 text-slate-400 border-slate-200", - swatchClassName: "bg-slate-200", hint: "Outside the consultant's published availability.", + // Was slate-100. On a white card that is nearly indistinguishable from the + // background, so a sparse week — mostly unavailable cells — read as an + // EMPTY grid rather than a full one with little availability, which is why + // the first migration was reverted (#1064, 49973623 / 6b78274e). + fill: "bg-slate-200", + border: "border-slate-300", + text: "text-slate-500", }, }; +export const SLOT_STATUS_TOKENS: Record<SlotStatusKey, SlotStatusToken> = ( + Object.keys(SLOT_STATUS_PAINT) as SlotStatusKey[] +).reduce( + (tokens, key) => { + const paint = SLOT_STATUS_PAINT[key]; + tokens[key] = { + label: paint.label, + hint: paint.hint, + fill: paint.fill, + border: paint.border, + className: [paint.fill, paint.text, paint.border, paint.hover] + .filter(Boolean) + .join(" "), + // Same fill, same border, no text colour and no hover: a swatch is a + // 12px block, not a control. + swatchClassName: `${paint.fill} ${paint.border}`, + }; + return tokens; + }, + {} as Record<SlotStatusKey, SlotStatusToken>, +); + +/** + * Everything a slot cell needs EXCEPT its colour. + * + * No border-COLOUR here, only the width. `border-transparent` used to sit in + * this string and silently beat the token's `border-emerald-500`: Tailwind + * resolves two same-specificity utilities by their order in the generated + * stylesheet, where `.border-transparent` is emitted last, not by the order + * they were concatenated. Unavailable cells therefore lost their outline + * altogether and read as absent rather than merely faint (#1064). + */ +export const SLOT_CELL_BASE_CLASS = + "h-8 w-full relative transition-colors duration-75 ease-in-out border rounded-sm text-[10px] leading-tight px-1 py-0.5 disabled:pointer-events-none disabled:opacity-50"; + +/** + * The full class string for one grid cell. + * + * Callers pass a state, never a colour — that is what keeps the grid and + * `SlotStatusLegend` on one palette. + */ +export function slotCellClassName( + key: SlotStatusKey, + options?: { faded?: boolean; className?: string }, +): string { + // cn, not a join: Tailwind resolves same-specificity conflicts by position in + // the GENERATED stylesheet, not by concatenation order — the exact mechanism + // behind this file's `border-transparent` regression (#1064). A plain join + // gives the open `className` extension point no protection against a caller + // passing a conflicting `bg-*`/`border-*`; twMerge makes the last argument + // win, which is the order a caller would reasonably expect. + return cn( + SLOT_CELL_BASE_CLASS, + SLOT_STATUS_TOKENS[key].className, + options?.faded && "opacity-60", + options?.className, + ); +} + +/** The booleans a cell resolves through, in the precedence they apply. */ +export interface SlotVisualFlags { + isSelected: boolean; + isThisEventSlot: boolean; + isRescheduling: boolean; + isBookedForDisplay: boolean; + isPartiallyBooked: boolean; + isAvailable: boolean; + isInPast: boolean; +} + +/** + * Which token a cell paints from. The ONE place that decision is made — + * `UnifiedCalendar.renderTimeCell` calls this rather than hand-rolling its own + * class string, which is what let the grid and `SlotStatusLegend` drift onto + * two different palettes in the first place (#1064; reverted attempt + * 49973623/6b78274e). + * + * A past-but-available slot resolves to `unavailable`: it is a real interval, + * just no longer bookable, and the muted palette says that without inventing + * a fifth colour family. + */ +export function resolveSlotStatusKey(flags: SlotVisualFlags): SlotStatusKey { + if (flags.isSelected) return "selected"; + if (flags.isThisEventSlot) return "thisEvent"; + if (flags.isRescheduling) return "rescheduling"; + if (flags.isBookedForDisplay) return "fullyBooked"; + if (flags.isPartiallyBooked) return "partiallyBooked"; + if (flags.isAvailable) return flags.isInPast ? "unavailable" : "available"; + return "unavailable"; +} + /** The states worth explaining on a read-only grid. */ export const BUYER_LEGEND_KEYS: SlotStatusKey[] = [ "available", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c03065c9a..09695da79 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3613,6 +3613,10 @@ model RescheduleRequest { resolvedAt DateTime? @db.Timestamptz resolvedBy User? @relation("RescheduleResolver", fields: [resolvedById], references: [id], onDelete: SetNull) resolvedById String? + /// The answering party's reply. `reason` runs consultee -> consultant; this + /// is the return leg, and it is the difference between "your sessions moved" + /// and "moved to Thursdays, I have blocked Tuesdays from September". + resolutionNote String? /// ADR 19/23 — inherits the appointment's org-ness so notifications route and /// org dashboards scope correctly. @@ -3663,6 +3667,12 @@ enum RescheduleRequestStatus { COUNTERED ACCEPTED DECLINED + /// The INITIATOR took their own request back. Distinct from DECLINED, which + /// is the other party refusing: only a withdrawal restores the booking to its + /// original times, because nothing should have moved if the person who asked + /// no longer wants it. Collapsing the two would make the audit trail unable + /// to say who ended it. + WITHDRAWN /// Lapsed unanswered; the request falls back to consultant allocation. EXPIRED } diff --git a/schemas/appointments.ts b/schemas/appointments.ts index 1a2262a5e..76720b0e6 100644 --- a/schemas/appointments.ts +++ b/schemas/appointments.ts @@ -13,6 +13,9 @@ export const CancelAppointmentSchema = z.object({ * request, and it is the only shape group events accept. `.passthrough()` * because the same body also carries `slotIds`, which the route parses itself. */ +/** ADR B1 — the calendar's atomic unit. */ +const SLOT_MS = 30 * 60 * 1000; + export const RescheduleProposalSchema = z .object({ proposedSlots: z @@ -22,9 +25,16 @@ export const RescheduleProposalSchema = z startsAt: z.string().datetime(), endsAt: z.string().datetime(), }) - .refine((s) => new Date(s.endsAt) > new Date(s.startsAt), { - message: "endsAt must be after startsAt", - }), + // Exactly one atom per row, not merely "ends after it starts". + // Auto-confirm hands the allocator `startsAt` alone, and manual mode + // reads each string as ONE 30-minute start — so a 60-minute row is + // silently booked as 30. The count check downstream still passes + // (one row per released slot), which is what makes it invisible: the + // consultee asks to move a 1-hour session and gets half of one. + .refine( + (s) => new Date(s.endsAt).getTime() - new Date(s.startsAt).getTime() === SLOT_MS, + { message: "Each proposed time must be exactly one 30-minute slot" }, + ), ) .min(1) .max(64) diff --git a/tailwind.config.ts b/tailwind.config.ts index 311a9f724..611c3dbd8 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -2,9 +2,18 @@ import type { Config } from "tailwindcss"; const config: Config = { darkMode: ["class"], + // `lib/` and `utils/` hold class strings too — the slot palette, appointment + // status badges, session and org labels, document icons. Tailwind only emits + // a utility it has SEEN in a scanned file, so every one of those was being + // dropped from the stylesheet unless the same class happened to appear under + // components/ or app/ as well. That is what made grid cells painted from + // `lib/scheduling/slot-status-tokens` render with no fill and no border at + // all — not faint, absent (#1064). content: [ "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./lib/**/*.{js,ts,jsx,tsx,mdx}", + "./utils/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { diff --git a/utils/slotAllocation/SlotAllocationService.ts b/utils/slotAllocation/SlotAllocationService.ts index 0df3830c0..3d6016e5e 100644 --- a/utils/slotAllocation/SlotAllocationService.ts +++ b/utils/slotAllocation/SlotAllocationService.ts @@ -122,6 +122,7 @@ export class SlotAllocationService { request.slots, request.idempotencyKey, request.initialAllocation, + request.wideLock, ); case "requested": @@ -829,6 +830,7 @@ export class SlotAllocationService { slotStrings: string[], idempotencyKey?: string, initialAllocation?: boolean, + wideLock?: boolean, ): Promise<AllocationResult> { // #837 — return the prior batch on a double-submit before doing any work. const replay = await this.findIdempotentAllocation( @@ -858,12 +860,19 @@ export class SlotAllocationService { // #860 — shard the lock by the earliest target day so allocations for // different days don't serialize; same-day (the actual duplicate risk) // still shares the key. #440's GiST constraint backstops cross-day overlap. - const lockScope = slotStrings - .map((s) => new Date(s)) - .filter((d) => !Number.isNaN(d.getTime())) - .sort((a, b) => a.getTime() - b.getTime())[0] - ?.toISOString() - .slice(0, 10); + // + // wideLock opts out of the sharding: the caller is placing times nobody + // picked per-day, so parallel same-consultant allocations could each pass + // the per-week cap check on a stale count. GiST cannot see a cap, only an + // overlap. + const lockScope = wideLock + ? undefined + : slotStrings + .map((s) => new Date(s)) + .filter((d) => !Number.isNaN(d.getTime())) + .sort((a, b) => a.getTime() - b.getTime())[0] + ?.toISOString() + .slice(0, 10); const lock = await lockAutoAllocate(consultantProfileId, lockScope); // #898 follow-up — serialize on the consultee too (consultant → consultee // lock order) so one person can't be booked with two consultants at once. diff --git a/utils/slotAllocation/types.ts b/utils/slotAllocation/types.ts index d7389bba2..777c94c31 100644 --- a/utils/slotAllocation/types.ts +++ b/utils/slotAllocation/types.ts @@ -46,6 +46,18 @@ 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; + /** + * Manual mode only. When true the Redis lock is taken consultant-WIDE rather + * than sharded by the target day. + * + * #860 shards the manual key so allocations on different days for one + * consultant run in parallel, with #440's GiST constraint backstopping + * overlap. But GiST only prevents time OVERLAP — per-day and per-week caps + * are validated by COUNTING, so two sharded allocations can each read the + * same count and both add, taking a 4-session week to 5. Callers that place + * times a human did not pick per-day (auto-confirm) must set this. + */ + wideLock?: boolean; /** * Consultant's explicit acceptance of times outside their own published * availability. Routes must only set this for the consultant or a privileged