From c7d66caa67d3f98331b3fecaba6fd160661abd7d Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:04:27 +0530 Subject: [PATCH 1/5] fix(appointments): gate Manage Timings on whether anyone has committed Manage Timings writes new times with no notice requirement and no acceptance, which is honest only while nobody else holds the time. The consultant menu offered it for any non-cancelled, non-past booking, so a consultation or subscription session a consultee had paid for could be moved out from under them. Adds `allowsManageTimings` next to `slotsAllowReschedule`: group events and anything unallocated keep the surface, a 1:1 with a confirmed slot loses it and gets the negotiated Reschedule instead. The two are now complements, so a booking never shows both. The page enforces the same rule after its ownership check, because the URL is linkable. Closes #1082 --- .../manage-timings-affordance.test.ts | 184 ++++++++++++++++++ .../ConsultantAppointmentsAdapter.tsx | 31 ++- .../[appointmentId]/timings/page.tsx | 54 ++++- lib/appointments/slots.ts | 47 +++++ 4 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 __tests__/booking-algorithm/manage-timings-affordance.test.ts diff --git a/__tests__/booking-algorithm/manage-timings-affordance.test.ts b/__tests__/booking-algorithm/manage-timings-affordance.test.ts new file mode 100644 index 000000000..ec0fba1c5 --- /dev/null +++ b/__tests__/booking-algorithm/manage-timings-affordance.test.ts @@ -0,0 +1,184 @@ +/** + * The Manage Timings menu item's gate, and its complement. + * + * Manage Timings writes new times straight onto the calendar with no notice + * requirement and no acceptance from anyone, so it may only be offered where + * nobody else has committed to a time. Where somebody has, the negotiated + * Reschedule takes its place — the two are alternatives, and offering both + * would hand a consultant a way around the proposal (#1082). + */ + +import { + allowsManageTimings, + slotsAllowReschedule, + upcomingSlots, +} from "@/lib/appointments/slots"; +import type { AppointmentKind } from "@/lib/appointments/view-model"; + +type TestSlot = { + isTentative?: boolean | null; + completionStatus?: string | null; +}; + +const confirmed: TestSlot[] = [ + { isTentative: false, completionStatus: "SCHEDULED" }, +]; +const tentative: TestSlot[] = [{ isTentative: true, completionStatus: null }]; +const nothingAllocated: TestSlot[] = []; + +/** Mirrors ConsultantAppointmentsAdapter's two guards, minus the status checks. */ +function offered(kind: AppointmentKind, slots: TestSlot[]) { + const timings = allowsManageTimings(kind, slots); + return { timings, reschedule: !timings && slotsAllowReschedule(slots) }; +} + +describe("allowsManageTimings", () => { + it("keeps Timings on a webinar whose instance is already confirmed", () => { + // Attendees bought into a published schedule; there is no counterparty to + // send a proposal to, and thirty of them cannot each accept one. + expect(offered("WEBINAR", confirmed)).toEqual({ + timings: true, + reschedule: false, + }); + }); + + it("keeps Timings on a class instance", () => { + expect(offered("CLASS", confirmed)).toEqual({ + timings: true, + reschedule: false, + }); + }); + + it("keeps Timings on an offering that was never scheduled", () => { + // The `unscheduled-class-…` / `unscheduled-webinar-…` rows: no Appointment + // row at all, so no slots. + expect(offered("CLASS", nothingAllocated)).toEqual({ + timings: true, + reschedule: false, + }); + expect(offered("WEBINAR", nothingAllocated)).toEqual({ + timings: true, + reschedule: false, + }); + }); + + it("keeps Timings on a consultation whose slots are still tentative", () => { + // Nothing has been placed — the request is awaiting allocation, so the + // consultee holds no time yet. + expect(offered("CONSULTATION", tentative)).toEqual({ + timings: true, + reschedule: false, + }); + }); + + it("keeps Timings on an approved 1:1 with nothing allocated", () => { + // "Not scheduled · 0/0". Reschedule refuses it for want of a time to move, + // so Timings has to hold this case or the row would offer neither. + expect(offered("CONSULTATION", nothingAllocated)).toEqual({ + timings: true, + reschedule: false, + }); + }); + + it("replaces Timings with Reschedule on a confirmed consultation", () => { + expect(offered("CONSULTATION", confirmed)).toEqual({ + timings: false, + reschedule: true, + }); + }); + + it("replaces Timings with Reschedule on a confirmed subscription session", () => { + expect( + offered("SUBSCRIPTION", [ + { isTentative: false, completionStatus: "SCHEDULED" }, + { isTentative: false, completionStatus: "SCHEDULED" }, + ]), + ).toEqual({ timings: false, reschedule: true }); + }); + + it("refuses Timings on a confirmed trial", () => { + // A trial is 1:1 and the consultant allocates the time, but the slot is + // created non-tentative and the consultee is notified of it — so it is a + // commitment by the same test as a consultation. The consultant's menu + // offers a trial neither action today, because the reschedule API has no + // TRIAL branch; that gap predates this and is out of scope. + expect(allowsManageTimings("TRIAL", confirmed)).toBe(false); + expect(offered("TRIAL", confirmed)).toEqual({ + timings: false, + reschedule: true, + }); + }); + + it("offers exactly one action in every settled case", () => { + const cases: Array<[AppointmentKind, TestSlot[]]> = [ + ["WEBINAR", confirmed], + ["CLASS", confirmed], + ["CLASS", nothingAllocated], + ["WEBINAR", nothingAllocated], + ["CONSULTATION", tentative], + ["CONSULTATION", nothingAllocated], + ["CONSULTATION", confirmed], + ["SUBSCRIPTION", confirmed], + ["TRIAL", confirmed], + ]; + for (const [kind, slots] of cases) { + const { timings, reschedule } = offered(kind, slots); + expect(timings).not.toBe(reschedule); + } + }); + + it("offers neither only while a proposal is already live", () => { + // The one deliberate gap: a released slot awaiting a new time IS the open + // reschedule, so Reschedule would earn a 409 and Timings would write + // straight over the proposal the consultee is still answering. + const inFlight: TestSlot[] = [ + { isTentative: false, completionStatus: "SCHEDULED" }, + { isTentative: true, completionStatus: "RESCHEDULED" }, + ]; + expect(offered("CONSULTATION", inFlight)).toEqual({ + timings: false, + reschedule: false, + }); + }); +}); + +describe("upcomingSlots", () => { + const now = new Date("2026-08-01T12:00:00Z"); + const hoursFromNow = (h: number) => + new Date(now.getTime() + h * 3_600_000).toISOString(); + + it("drops finished sessions and orders the rest", () => { + const slots = [ + { startsAt: hoursFromNow(48), endsAt: hoursFromNow(49) }, + { startsAt: hoursFromNow(-48), endsAt: hoursFromNow(-47) }, + { startsAt: hoursFromNow(24), endsAt: hoursFromNow(25) }, + ]; + expect(upcomingSlots(slots, now).map((s) => s.startsAt)).toEqual([ + hoursFromNow(24), + hoursFromNow(48), + ]); + }); + + it("keeps Timings on a program whose remaining sessions are unallocated", () => { + // A subscription part-way through: past sessions are confirmed, but the + // consultee holds no future time. Deciding on the raw list would read the + // finished session as a commitment and hide the action. + const slots = [ + { + startsAt: hoursFromNow(-48), + endsAt: hoursFromNow(-47), + isTentative: false, + completionStatus: "COMPLETED", + }, + { + startsAt: hoursFromNow(24), + endsAt: hoursFromNow(25), + isTentative: true, + completionStatus: null, + }, + ]; + expect(allowsManageTimings("SUBSCRIPTION", upcomingSlots(slots, now))).toBe( + true, + ); + }); +}); diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx index 261f1b5f1..7b11a97d3 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx @@ -8,9 +8,11 @@ import type { PrimaryAction, } from "@/lib/appointments/adapter"; import { + allowsManageTimings, CONSULTANT_JOIN_WINDOW_MS, getJoinableSlot, slotsAllowReschedule, + upcomingSlots, } from "@/lib/appointments/slots"; import { isApprovedStatus, @@ -38,7 +40,7 @@ const TYPE_LABEL: Record = { TRIAL: "Trial", }; -/** Webinar/class cancel+reschedule is plan-owner only (API rejects collaborators). */ +/** Webinar/class lifecycle actions are plan-owner only (API rejects collaborators). */ function canManageBookingLifecycle(vm: AppointmentVM): boolean { if (vm.kind === "WEBINAR" || vm.kind === "CLASS") { return !vm.collaboratorRole || vm.collaboratorRole === "HOST"; @@ -54,14 +56,9 @@ function actionableRawSlots(vm: AppointmentVM) { : vm.raw.appointment ? [vm.raw.appointment] : []; - const now = Date.now(); - return sources - .flatMap((a) => a.slotsOfAppointment ?? []) - .filter((slot) => new Date(slot.endsAt).getTime() >= now) - .sort( - (a, b) => - new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(), - ); + // Shared with the timings page's own gate, so the menu cannot offer a route + // that then 404s on a different reading of the same slots (#1082). + return upcomingSlots(sources.flatMap((a) => a.slotsOfAppointment ?? [])); } export function useConsultantAppointmentsAdapter( @@ -209,7 +206,18 @@ export function useConsultantAppointmentsAdapter( const lifecycleOk = canManageBookingLifecycle(vm); const isTrial = vm.kind === "TRIAL"; - if (appointment && vm.bucket !== "cancelled" && vm.bucket !== "past") { + // Manage Timings moves sessions with no notice and no acceptance, so it is + // only offered where nobody else has committed to the time. A 1:1 whose + // consultee already holds a confirmed slot gets Reschedule below instead — + // the two are alternatives, never both (#1082). + const timingsOk = allowsManageTimings(vm.kind, rawSlots); + + if ( + appointment && + vm.bucket !== "cancelled" && + vm.bucket !== "past" && + timingsOk + ) { items.push({ key: "timings", label: "Timings", @@ -231,6 +239,9 @@ export function useConsultantAppointmentsAdapter( lifecycleOk && !inactive && isApprovedStatus(vm.status) && + // Reschedule is the negotiated path, so it belongs exactly where Manage + // Timings does not: a booking a counterparty holds a confirmed time on. + !timingsOk && slotsAllowReschedule(rawSlots) ) { items.push({ diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx index 729ecc0a0..4a2ee7415 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx @@ -4,7 +4,12 @@ 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 { allowsManageTimings, upcomingSlots } from "@/lib/appointments/slots"; +import { readAppointmentDetail } from "@/lib/data/appointment-detail"; +import { + readManageTimingsTarget, + type ManageTimingsTarget, +} from "@/lib/data/manage-timings-target"; import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; import { buildManageTimingsSubject } from "@/lib/scheduling/manage-timings-subject"; @@ -28,6 +33,38 @@ type PageProps = { // React.cache so generateMetadata() and the page body share one query per request. const loadTarget = cache(readManageTimingsTarget); +const loadDetail = cache(readAppointmentDetail); + +/** + * The counterparty gate, server side (#1082). + * + * This surface writes new times with no notice and no acceptance, which is + * honest only while nobody else has committed to one. The menu already hides + * it for a 1:1 whose consultee holds a confirmed slot, but the URL is linkable + * and survives a refresh, so the menu is not the control. + * + * Only the two 1:1 kinds need the extra read, and only they pay for it: a + * group event is allowed regardless. Slots are fetched here rather than + * widened into `ManageTimingsTarget` to stay clear of #1075; `cache` keeps it + * to one query across metadata and body. + */ +async function manageTimingsAllowed( + target: ManageTimingsTarget, + appointmentId: string, +): Promise { + const kind = target.appointment.appointmentType; + if (kind !== "CONSULTATION" && kind !== "SUBSCRIPTION") return true; + + const detail = await loadDetail(appointmentId); + if (!detail) return false; + // Program-wide, same as the menu's group card: a subscription session is one + // Appointment among several and any of them may carry the committed time. + const slots = [ + ...detail.appointment.slotsOfAppointment, + ...detail.siblings.flatMap((sibling) => sibling.slotsOfAppointment), + ]; + return allowsManageTimings(kind, upcomingSlots(slots)); +} /** * Names the offering, not the task. A consultant with several unscheduled @@ -40,10 +77,15 @@ export async function generateMetadata({ 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. + // offering for any id a signed-in consultant cared to try. The counterparty + // gate joins it so a route that 404s never gets a titled tab either. if (!target || !target.planOwnerIds.includes(consultantId)) { return { title: "Manage timings — Familiarise" }; } + const allowed = await manageTimingsAllowed(target, appointmentId).catch( + () => false, + ); + if (!allowed) return { title: "Manage timings — Familiarise" }; const resolved = buildManageTimingsSubject(consultantId, target.appointment); return { title: `Manage timings: ${resolved.title} — Familiarise` }; @@ -65,6 +107,10 @@ export default async function ManageTimingsPage({ // consultant to the session. if (!target.planOwnerIds.includes(consultantId)) notFound(); + // Ownership is not the question a booked consultee cares about — see + // manageTimingsAllowed. Reschedule is the route for that case. + if (!(await manageTimingsAllowed(target, appointmentId))) notFound(); + const resolved = buildManageTimingsSubject( consultantId, target.appointment, @@ -98,8 +144,8 @@ export default async function ManageTimingsPage({ 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. + Max {resolved.classInfo.sessionsPerWeek} classes per day; weekly limit + applies. )} diff --git a/lib/appointments/slots.ts b/lib/appointments/slots.ts index ea1fce737..01a5a76b2 100644 --- a/lib/appointments/slots.ts +++ b/lib/appointments/slots.ts @@ -7,6 +7,7 @@ import { toDate, toDateOrNull, + type AppointmentKind, type SessionVM, type SlotLike, } from "./view-model"; @@ -58,6 +59,52 @@ export function slotsAllowReschedule( return !slots.some((slot) => slot.completionStatus === "RESCHEDULED"); } +/** + * Whether Manage Timings may be offered at all — the menu item AND the page, + * since that URL is linkable (#1082). + * + * Manage Timings writes new times straight onto the calendar: no notice + * requirement, no acceptance from anyone. That is honest only while nobody + * else has committed to a time, so the deciding question is whether a + * counterparty already holds one — not who owns the calendar. + * + * The exact complement of `slotsAllowReschedule` for the surfaces that offer + * both, so a consultant is never handed the unilateral surface and the + * negotiated one for the same booking. + */ +export function allowsManageTimings( + kind: AppointmentKind, + slots: Array<{ isTentative?: boolean | null }>, +): boolean { + // A webinar or class is a published schedule attendees buy into rather than + // a time anyone negotiated, so the organiser keeps this surface even once + // the instance is confirmed — there is no single counterparty to propose to, + // and asking every attendee to accept is not a coherent flow. + if (kind === "WEBINAR" || kind === "CLASS") return true; + // Nothing placed: an offering that was never scheduled, or a booking whose + // sessions are not allocated yet. Still the consultant's own calendar. + if (slots.length === 0) return true; + // Tentative means the request is still awaiting allocation, not booked — + // same reading as slotsAllowReschedule, which refuses on the same test. + return Boolean(slots[0]?.isTentative); +} + +/** + * The slots a time-change decision acts on: still ahead of now, chronological. + * A finished session is not what "has someone committed to a time" is asking + * about, and the first entry has to be the earliest for the tentative test. + */ +export function upcomingSlots< + T extends { startsAt: Date | string; endsAt: Date | string }, +>(slots: T[], now: Date = new Date()): T[] { + const cutoff = now.getTime(); + return slots + .filter((slot) => toDate(slot.endsAt).getTime() >= cutoff) + .sort( + (a, b) => toDate(a.startsAt).getTime() - toDate(b.startsAt).getTime(), + ); +} + function slotTimes(slot: SlotLike): { start: number; end: number } { const start = toDate(slot.startsAt).getTime(); const endsAt = toDateOrNull(slot.endsAt ?? null); From 298a46bf5ac32cbb287eb8c78ca104145f7aa20f Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:59:48 +0530 Subject: [PATCH 2/5] feat(appointments): give group events a proper Unschedule action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gating Manage Timings on whether anyone has committed to a time also took Reschedule off webinars and classes. That was right — for a group event that route never opened a proposal, it only marked every slot tentative and handed the instance back to the allocate queue — but it removed a real capability along with the wrong label. Adds `allowsUnschedule` beside the other two predicates. It is orthogonal to them, not a third branch: a confirmed webinar offers Timings AND Unschedule, a 1:1 offers Reschedule and never Unschedule, and the "never both, never neither" property still holds within the Timings/Reschedule pair. The action routes at the existing reschedule endpoint rather than a parallel implementation, so the behaviour is unchanged and only its name, its gate and its confirm copy are new. Unschedule is emphatically not Cancel. The booking stays sold, attendees stay enrolled, no refund is issued and no earnings, ledger row or utilisation figure moves; the only thing withdrawn is the date. The confirm dialog says all of that plainly and points at Cancel booking for the other outcome, because for a webinar with thirty paid attendees the difference is a new date versus refunding all of them. Attendee notification was already correct on this path: the route's post-transaction fan-out adds every user connected to the event's slots (#624), and a group buyer is connected to every slot of every session, so enrolled attendees are told the date has been withdrawn. Part of #1082 --- .../manage-timings-affordance.test.ts | 95 ++++++++++++++++++- .../ConsultantAppointmentsAdapter.tsx | 35 ++++++- .../components/useConsultantEventActions.ts | 60 +++++++++++- .../UnscheduleConfirmationDialog.tsx | 88 +++++++++++++++++ lib/appointments/slots.ts | 21 ++++ 5 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 components/appointments/UnscheduleConfirmationDialog.tsx diff --git a/__tests__/booking-algorithm/manage-timings-affordance.test.ts b/__tests__/booking-algorithm/manage-timings-affordance.test.ts index ec0fba1c5..dcb80f2b0 100644 --- a/__tests__/booking-algorithm/manage-timings-affordance.test.ts +++ b/__tests__/booking-algorithm/manage-timings-affordance.test.ts @@ -1,15 +1,21 @@ /** - * The Manage Timings menu item's gate, and its complement. + * The three time-change affordances on the consultant's appointment menu. * * Manage Timings writes new times straight onto the calendar with no notice * requirement and no acceptance from anyone, so it may only be offered where * nobody else has committed to a time. Where somebody has, the negotiated * Reschedule takes its place — the two are alternatives, and offering both * would hand a consultant a way around the proposal (#1082). + * + * Unschedule sits outside that pair rather than inside it. It withdraws a + * placed group event's date and puts it back in the allocate queue with the + * sale, the enrolment and the money all untouched, so a confirmed webinar + * offers Timings AND Unschedule, and a 1:1 never offers Unschedule at all. */ import { allowsManageTimings, + allowsUnschedule, slotsAllowReschedule, upcomingSlots, } from "@/lib/appointments/slots"; @@ -32,6 +38,11 @@ function offered(kind: AppointmentKind, slots: TestSlot[]) { return { timings, reschedule: !timings && slotsAllowReschedule(slots) }; } +/** All three menu gates together, which is how a consultant actually meets them. */ +function menu(kind: AppointmentKind, slots: TestSlot[]) { + return { ...offered(kind, slots), unschedule: allowsUnschedule(kind, slots) }; +} + describe("allowsManageTimings", () => { it("keeps Timings on a webinar whose instance is already confirmed", () => { // Attendees bought into a published schedule; there is no counterparty to @@ -135,9 +146,89 @@ describe("allowsManageTimings", () => { { isTentative: false, completionStatus: "SCHEDULED" }, { isTentative: true, completionStatus: "RESCHEDULED" }, ]; - expect(offered("CONSULTATION", inFlight)).toEqual({ + expect(menu("CONSULTATION", inFlight)).toEqual({ timings: false, reschedule: false, + unschedule: false, + }); + }); +}); + +describe("allowsUnschedule", () => { + it("offers a confirmed webinar Timings AND Unschedule, never Reschedule", () => { + // The pair's "exactly one" property is about Timings vs Reschedule only. + // Unschedule is orthogonal: it withdraws the date, Timings sets a new one. + expect(menu("WEBINAR", confirmed)).toEqual({ + timings: true, + reschedule: false, + unschedule: true, + }); + }); + + it("offers a confirmed class Timings AND Unschedule, never Reschedule", () => { + expect(menu("CLASS", confirmed)).toEqual({ + timings: true, + reschedule: false, + unschedule: true, + }); + }); + + it("withholds Unschedule from an offering that was never scheduled", () => { + // No Appointment row, so no slots and no date to withdraw. Timings is the + // surface for putting one on the calendar in the first place. + expect(menu("WEBINAR", nothingAllocated)).toEqual({ + timings: true, + reschedule: false, + unschedule: false, + }); + expect(menu("CLASS", nothingAllocated)).toEqual({ + timings: true, + reschedule: false, + unschedule: false, + }); + }); + + it("withholds Unschedule from an event already unscheduled", () => { + // The release leaves every slot tentative, so the action is idempotent by + // construction rather than by a second guard. + expect(allowsUnschedule("WEBINAR", tentative)).toBe(false); + expect(allowsUnschedule("CLASS", tentative)).toBe(false); + }); + + it("offers Unschedule while any session of a part-released class is still placed", () => { + // A class is several session appointments; the route releases all of them, + // so one still-placed session is enough to have something to withdraw. + const partlyReleased: TestSlot[] = [ + { isTentative: true, completionStatus: "RESCHEDULED" }, + { isTentative: false, completionStatus: "SCHEDULED" }, + ]; + expect(allowsUnschedule("CLASS", partlyReleased)).toBe(true); + }); + + it("never offers Unschedule for a 1:1, whatever its slots look like", () => { + // Releasing a time a counterparty holds is the negotiation Reschedule runs. + const oneToOne: AppointmentKind[] = [ + "CONSULTATION", + "SUBSCRIPTION", + "TRIAL", + ]; + for (const kind of oneToOne) { + for (const slots of [confirmed, tentative, nothingAllocated]) { + expect(allowsUnschedule(kind, slots)).toBe(false); + } + } + }); + + it("gives a confirmed 1:1 Reschedule and neither other action", () => { + expect(menu("CONSULTATION", confirmed)).toEqual({ + timings: false, + reschedule: true, + unschedule: false, + }); + expect(menu("SUBSCRIPTION", confirmed)).toEqual({ + timings: false, + reschedule: true, + unschedule: false, }); }); }); diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx index 7b11a97d3..4d8756d40 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx @@ -9,6 +9,7 @@ import type { } from "@/lib/appointments/adapter"; import { allowsManageTimings, + allowsUnschedule, CONSULTANT_JOIN_WINDOW_MS, getJoinableSlot, slotsAllowReschedule, @@ -28,9 +29,10 @@ import { } from "./utils/participantHelpers"; import { useConsultantEventActions } from "./components/useConsultantEventActions"; import { CancelConfirmationDialog } from "@/components/appointments/consultee/CancelConfirmationDialog"; +import { UnscheduleConfirmationDialog } from "@/components/appointments/UnscheduleConfirmationDialog"; import { ConsultantResponseUpload } from "../documents/ConsultantResponseUpload"; -type DialogKind = "cancel" | "documents"; +type DialogKind = "cancel" | "unschedule" | "documents"; const TYPE_LABEL: Record = { CONSULTATION: "Consultation", @@ -257,6 +259,25 @@ export function useConsultantAppointmentsAdapter( }); } + // Unschedule is not a third branch of the pair above: a confirmed webinar + // offers Timings AND this. It withdraws the date only — the booking stays + // sold, attendees stay enrolled, no money moves — which is the whole of + // what separates it from Cancel below (#1082). + if ( + vm.appointmentId && + lifecycleOk && + !inactive && + // The route's own from-state for a group release is SCHEDULED/IN_PROGRESS. + isConfirmedStatus(vm.status) && + allowsUnschedule(vm.kind, rawSlots) + ) { + items.push({ + key: "unschedule", + label: "Unschedule", + onClick: () => openDialog(vm, "unschedule"), + }); + } + if (vm.appointmentId && !isTrial && lifecycleOk && !inactive) { items.push({ key: "cancel", @@ -313,6 +334,18 @@ export function useConsultantAppointmentsAdapter( isLoading={actions.isLoading} /> + { + await actions.handleUnschedule(); + closeDialog(); + }} + onCancel={closeDialog} + title={activeVm.title} + appointmentType={typeLabel} + isLoading={actions.isLoading} + /> + {activeVm.appointmentId && dialog === "documents" && ( => { + if (!appointmentId) { + toast({ + title: "Error", + description: "Appointment ID is missing", + variant: "destructive", + }); + return false; + } + + setIsLoading(true); + try { + // No `type` param: the route derives it from the DB and only compares + // when one is supplied, so omitting it cannot mismatch. + const response = await fetch( + `/api/appointments/${appointmentId}/reschedule`, + { method: "POST", headers: { "Content-Type": "application/json" } }, + ); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || "Failed to unschedule"); + } + + toast({ + title: `${type} unscheduled`, + description: `"${title}" is off the calendar and back in your queue. Attendees stay enrolled and have been told the date is withdrawn.`, + }); + invalidateBookingData(); + return true; + } catch (error) { + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "client" } }, + ); + toast({ + title: "Error", + description: + error instanceof Error ? error.message : "Failed to unschedule", + variant: "destructive", + }); + return false; + } finally { + setIsLoading(false); + } + }; + const handleCancelConfirm = async () => { if (!appointmentId) { toast({ @@ -194,6 +247,7 @@ export function useConsultantEventActions({ return { isLoading, handleReschedule, + handleUnschedule, handleCancelConfirm, }; } diff --git a/components/appointments/UnscheduleConfirmationDialog.tsx b/components/appointments/UnscheduleConfirmationDialog.tsx new file mode 100644 index 000000000..73625d1a9 --- /dev/null +++ b/components/appointments/UnscheduleConfirmationDialog.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { CalendarX, Loader2 } from "lucide-react"; + +interface UnscheduleConfirmationDialogProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; + title: string; + /** "Webinar" / "Class" — the only kinds this action exists for. */ + appointmentType: string; + isLoading?: boolean; +} + +/** + * Unschedule is one menu row away from Cancel booking and undoes far less, so + * the whole job of this dialog is to make the two impossible to confuse: the + * event stays sold, nobody is refunded, and the only thing withdrawn is the + * date (#1082). Deliberately not styled destructive — it is reversible by + * setting new times, and a red button here would read as "refund everyone". + */ +export function UnscheduleConfirmationDialog({ + isOpen, + onConfirm, + onCancel, + title, + appointmentType, + isLoading = false, +}: Readonly) { + const kind = appointmentType.toLowerCase(); + return ( + !open && onCancel()}> + + + + + Unschedule this {kind}? + + +
+

