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..bed5d5dfa --- /dev/null +++ b/__tests__/booking-algorithm/manage-timings-affordance.test.ts @@ -0,0 +1,297 @@ +/** + * 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"; +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) }; +} + +/** 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 + // 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(menu("CONSULTATION", inFlight)).toEqual({ + timings: false, + reschedule: false, + 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", () => { + 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, + }); + }); +}); + +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/__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/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx index 261f1b5f1..4d8756d40 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx @@ -8,9 +8,12 @@ import type { PrimaryAction, } from "@/lib/appointments/adapter"; import { + allowsManageTimings, + allowsUnschedule, CONSULTANT_JOIN_WINDOW_MS, getJoinableSlot, slotsAllowReschedule, + upcomingSlots, } from "@/lib/appointments/slots"; import { isApprovedStatus, @@ -26,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", @@ -38,7 +42,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 +58,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 +208,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 +241,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({ @@ -246,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", @@ -302,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" && ( { + 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/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts index 45f48028d..36f35c38c 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts @@ -83,9 +83,7 @@ export function useConsultantEventActions({ const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: Object.keys(payload).length - ? JSON.stringify(payload) - : undefined, + body: Object.keys(payload).length ? JSON.stringify(payload) : undefined, }); const data = await response.json(); @@ -133,6 +131,61 @@ export function useConsultantEventActions({ } }; + /** + * Withdraw a published group event's date without ending the booking. + * + * Same route as `handleReschedule`, deliberately: for a WEBINAR/CLASS that + * call never opened a proposal — there is no single counterparty to propose + * to — it only released the slots back to the allocate queue. That behaviour + * was correct and is what this names (#1082). Nothing here touches money, + * enrolment, earnings or the ledger; that is Cancel. + */ + const handleUnschedule = async (): Promise => { + 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/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); + } + }} + /> + + +
); } diff --git a/lib/appointments/slots.ts b/lib/appointments/slots.ts index ecfe5684b..bb938af76 100644 --- a/lib/appointments/slots.ts +++ b/lib/appointments/slots.ts @@ -4,7 +4,12 @@ * the consultee useEventActions hook and the consultant joinState util. */ -import { toDate, toDateOrNull, type SessionVM } from "./view-model"; +import { + toDate, + toDateOrNull, + type AppointmentKind, + type SessionVM, +} from "./view-model"; export const DEFAULT_MEETING_DURATION_MS = 60 * 60 * 1000; @@ -81,6 +86,76 @@ 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; + // 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)); +} + +/** + * 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 + * 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: SessionSlotLike): { start: number; end: number } { const start = toDate(slot.startsAt).getTime(); const endsAt = toDateOrNull(slot.endsAt ?? null); 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;