diff --git a/__tests__/booking-algorithm/consultee-affordances.test.ts b/__tests__/booking-algorithm/consultee-affordances.test.ts index 5da1c5b15..865d5e6f2 100644 --- a/__tests__/booking-algorithm/consultee-affordances.test.ts +++ b/__tests__/booking-algorithm/consultee-affordances.test.ts @@ -1,6 +1,9 @@ import { consulteeDestructiveAction, consulteeMayReschedule, + currentRoundProposedSlots, + openProposalTarget, + type OpenRescheduleProposal, } from "@/lib/appointments/consultee-affordances"; describe("#1005 consultee affordances", () => { @@ -20,3 +23,39 @@ describe("#1005 consultee affordances", () => { expect(consulteeDestructiveAction("CLASS")).toBe("leave-event"); }); }); + +describe("#1163 open proposal detection", () => { + const proposal = (round: number): OpenRescheduleProposal => ({ + id: `req-${round}`, + status: "PENDING_REVIEW", + reason: null, + round, + expiresAt: "2026-08-20T10:00:00.000Z", + initiatorRole: "CONSULTANT", + initiatedById: "user-1", + proposedSlots: [ + { startsAt: "2026-08-21T10:00:00.000Z", endsAt: "2026-08-21T10:30:00.000Z", round: 1 }, + { startsAt: "2026-08-22T10:00:00.000Z", endsAt: "2026-08-22T10:30:00.000Z", round: 2 }, + ], + }); + + it("finds the proposal on whichever appointment carries it — not the anchor", () => { + const target = openProposalTarget([ + { id: "anchor", rescheduleRequests: [] }, + undefined, + { id: "sibling", rescheduleRequests: [proposal(1)] }, + ]); + expect(target?.appointmentId).toBe("sibling"); + expect(target?.proposal.id).toBe("req-1"); + }); + + it("returns null when nothing is open (the reads pre-narrow to open statuses)", () => { + expect(openProposalTarget([{ id: "a" }, null, undefined])).toBeNull(); + }); + + it("shows only the current round of a countered request", () => { + const slots = currentRoundProposedSlots(proposal(2)); + expect(slots).toHaveLength(1); + expect(slots[0].round).toBe(2); + }); +}); diff --git a/__tests__/booking-algorithm/reschedule-respond-ui.test.ts b/__tests__/booking-algorithm/reschedule-respond-ui.test.ts new file mode 100644 index 000000000..c3778badc --- /dev/null +++ b/__tests__/booking-algorithm/reschedule-respond-ui.test.ts @@ -0,0 +1,113 @@ +/** + * #1163 / #1169 PR 4b (UI half) — the respond endpoint has UI callers, and + * withdraw has one. Source contracts: these assert the wiring that turns the + * API loop shipped in PR 4a into something a consultee can actually click. + */ + +import fs from "fs"; +import path from "path"; + +const read = (rel: string) => + fs.readFileSync(path.join(process.cwd(), rel), "utf8"); + +const proposalCard = read( + "components/appointments/detail/RescheduleProposalCard.tsx", +); +const detailClient = read( + "components/appointments/detail/AppointmentDetailClient.tsx", +); +const adapter = read( + "components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx", +); +const eventActions = read( + "components/appointments/consultee/useEventActions.ts", +); +const allocationTab = read( + "components/dashboard/shared/requests/RequestSlotAllocationTab.tsx", +); +const reschedulePage = read( + "app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx", +); + +describe("#1163 — the proposal card answers through the lifecycle endpoints", () => { + it("calls respond with an action and withdraw without one", () => { + expect(proposalCard).toContain("/reschedule/respond"); + expect(proposalCard).toContain("/reschedule/withdraw"); + expect(proposalCard).toContain("JSON.stringify({ action: kind })"); + }); + + it("splits the affordances by identity: counterparty answers, initiator withdraws", () => { + expect(proposalCard).toContain("proposal.initiatedById"); + expect(proposalCard).toContain('mutation.mutate("withdraw")'); + expect(proposalCard).toContain('mutation.mutate("accept")'); + expect(proposalCard).toContain('mutation.mutate("decline")'); + }); + + it("decline confirms first and says the booking is not being cancelled", () => { + expect(proposalCard).toContain("not"); + expect(proposalCard).toContain("cancelling the booking"); + expect(proposalCard).toContain("AlertDialog"); + }); + + it("invalidates the detail and events caches and relays the server message", () => { + expect(proposalCard).toContain('["appointment-detail", appointmentId]'); + expect(proposalCard).toContain('["consultee-events"]'); + expect(proposalCard).toContain("description: data.message"); + }); + + it("mounts on the shared detail page off the live rescheduleRequests read", () => { + expect(detailClient).toContain("RescheduleProposalCard"); + expect(detailClient).toContain("rescheduleRequests?.[0]"); + }); +}); + +describe("#1163 — the consultee list surfaces the proposal", () => { + it("the adapter navigates to the appointment CARRYING the proposal", () => { + expect(adapter).toContain("openProposalTarget"); + expect(adapter).toContain("groupAppointments"); + expect(adapter).toContain("proposalTarget.appointmentId"); + }); +}); + +describe("#1163 — cancel/reschedule invalidation reaches the detail hub", () => { + it("useEventActions always invalidates appointment-detail", () => { + expect(eventActions).toContain('["appointment-detail", appointmentId]'); + }); + + it("the adapter threads its resolved consulteeId instead of trusting useParams", () => { + expect(eventActions).toContain("consulteeIdOverride"); + // The adapter passes the id it resolved (options → params → session). + expect(adapter).toMatch(/useEventActions\(\{[\s\S]*?consulteeId,[\s\S]*?\}\)/); + }); +}); + +describe("#1163 — the consultant inbox answers proposals", () => { + it("Use Requested Times routes an answerable proposal through respond-accept", () => { + expect(allocationTab).toContain("answerableProposal"); + expect(allocationTab).toContain("/reschedule/respond"); + expect(allocationTab).toContain('action: "accept"'); + // The suppression is lifted BY the proposal, not removed outright. + expect(allocationTab).toContain("rescheduledSlotCount"); + }); + + it("only a PENDING_REVIEW consultee-initiated proposal with times is answerable", () => { + expect(allocationTab).toContain('proposal.status !== "PENDING_REVIEW"'); + expect(allocationTab).toContain('proposal.initiatorRole !== "CONSULTEE"'); + expect(allocationTab).toContain("currentRoundSlots(proposal).length === 0"); + }); + + it("decline is confirmed, covers subscriptions, and disables in flight", () => { + expect(allocationTab).toContain("handleDeclineConfirm"); + expect(allocationTab).toContain("/api/bookings/subscriptions/"); + expect(allocationTab).toContain('JSON.stringify({ status: "REJECTED" })'); + expect(allocationTab).toContain("declining"); + expect(allocationTab).toContain("AlertDialog"); + }); +}); + +describe("#1163 — the reschedule page refuses trial subjects", () => { + it("renders a friendly refusal instead of a picker that 403s at submit", () => { + expect(reschedulePage).toContain('appointmentType === "TRIAL"'); + expect(reschedulePage).toContain("can't be rescheduled"); + }); +}); 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 8e1607900..fff13f671 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx @@ -1,7 +1,10 @@ import { cache } from "react"; import type { Metadata } from "next"; +import Link from "next/link"; import { notFound } from "next/navigation"; +import { CalendarX } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { DashboardViewportFill } from "@/components/dashboard/DashboardViewportFill"; import { PanelHeader } from "@/components/dashboard/PageScaffold"; import prisma from "@/lib/prisma"; @@ -107,6 +110,33 @@ export default async function RescheduleAppointmentPage({ notFound(); } + // #1163 — trials have no reschedule path (the API rejects them at submit), + // so refuse before drawing a picker whose submit can only fail. + if (detail.appointment.appointmentType === "TRIAL") { + return ( + +
+
+ +
+

+ Trial sessions can't be rescheduled +

+

+ A trial is a one-off taster at the time your consultant offered. + If it no longer works, cancel it and request a new one, or message + your consultant. +

+ +
+
+ ); + } + // 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. diff --git a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx index 654584ade..dec7ca21b 100644 --- a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx +++ b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx @@ -23,6 +23,8 @@ import { import { consulteeDestructiveAction, consulteeMayReschedule, + openProposalTarget, + type OpenRescheduleProposal, } from "@/lib/appointments/consultee-affordances"; import { isApprovedStatus, @@ -87,6 +89,27 @@ function sourceId(vm: AppointmentVM): string | null { } } +/** + * The open reschedule proposal on this row, and which appointment carries it. + * + * Scans the anchor AND the group children: a subscription row anchors on its + * next actionable child while the proposal may sit on another. The cast is the + * `sourceId` idiom — `TAppointment` predates the `rescheduleRequests` include + * the consultee reads now carry. #1163 + */ +function rowProposalTarget( + vm: AppointmentVM, +): { appointmentId: string; proposal: OpenRescheduleProposal } | null { + type WithProposals = { + id: string; + rescheduleRequests?: OpenRescheduleProposal[]; + }; + return openProposalTarget([ + vm.raw.appointment as WithProposals | undefined, + ...((vm.raw.groupAppointments ?? []) as WithProposals[]), + ]); +} + const KIND_TO_TYPE: Record< AppointmentVM["kind"], "Consultation" | "Subscription" | "Webinar" | "Class" | "Trial" @@ -147,6 +170,9 @@ export function useConsulteeAppointmentsAdapter(options?: { title: activeVm?.title ?? "", consultant: activeVm?.counterpart.name ?? "", type: typeLabel, + // The hook falls back to useParams, which the org detail route lacks — + // thread the id this adapter already resolved. #1163 + consulteeId, }); const closeDialog = () => setDialog(null); @@ -241,6 +267,20 @@ export function useConsulteeAppointmentsAdapter(options?: { const items: OverflowItem[] = []; const inactive = isInactiveStatus(vm.status); const slots = vm.raw.rawSlots ?? []; + // #1163 — a live proposal outranks every other action: until it is + // answered the row just says "awaiting confirmation". Navigation, not a + // dialog — the detail page hosts the card with accept/decline/withdraw. + const proposalTarget = rowProposalTarget(vm); + if (proposalTarget && consulteeId && !inactive) { + items.push({ + key: "reschedule-proposal", + label: "Review reschedule request", + onClick: () => + router.push( + `/dashboard/consultee/${consulteeId}/appointments/${proposalTarget.appointmentId}`, + ), + }); + } // #1005 — kind-gate: only offer actions the server will honour. if ( consulteeMayReschedule(vm.kind) && @@ -330,6 +370,13 @@ export function useConsulteeAppointmentsAdapter(options?: { }; const invalidateBookings = () => { + // The detail hub renders the trial/event just acted on — refresh it even + // when no consulteeId resolved (org mounts). #1163 + if (activeVm?.appointmentId) { + void queryClient.invalidateQueries({ + queryKey: ["appointment-detail", activeVm.appointmentId], + }); + } if (!consulteeId) return; void queryClient.invalidateQueries({ queryKey: ["consultee-events", consulteeId], diff --git a/components/appointments/consultee/useEventActions.ts b/components/appointments/consultee/useEventActions.ts index 76b8dd422..fde916e2a 100644 --- a/components/appointments/consultee/useEventActions.ts +++ b/components/appointments/consultee/useEventActions.ts @@ -22,6 +22,9 @@ interface UseEventActionsOptions { title: string; consultant: string; type: "Consultation" | "Subscription" | "Webinar" | "Class" | "Trial"; + /** #1163 — the org detail route has no `[consulteeId]` param, so a caller + * that resolved the id another way must thread it or invalidation no-ops. */ + consulteeId?: string; } /** @@ -109,19 +112,29 @@ export function useEventActions({ title, consultant: _consultant, type, + consulteeId: consulteeIdOverride, }: UseEventActionsOptions) { const { toast } = useToast(); const router = useRouter(); const queryClient = useQueryClient(); const params = useParams<{ consulteeId: string }>(); - const consulteeId = params?.consulteeId; + // Caller's resolved id first: on the org detail route the param is absent, + // and trusting it alone made every invalidation there a silent no-op. #1163 + const consulteeId = consulteeIdOverride || params?.consulteeId; // Refresh every surface that renders this booking (events across all org // scopes via prefix match, plus the home pending-payments widget) without // the full-page reload that used to nuke the react-query cache and SPA // state after cancel/reschedule. const invalidateBookingData = () => { - // Outside the consultee route the param is absent; an undefined key + // The detail hub renders this booking on every dashboard — always + // refresh it, whatever route the action came from. #1163 + if (appointmentId) { + void queryClient.invalidateQueries({ + queryKey: ["appointment-detail", appointmentId], + }); + } + // Outside the consultee route the id may be absent; an undefined key // segment would silently match nothing — bail instead. if (!consulteeId) return; void queryClient.invalidateQueries({ diff --git a/components/appointments/detail/AppointmentDetailClient.tsx b/components/appointments/detail/AppointmentDetailClient.tsx index 44c91d066..95cbc553d 100644 --- a/components/appointments/detail/AppointmentDetailClient.tsx +++ b/components/appointments/detail/AppointmentDetailClient.tsx @@ -38,6 +38,7 @@ import { CountdownBadge } from "../CountdownBadge"; import { KIND_LABEL } from "../AppointmentRow"; import { RowPrimaryAction } from "../RowPrimaryAction"; import { SessionTimeline } from "../SessionTimeline"; +import { RescheduleProposalCard } from "./RescheduleProposalCard"; import { SupportThreadSheet } from "@/components/support/SupportThreadSheet"; import { AppointmentCsatCard } from "@/components/support/AppointmentCsatCard"; @@ -161,7 +162,11 @@ export function AppointmentDetailClient({ const { vm, recordings } = mapped; const action = adapter.primaryAction(vm); - const overflow = adapter.overflowItems(vm); + // #1163 — the proposal card below IS the answer surface; the adapter's + // "Review reschedule request" list affordance would only link back here. + const overflow = adapter + .overflowItems(vm) + .filter((item) => item.key !== "reschedule-proposal"); const badge = eventUnionStatusBadge(vm.status); const payments = detail.appointment.payment ?? []; const orgName = @@ -187,6 +192,10 @@ export function AppointmentDetailClient({ ? vm.sessions.find((s) => s.startsAt.getTime() === vm.nextAt?.getTime()) : undefined; const hasConfirmedSessions = vm.sessions.some((s) => !s.isTentative); + // #1163 — the read narrows to open statuses and takes one, so [0] is THE + // live proposal; the card is the answer surface "Awaiting schedule + // confirmation" never offered. + const openProposal = detail.appointment.rescheduleRequests?.[0] ?? null; return ( @@ -310,6 +319,14 @@ export function AppointmentDetailClient({ )} + {openProposal && ( + + )} +
{vm.bucket === "past" && ( diff --git a/components/appointments/detail/RescheduleProposalCard.tsx b/components/appointments/detail/RescheduleProposalCard.tsx new file mode 100644 index 000000000..1ac51b2e3 --- /dev/null +++ b/components/appointments/detail/RescheduleProposalCard.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useState } from "react"; +import { format } from "date-fns"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CalendarClock, Loader2 } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { useSession } from "@/lib/auth-client"; +import { + currentRoundProposedSlots, + type OpenRescheduleProposal, +} from "@/lib/appointments/consultee-affordances"; + +/** + * The open reschedule proposal on an appointment, with its answers (#1163). + * + * Counterparty (session user ≠ initiator) gets Accept + Decline; the + * initiator gets Withdraw. Decline confirms first, because its one surprise + * is worth spelling out: the released times STAY with the consultant to + * re-place — declining a proposal is not cancelling the booking. + * + * Toasts relay the SERVER's message: accept runs the full allocator and + * decline/withdraw are CAS transitions, so what actually happened is decided + * there, not here. + */ + +type ProposalAnswer = "accept" | "decline" | "withdraw"; + +async function postAnswer( + appointmentId: string, + kind: ProposalAnswer, +): Promise<{ message?: string }> { + const url = + kind === "withdraw" + ? `/api/appointments/${appointmentId}/reschedule/withdraw` + : `/api/appointments/${appointmentId}/reschedule/respond`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + ...(kind === "withdraw" ? {} : { body: JSON.stringify({ action: kind }) }), + }); + const data = (await res.json().catch(() => ({}))) as { + message?: string; + error?: string; + }; + if (!res.ok) { + throw new Error(data.error || "The request could not be completed."); + } + return data; +} + +const ANSWER_TOAST_TITLE: Record = { + accept: "New times confirmed", + decline: "Proposal declined", + withdraw: "Request withdrawn", +}; + +interface RescheduleProposalCardProps { + appointmentId: string; + proposal: OpenRescheduleProposal; + /** Which detail page hosts the card — copy only, never authorization. */ + role: "consultee" | "consultant"; +} + +export function RescheduleProposalCard({ + appointmentId, + proposal, + role, +}: Readonly) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const [confirmDecline, setConfirmDecline] = useState(false); + + const viewerId = session?.user?.id; + const isInitiator = !!viewerId && viewerId === proposal.initiatedById; + const slots = currentRoundProposedSlots(proposal); + + const mutation = useMutation({ + mutationFn: (kind: ProposalAnswer) => postAnswer(appointmentId, kind), + onSuccess: (data, kind) => { + setConfirmDecline(false); + toast({ title: ANSWER_TOAST_TITLE[kind], description: data.message }); + void queryClient.invalidateQueries({ + queryKey: ["appointment-detail", appointmentId], + }); + // Prefix match — refreshes the events list for every consulteeId/scope. + void queryClient.invalidateQueries({ queryKey: ["consultee-events"] }); + }, + onError: (error: Error) => { + setConfirmDecline(false); + toast({ + title: "Error", + description: error.message, + variant: "destructive", + }); + // A 409 usually means the other side answered first — refetch so the + // card stops offering answers to a settled request. + void queryClient.invalidateQueries({ + queryKey: ["appointment-detail", appointmentId], + }); + }, + }); + const busy = mutation.isPending; + + let heading: string; + if (isInitiator) { + heading = "You asked to reschedule"; + } else if (proposal.initiatorRole === "CONSULTANT") { + heading = + role === "consultee" + ? "Your consultant proposed new times" + : "New times were proposed for this booking"; + } else { + // CONSULTEE-role proposals include org admins acting on the payer side + // (#1166), so a consultee counterparty must not be told "you asked". + heading = + role === "consultant" + ? "Your consultee asked for new times" + : "New times were proposed for this booking"; + } + + return ( +
+
+ + + {heading} + + {proposal.round > 1 && ( + + Counter-offer + + )} +
+ + {slots.length > 0 ? ( +
    + {slots.map((slot) => { + const startsAt = new Date(slot.startsAt); + const endsAt = new Date(slot.endsAt); + return ( +
  • + {format(startsAt, "EEE, d MMM yyyy · h:mm a")} + + {" – "} + {format(endsAt, "h:mm a")} + +
  • + ); + })} +
+ ) : ( +

+ No specific time was named — the replacement will be picked on the + calendar. +

+ )} + + {proposal.reason && ( +

+ “{proposal.reason}” +

+ )} + +

+ {isInitiator ? "Expires" : "Needs an answer by"}{" "} + {format(new Date(proposal.expiresAt), "EEE, d MMM yyyy · h:mm a")} +

+ + {viewerId && ( +
+ {isInitiator ? ( + + ) : ( + <> + {/* Accept needs concrete times; a preference-only request is + answered on the calendar (the route 422s it). */} + {slots.length > 0 && ( + + )} + + + )} +
+ )} + + !open && setConfirmDecline(false)} + > + + + Decline the proposed times? + +
+

+ You are only turning down these times — you are not + cancelling the booking. +

+

+ {role === "consultee" + ? "The sessions being moved stay with your consultant, who will place them at new times." + : "The released sessions stay in your allocate queue to place at new times."} +

+
+
+
+ + Keep deciding + { + // Keep the dialog open while in flight; onSuccess closes it. + event.preventDefault(); + mutation.mutate("decline"); + }} + > + {busy ? ( + <> + + Declining... + + ) : ( + "Decline proposal" + )} + + +
+
+
+ ); +} diff --git a/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx b/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx index 6e9055185..e5fcdf928 100644 --- a/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx +++ b/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx @@ -1,4 +1,14 @@ import * as Sentry from "@sentry/nextjs"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -19,6 +29,7 @@ import { CalendarClock, CheckCircle2, ChevronDown, + Loader2, RefreshCw, } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; @@ -81,6 +92,9 @@ interface Request { rescheduledSlotCount?: number; /** The times the consultee asked for, when they named any. */ proposal?: RescheduleProposalInfo; + /** Which appointment carries `proposal` — the respond endpoint is keyed by + * appointment, and a subscription's proposal sits on ONE child. #1163 */ + proposalAppointmentId?: string; /** What the consultee said when booking. */ requestNotes?: string | null; } @@ -135,6 +149,28 @@ function proposalOf( return appointment?.rescheduleRequests?.[0]; } +/** + * The proposal "Use Requested Times" can honestly answer via the respond + * endpoint: open, consultee-initiated, naming concrete times (#1163). + * + * COUNTERED stays null — the consultant already answered with a counter and + * the ball is with the consultee. So does a preference-only request: with no + * named times there is nothing to accept, only the allocate page. + */ +function answerableProposal(request: Request): RescheduleProposalInfo | null { + const proposal = request.proposal; + if ( + !proposal || + proposal.status !== "PENDING_REVIEW" || + proposal.initiatorRole !== "CONSULTEE" || + !request.proposalAppointmentId || + currentRoundSlots(proposal).length === 0 + ) { + return null; + } + return proposal; +} + // Helper function to fetch and process data async function fetchDataFromApi( url: string, @@ -517,6 +553,11 @@ export function RequestSlotAllocationTab({ useState(false); const [selectedRequestForDialog, setSelectedRequestForDialog] = useState(null); + /** Respond-accept in flight — holds the dialog and disables its exits. #1163 */ + const [respondInFlight, setRespondInFlight] = useState(false); + /** Row awaiting the decline confirmation, and the decline in flight. */ + const [declineTarget, setDeclineTarget] = useState(null); + const [declining, setDeclining] = useState(false); // Fetch requests, available slots, and existing appointments const fetchData = useCallback(async () => { @@ -591,6 +632,9 @@ export function RequestSlotAllocationTab({ tentativeSlotCount: tentativeCount, rescheduledSlotCount: rescheduledCount, proposal: proposalOf(consultation.appointment), + proposalAppointmentId: proposalOf(consultation.appointment) + ? consultation.appointment?.id + : undefined, totalSlotCount: totalCount, }; }), @@ -608,6 +652,11 @@ export function RequestSlotAllocationTab({ const sessionDuration = subscription.subscriptionPlan?.sessionDurationInHours || 1; const slotsPerSession = Math.ceil(sessionDuration / 0.5); + // At most one child appointment carries a live proposal; keep the + // pair so the respond endpoint knows which appointment. #1163 + const proposalAppointment = subscription.appointments?.find( + (appt) => proposalOf(appt), + ); // Flatten all slots from all appointments const allSlots = @@ -693,9 +742,8 @@ export function RequestSlotAllocationTab({ requestNotes: subscription.requestNotes, tentativeSlotCount: tentativeCount, rescheduledSlotCount: rescheduledCount, - proposal: subscription.appointments - ?.map(proposalOf) - .find(Boolean), + proposal: proposalOf(proposalAppointment), + proposalAppointmentId: proposalAppointment?.id, totalSlotCount: totalCount, }; }), @@ -771,9 +819,83 @@ export function RequestSlotAllocationTab({ [fetchData, onUpdate], ); + /** + * #1163 — the consultee proposed these times, so confirming them is + * ANSWERING the proposal, not allocating: the respond endpoint re-validates + * through the full allocator under the wide lock and finalizes the request + * ACCEPTED, which the allocate PATCH would leave dangling open. + */ + const acceptProposal = async (request: Request) => { + setRespondInFlight(true); + try { + const response = await fetch( + `/api/appointments/${request.proposalAppointmentId}/reschedule/respond`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "accept" }), + }, + ); + const data = (await response.json().catch(() => ({}))) as { + message?: string; + error?: string; + }; + + if (response.status === 409 || response.status === 404) { + // Withdrawn/answered elsewhere, or the allocator refused the times — + // either way this snapshot is stale; resync instead of retrying. + toast({ + title: "Could not confirm", + description: + data.error || "This proposal can no longer be accepted.", + variant: "destructive", + }); + setRequestedSlotsDialogOpen(false); + setSelectedRequestForDialog(null); + fetchData(); + onUpdate(); + return; + } + if (!response.ok) { + throw new Error(data.error || "Failed to confirm the proposed times"); + } + + toast({ + title: "Times confirmed", + description: + data.message ?? "The booking has moved to the proposed times.", + variant: "default", + }); + setRequestedSlotsDialogOpen(false); + setSelectedRequestForDialog(null); + setRequests((prev) => prev.filter((r) => r.id !== request.id)); + onUpdate(); + } catch (error) { + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "client", feature: "slot-allocation" } }, + ); + toast( + allocationFailed( + error instanceof Error + ? error.message + : "Failed to confirm the proposed times", + ), + ); + } finally { + setRespondInFlight(false); + } + }; + const handleRequestedAllocation = async (override: boolean) => { if (!selectedRequestForDialog) return; + // A live consultee proposal answers through respond, never allocate. #1163 + if (answerableProposal(selectedRequestForDialog)) { + await acceptProposal(selectedRequestForDialog); + return; + } + try { const endpoint = selectedRequestForDialog.type === AppointmentsType.SUBSCRIPTION @@ -851,27 +973,33 @@ export function RequestSlotAllocationTab({ } }; - const handleDecline = async (request: Request) => { - if (request.type !== AppointmentsType.CONSULTATION) return; + /** Runs after the confirm dialog — declining rejects a request someone is + * waiting on (and refunds anything paid), so it is never one click. */ + const handleDeclineConfirm = async () => { + const request = declineTarget; + if (!request) return; + const endpoint = + request.type === AppointmentsType.SUBSCRIPTION + ? `/api/bookings/subscriptions/${request.id}` + : `/api/bookings/consultations/${request.id}`; + setDeclining(true); try { - const response = await fetch( - `/api/bookings/consultations/${request.id}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: "REJECTED" }), - }, - ); + const response = await fetch(endpoint, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "REJECTED" }), + }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || "Failed to decline request"); } toast({ title: "Request declined", - description: "The consultation request has been declined.", + description: `The ${getRequestTypeLabel(request.type).toLowerCase()} request has been declined.`, variant: "default", }); setRequests((prev) => prev.filter((r) => r.id !== request.id)); + setDeclineTarget(null); onUpdate(); } catch (error) { Sentry.captureException( @@ -884,6 +1012,8 @@ export function RequestSlotAllocationTab({ error instanceof Error ? error.message : "Failed to decline request", variant: "destructive", }); + } finally { + setDeclining(false); } }; @@ -1062,35 +1192,43 @@ export function RequestSlotAllocationTab({ Allocate Slots {/* Hidden for directly booked consultations (Bug #8 fix), and - for anything awaiting reschedule: those slots still carry - the ORIGINAL startsAt, so "using" them would re-confirm the - times the consultee just asked to move. */} - {request.requestedTimes && - request.requestedTimes.length > 0 && - request.bookingSource === "REQUEST_SUBMITTED" && - (request.rescheduledSlotCount ?? 0) === 0 && ( - - )} + for a reschedule that names NO times: released slots still + carry the ORIGINAL startsAt, so "using" them would + re-confirm the times the consultee just asked to move. + A live proposal lifts that suppression — the button then + answers with the PROPOSED times via respond-accept. #1163 */} + {(answerableProposal(request) || + (request.requestedTimes && + request.requestedTimes.length > 0 && + request.bookingSource === "REQUEST_SUBMITTED" && + (request.rescheduledSlotCount ?? 0) === 0)) && ( + + )} )} {/* Quiet by design: declining is the rarer branch, and nothing is - destroyed until the request is actually rejected. */} - {request.type === AppointmentsType.CONSULTATION && ( + destroyed until the confirm dialog is answered. #1163 adds the + subscription arm — its PATCH gained the same consultant-only + REJECTED path (#1004). */} + {(request.type === AppointmentsType.CONSULTATION || + request.type === AppointmentsType.SUBSCRIPTION) && ( @@ -1162,7 +1300,16 @@ export function RequestSlotAllocationTab({ requestType={ selectedRequestForDialog?.type || AppointmentsType.CONSULTATION } - requestedSlots={selectedRequestForDialog?.requestedTimes || []} + requestedSlots={(() => { + // In the proposal case the times under review are the PROPOSED + // ones — the stored slots still hold what is being moved away + // from. #1163 + if (!selectedRequestForDialog) return []; + const proposal = answerableProposal(selectedRequestForDialog); + return proposal + ? currentRoundSlots(proposal).map((slot) => slot.startsAt) + : selectedRequestForDialog.requestedTimes || []; + })()} requestedSlotsWithStatus={selectedRequestForDialog?.requestedSlots} schedulingPeriod={ selectedRequestForDialog?.startDate && @@ -1173,12 +1320,63 @@ export function RequestSlotAllocationTab({ } : undefined } + confirming={respondInFlight} onConfirm={handleRequestedAllocation} onCancel={() => { setRequestedSlotsDialogOpen(false); setSelectedRequestForDialog(null); }} /> + + { + if (!open && !declining) setDeclineTarget(null); + }} + > + + + Decline this request? + +
+

+ {declineTarget?.requestedBy.user.name}{" "} + asked for{" "} + "{declineTarget?.title}". This + rejects the whole booking request. +

+

+ The consultee is notified, and if they have already paid, + the payment is returned in full. +

+
+
+
+ + + Keep request + + { + // Keep the dialog open while in flight; success closes it. + event.preventDefault(); + void handleDeclineConfirm(); + }} + > + {declining ? ( + <> + + Declining... + + ) : ( + "Decline request" + )} + + +
+
); diff --git a/components/dashboard/shared/requests/components/RequestedSlotsDialog.tsx b/components/dashboard/shared/requests/components/RequestedSlotsDialog.tsx index 66b2e4da0..2f7080adc 100644 --- a/components/dashboard/shared/requests/components/RequestedSlotsDialog.tsx +++ b/components/dashboard/shared/requests/components/RequestedSlotsDialog.tsx @@ -35,6 +35,9 @@ interface RequestedSlotsDialogProps { requestedSlots: string[]; requestedSlotsWithStatus?: SlotWithStatus[]; // New: includes tentative info schedulingPeriod?: { startDate?: Date; endDate?: Date }; + /** Parent's confirm is in flight — hold both exits so a double click can't + * fire a second submit. #1163 */ + confirming?: boolean; onConfirm: (override: boolean) => Promise; onCancel: () => void; } @@ -47,6 +50,7 @@ export function RequestedSlotsDialog({ requestedSlots, requestedSlotsWithStatus, schedulingPeriod, + confirming = false, onConfirm, onCancel, }: RequestedSlotsDialogProps) { @@ -462,14 +466,18 @@ export function RequestedSlotsDialog({
-