+ This takes "{title}" off the calendar + and back into your queue, so you can set a new date for it. +

+

+ The {kind} is still sold and everyone enrolled stays enrolled. + Nobody is refunded and your earnings do not change. +

+

+ Attendees will be told the date has been withdrawn, so only do + this if you intend to set a new one. +

+

+ To end the {kind} instead and refund attendees, use{" "} + Cancel booking. +

+
+
+
+ + + Keep the date + + + {isLoading ? ( + <> + + Unscheduling... + + ) : ( + "Unschedule" + )} + + +
+
+ ); +} diff --git a/lib/appointments/slots.ts b/lib/appointments/slots.ts index 01a5a76b2..840286c07 100644 --- a/lib/appointments/slots.ts +++ b/lib/appointments/slots.ts @@ -89,6 +89,27 @@ export function allowsManageTimings( return Boolean(slots[0]?.isTentative); } +/** + * Whether Unschedule may be offered — pulling a placed group event off the + * calendar and back into the allocate queue, without cancelling it (#1082). + * + * Orthogonal to the Timings/Reschedule pair rather than a third branch of it. + * A confirmed webinar offers Timings AND this; a 1:1 never offers it, because + * releasing a time a counterparty holds is the negotiation Reschedule already + * runs. It is emphatically NOT Cancel: the booking stays sold, attendees stay + * enrolled, and no money, earnings or ledger row moves. + */ +export function allowsUnschedule( + kind: AppointmentKind, + slots: Array<{ isTentative?: boolean | null }>, +): boolean { + if (kind !== "WEBINAR" && kind !== "CLASS") return false; + // Nothing placed yet — an offering that was never scheduled, or one already + // unscheduled (the release leaves every slot tentative). No date to withdraw, + // and Timings is the surface for setting one. + return slots.some((slot) => !slot.isTentative); +} + /** * The slots a time-change decision acts on: still ahead of now, chronological. * A finished session is not what "has someone committed to a time" is asking From 1e3cf13814c4296cfd18437852163641183d59be Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:00:14 +0530 Subject: [PATCH 3/5] fix(notifications): a reschedule with no new time no longer says "from to" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consultant's inbox rendered "rescheduled ... from  to". The payload type declared `oldDateTime?` and `newDateTime?`, the reschedule route passed neither, and the template interpolated both anyway. Passing two values would not have fixed it, because the sentence is wrong for the common case. A plain release has no destination — that is the entire point of "Any time works": the slots go back to the consultant's queue and no new time exists yet. Only an auto-confirmed proposal actually moved anything, and between those two sits a third case the route already distinguishes in its own response message, a proposal that has been made and is waiting to be answered. So the payload now carries `outcome`, one of MOVED, PROPOSED or RELEASED, and the two arms are a union rather than optional fields: MOVED and PROPOSED cannot be constructed without both times, which makes the blank-blank payload a compile error rather than a rendering accident. This is the idiom the org workflows already use — `reminderStage` on the dunning notice and `kind` on the payout failure both drive their copy from one workflow id. `rescheduleNotificationVariant` in the policy module derives the variant, so the route stays a caller and the rule is unit-testable. The released time is captured inside the transaction because an auto-confirm deletes those slot rows and writes new ones — by the time the notification is assembled the time being given up exists nowhere else. The template itself lives in Novu's dashboard and cannot be changed from here. Until it branches on `outcome`, the moved case renders real times where it rendered blanks, and the released case still renders the wrong sentence. The Novu-side change is spelled out in the PR body. An audit of every optional field on every payload in lib/novu found the same shape once more: `SupportTicketPayload.respondedBy` is declared and never passed, so a template naming the responder rendered an empty attribution. It is passed now. Three more are declared and never passed but need a query or a loop change to supply, and are listed in the PR body rather than fixed here. --- .../reschedule-proposals.test.ts | 65 +++++++++++++++++++ .../[appointmentId]/reschedule/route.ts | 28 ++++++++ .../[ticketId]/responses/route.ts | 4 ++ lib/booking/reschedule-proposals.ts | 38 +++++++++++ lib/novu/workflows.ts | 38 +++++++++-- 5 files changed, 169 insertions(+), 4 deletions(-) diff --git a/__tests__/booking-algorithm/reschedule-proposals.test.ts b/__tests__/booking-algorithm/reschedule-proposals.test.ts index 5057aea23..7a7bc5b84 100644 --- a/__tests__/booking-algorithm/reschedule-proposals.test.ts +++ b/__tests__/booking-algorithm/reschedule-proposals.test.ts @@ -12,6 +12,7 @@ import { computeProposalExpiry, mayAutoConfirm, proposalCountMatches, + rescheduleNotificationVariant, supportsProposals, } from "../../lib/booking/reschedule-proposals"; import { @@ -155,3 +156,67 @@ describe("allocationRequestSchema carries override", () => { ); }); }); + +describe("rescheduleNotificationVariant", () => { + const released = new Date("2026-08-10T09:00:00Z"); + const proposed = new Date("2026-08-12T14:00:00Z"); + + it("carries both times when the proposal auto-confirmed", () => { + expect( + rescheduleNotificationVariant({ + releasedAt: released, + proposedAt: proposed, + autoConfirmed: true, + }), + ).toEqual({ + outcome: "MOVED", + oldDateTime: released.toISOString(), + newDateTime: proposed.toISOString(), + }); + }); + + it("distinguishes a proposal still awaiting an answer from a confirmed move", () => { + expect( + rescheduleNotificationVariant({ + releasedAt: released, + proposedAt: proposed, + autoConfirmed: false, + }), + ).toEqual({ + outcome: "PROPOSED", + oldDateTime: released.toISOString(), + newDateTime: proposed.toISOString(), + }); + }); + + it("emits no destination for a plain release", () => { + // The bug this replaces: a template rendering "from {{oldDateTime}} to + // {{newDateTime}}" against a payload carrying neither, which reached the + // consultant's inbox as "rescheduled ... from to". + const variant = rescheduleNotificationVariant({ + releasedAt: released, + proposedAt: null, + autoConfirmed: false, + }); + + expect(variant).toEqual({ + outcome: "RELEASED", + oldDateTime: released.toISOString(), + }); + // Absent, not undefined — an explicit key would still serialize into the + // payload Novu interpolates. + expect("newDateTime" in variant).toBe(false); + }); + + it("omits the released time too when there is none to report", () => { + const variant = rescheduleNotificationVariant({ + releasedAt: null, + proposedAt: proposed, + autoConfirmed: true, + }); + + // A destination alone cannot render "moved from X to Y", so it degrades to + // the released sentence rather than half-filling the other one. + expect(variant).toEqual({ outcome: "RELEASED" }); + }); +}); diff --git a/app/api/appointments/[appointmentId]/reschedule/route.ts b/app/api/appointments/[appointmentId]/reschedule/route.ts index 0b66544d5..c652759f3 100644 --- a/app/api/appointments/[appointmentId]/reschedule/route.ts +++ b/app/api/appointments/[appointmentId]/reschedule/route.ts @@ -8,6 +8,7 @@ import { RescheduleProposalSchema } from "@/schemas/appointments"; import { computeProposalExpiry, proposalCountMatches, + rescheduleNotificationVariant, supportsProposals, } from "@/lib/booking/reschedule-proposals"; import { @@ -537,10 +538,20 @@ export async function POST( const rescheduleType = getRescheduleType(); + // Captured here because an auto-confirm deletes these rows and writes + // new ones — by the time the notification is built, the time being + // given up no longer exists anywhere. + const releasedAt = slotsToReschedule.reduce( + (earliest, slot) => + !earliest || slot.startsAt < earliest ? slot.startsAt : earliest, + null, + ); + // Return detailed response return { success: true, rescheduleType, + releasedAt, // #448 — sessionsAffected is the user-facing count (distinct sessions); // slotsAffected stays for back-compat / debugging. sessionsAffected, @@ -751,9 +762,26 @@ export async function POST( ? "webinar" : "class"; + // Earliest of the times asked for, and only when a proposal actually + // opened: a group event never carries one, so it is always a release. + const proposedAt = result.rescheduleRequestId + ? (proposedSlots?.reduce( + (earliest, slot) => + !earliest || slot.startsAt < earliest + ? slot.startsAt + : earliest, + null, + ) ?? null) + : null; + if (uniqueUserIds.length > 0) { void notifyAppointmentRescheduled(uniqueUserIds, { ...notificationScope(appointment.organizationId), + ...rescheduleNotificationVariant({ + releasedAt: result.releasedAt, + proposedAt, + autoConfirmed, + }), appointmentType, consultantName: plan?.consultantProfile?.user?.name ?? "Consultant", consulteeName: requestedBy?.user?.name ?? "Participant", diff --git a/app/api/staff/support-tickets/[ticketId]/responses/route.ts b/app/api/staff/support-tickets/[ticketId]/responses/route.ts index 796c646ab..2c6ce533d 100644 --- a/app/api/staff/support-tickets/[ticketId]/responses/route.ts +++ b/app/api/staff/support-tickets/[ticketId]/responses/route.ts @@ -86,6 +86,10 @@ export async function POST(req: NextRequest, { params }: RouteParams) { ticketId: ticket.id, ticketTitle: ticket.title || "Support Ticket", message: validatedData.message, + // Declared on the payload and never passed, so a template naming the + // responder rendered an empty attribution — same shape as the blank + // reschedule times. + respondedBy: response.user?.name ?? "Support", dashboardUrl: "/dashboard", }); } diff --git a/lib/booking/reschedule-proposals.ts b/lib/booking/reschedule-proposals.ts index 6c02edc12..391c12c63 100644 --- a/lib/booking/reschedule-proposals.ts +++ b/lib/booking/reschedule-proposals.ts @@ -18,6 +18,7 @@ */ import type { AppointmentsType, RescheduleInitiatorRole } from "@prisma/client"; +import type { RescheduleOutcomeFields } from "@/lib/novu/workflows"; /** Ceiling on how long an unanswered proposal may sit. */ export const PROPOSAL_MAX_LIFETIME_HOURS = 72; @@ -107,6 +108,43 @@ export function proposalCountMatches( return releasedSlotCount === proposedSlotCount; } +/** + * Which sentence the reschedule notification should render. + * + * The three outcomes the route already distinguishes in its response message + * are the same three a recipient needs told apart: you were moved, you were + * asked, or the time was given up and nobody has picked a new one. Collapsing + * them into one "moved from X to Y" template is what left the released case + * interpolating two empty strings. + * + * `releasedAt` is the earliest slot handed back, `proposedAt` the earliest time + * asked for — earliest rather than all of them because a multi-session + * reschedule still needs one sentence. + */ +export function rescheduleNotificationVariant(args: { + releasedAt: Date | null; + proposedAt: Date | null; + autoConfirmed: boolean; +}): RescheduleOutcomeFields { + const { releasedAt, proposedAt, autoConfirmed } = args; + + // A destination needs both ends: the arms carrying `newDateTime` also carry + // `oldDateTime`, so a missing released time falls back to RELEASED rather + // than half-filling the sentence. + if (releasedAt && proposedAt) { + return { + outcome: autoConfirmed ? "MOVED" : "PROPOSED", + oldDateTime: releasedAt.toISOString(), + newDateTime: proposedAt.toISOString(), + }; + } + + return { + outcome: "RELEASED", + ...(releasedAt ? { oldDateTime: releasedAt.toISOString() } : {}), + }; +} + /* * There is deliberately no counter-round. * diff --git a/lib/novu/workflows.ts b/lib/novu/workflows.ts index 9e3bb9a68..3da2cb913 100644 --- a/lib/novu/workflows.ts +++ b/lib/novu/workflows.ts @@ -184,10 +184,40 @@ export type AppointmentCancelledPayload = AppointmentPayload & { cancelledBy: "consultant" | "consultee" | "system"; }; -export type AppointmentRescheduledPayload = AppointmentPayload & { - oldDateTime?: string; - newDateTime?: string; -}; +/** + * Which of the three reschedule outcomes happened, and therefore which sentence + * the `appointment-rescheduled` template must render. + * + * A reschedule does not always have a destination. "Any time works" is the + * common case — the slots go back to the consultant's queue and no new time + * exists yet — so a template that always says "moved from X to Y" has nothing + * to put in either blank. The discriminator makes that a template branch rather + * than two empty interpolations, the same way `OrgInvoiceOverduePayload` + * (`reminderStage`) and `OrgPayoutFailedPayload` (`kind`) drive their copy. + * + * The arms are unions rather than optional fields on purpose: MOVED and + * PROPOSED cannot be constructed without both times, so the blank-blank payload + * that produced "from  to" is now a compile error. + */ +export type RescheduleOutcomeFields = + | { + /** MOVED: auto-confirmed, the booking now holds `newDateTime`. + * PROPOSED: `newDateTime` was asked for and awaits the other party. */ + outcome: "MOVED" | "PROPOSED"; + oldDateTime: string; + newDateTime: string; + } + | { + /** Slots released with no replacement time — awaiting a new one. */ + outcome: "RELEASED"; + oldDateTime?: string; + newDateTime?: never; + }; + +// `dateTime` from AppointmentPayload is deliberately unused here: a reschedule +// is about the pair of times, not a single one. +export type AppointmentRescheduledPayload = AppointmentPayload & + RescheduleOutcomeFields; export type PaymentSuccessPayload = NotificationScope & { amount: number; From 90acda55c6c117e66684c2f53f42454b3acff6b7 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:00:14 +0530 Subject: [PATCH 4/5] fix(notifications): stop the inbox panel parking over the slot grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification panel covered the Friday and Saturday columns of the calendar at a normal laptop width. Two things put it there. It was Novu's own bundled popover, fixed at 400px on a bespoke z-index of 9999 with no collision handling; and its open state belonged to Novu, so clicking a notification routed the user to the page the notification was about and then stayed open on top of it. The panel a consultant saw over their calendar was usually the one that had just sent them there. Given children, `Inbox` drops to being a provider — `Bell` and `InboxContent` are the composition parts — so the panel becomes ours to place. It is now the repo's Radix popover from components/ui, which brings collision-aware placement, a viewport-capped size, Escape and outside-click dismissal, and the shared z-layer instead of a number picked to beat everything else on the page. Because the open state is ours, the panel closes before routing. It is also narrower: 22rem against the old 25rem, capped at the viewport on both axes, which leaves more of the grid legible while it is open. Presentational only. The subscriber, the ADR 23 scope tabs, the appearance variables and the click-to-route behaviour are unchanged; the two appearance keys that styled Novu's popover are dropped because that popover no longer renders, and `bellContainer` with them, since a custom `renderBell` has always bypassed the container it styled. --- .../notifications/NotificationInbox.tsx | 120 +++++++++++------- 1 file changed, 75 insertions(+), 45 deletions(-) diff --git a/components/notifications/NotificationInbox.tsx b/components/notifications/NotificationInbox.tsx index 66a03a330..3e59f6595 100644 --- a/components/notifications/NotificationInbox.tsx +++ b/components/notifications/NotificationInbox.tsx @@ -1,10 +1,15 @@ "use client"; -import { useMemo } from "react"; -import { Inbox } from "@novu/nextjs"; -import { Bell } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Bell, Inbox, InboxContent } from "@novu/nextjs"; +import { Bell as BellIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useSession } from "@/lib/auth-client"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; const NOVU_APP_ID = process.env.NEXT_PUBLIC_NOVU_APP_ID; @@ -16,6 +21,7 @@ type OrgMembershipLite = { export function NotificationInbox() { const router = useRouter(); const { data: session } = useSession(); + const [open, setOpen] = useState(false); const memberships = useMemo(() => { const raw = (session?.user as Record | undefined) @@ -68,36 +74,24 @@ export function NotificationInbox() { return null; } + /** + * Novu's bundled popover is deliberately not used. Given children, `Inbox` + * drops to a provider and the panel becomes ours to place, which is what the + * calendar needed: its own popover took a fixed 400px at a bespoke z-index of + * 9999, could not be closed programmatically, and so stayed parked over the + * Friday and Saturday columns of the slot grid after a notification click + * navigated there. + * + * The repo's Radix popover fixes the placement (collision-aware, capped at + * the viewport, on the shared z-layer) and, because the open state is ours, + * the panel dismisses itself before routing rather than following the user + * onto the page they asked for. + */ return ( { - const count = unreadCount?.total ?? 0; - return ( -
0 ? ` (${count} unread)` : ""}`} - > - - {count > 0 && ( - - {count > 99 ? "99+" : count} - - )} -
- ); - }} - onNotificationClick={(notification) => { - const url = notification?.redirect?.url; - if (url) { - router.push(url); - } - }} appearance={{ variables: { colorBackground: "#ffffff", @@ -114,28 +108,64 @@ export function NotificationInbox() { borderRadius: "0.5rem", }, elements: { - popoverContent: { - zIndex: 9999, - width: "min(400px, calc(100vw - 2rem))", - maxHeight: "calc(100vh - 6rem)", - borderRadius: "0.75rem", - boxShadow: - "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)", - border: "1px solid #e4e4e7", - overflowY: "auto", - }, - popoverTrigger: { - zIndex: 9998, - }, - bellContainer: { - display: "contents", - }, notification: { padding: "12px 16px", gap: "12px", }, }, }} - /> + > + + + + + {/* Narrower than the old 400px and collision-padded so the panel stays + inside the viewport instead of running to the window edge over the + page it is anchored above. */} + + { + const url = notification?.redirect?.url; + // Closed first: the panel must not still be sitting over the + // grid it just sent the user to. + setOpen(false); + if (url) { + router.push(url); + } + }} + /> + + +
); } From 2640f2fa04256f3f23e5df4330af163a3e012ed6 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:18:42 +0530 Subject: [PATCH 5/5] fix(appointments): check every upcoming slot, not just the earliest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partial reschedule releases one session of a multi-session booking and leaves the rest confirmed. The released slot can sort first, so reading only `slots[0].isTentative` saw "tentative", concluded nobody had committed, and handed the consultant Manage Timings — the unilateral surface — for a booking whose later sessions the consultee still holds. The existing in-flight test happened to put the released slot second, which is why it passed. The new case puts it first, and fails against the old predicate. Part of #1082 --- .../manage-timings-affordance.test.ts | 22 +++++++++++++++++++ lib/appointments/slots.ts | 9 +++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/__tests__/booking-algorithm/manage-timings-affordance.test.ts b/__tests__/booking-algorithm/manage-timings-affordance.test.ts index dcb80f2b0..bed5d5dfa 100644 --- a/__tests__/booking-algorithm/manage-timings-affordance.test.ts +++ b/__tests__/booking-algorithm/manage-timings-affordance.test.ts @@ -152,6 +152,28 @@ describe("allowsManageTimings", () => { unschedule: false, }); }); + + it("refuses Timings when a LATER session is still committed", () => { + // The reverse of the case above, and the one that actually bit: a partial + // reschedule releases one session of a multi-session booking, so the + // earliest slot is the released one while the consultee still holds a + // confirmed time afterwards. Reading only slots[0] saw "tentative" and + // handed back the unilateral surface. + const releasedFirst: TestSlot[] = [ + { isTentative: true, completionStatus: "RESCHEDULED" }, + { isTentative: false, completionStatus: "SCHEDULED" }, + ]; + expect(menu("SUBSCRIPTION", releasedFirst).timings).toBe(false); + expect(menu("CONSULTATION", releasedFirst).timings).toBe(false); + }); + + it("still offers Timings when every upcoming session is unallocated", () => { + const allTentative: TestSlot[] = [ + { isTentative: true, completionStatus: null }, + { isTentative: true, completionStatus: null }, + ]; + expect(menu("SUBSCRIPTION", allTentative).timings).toBe(true); + }); }); describe("allowsUnschedule", () => { diff --git a/lib/appointments/slots.ts b/lib/appointments/slots.ts index 840286c07..1f4952864 100644 --- a/lib/appointments/slots.ts +++ b/lib/appointments/slots.ts @@ -84,9 +84,12 @@ export function allowsManageTimings( // Nothing placed: an offering that was never scheduled, or a booking whose // sessions are not allocated yet. Still the consultant's own calendar. if (slots.length === 0) return true; - // Tentative means the request is still awaiting allocation, not booked — - // same reading as slotsAllowReschedule, which refuses on the same test. - return Boolean(slots[0]?.isTentative); + // EVERY upcoming slot, not just the earliest. A partial reschedule releases + // one session of a multi-session booking and leaves the rest confirmed, so + // the first slot chronologically can be the released one while a consultee + // still holds a committed time later in the same booking. Reading only + // `slots[0]` handed back the unilateral surface in exactly that case. + return slots.every((slot) => Boolean(slot.isTentative)); } /**