Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions __tests__/booking-algorithm/consultee-affordances.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
consulteeDestructiveAction,
consulteeMayReschedule,
currentRoundProposedSlots,
openProposalTarget,
type OpenRescheduleProposal,
} from "@/lib/appointments/consultee-affordances";

describe("#1005 consultee affordances", () => {
Expand All @@ -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);
});
});
113 changes: 113 additions & 0 deletions __tests__/booking-algorithm/reschedule-respond-ui.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<DashboardViewportFill className="gap-4">
<div className="flex flex-1 flex-col items-center justify-center py-16 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<CalendarX className="h-7 w-7 text-muted-foreground/70" />
</div>
<h1 className="text-base font-medium text-foreground">
Trial sessions can&apos;t be rescheduled
</h1>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
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.
</p>
<Button variant="outline" size="sm" className="mt-4" asChild>
<Link href={`/dashboard/consultee/${consulteeId}/appointments`}>
Back to appointments
</Link>
</Button>
</div>
</DashboardViewportFill>
);
}

// 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.
Expand Down
47 changes: 47 additions & 0 deletions components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
import {
consulteeDestructiveAction,
consulteeMayReschedule,
openProposalTarget,
type OpenRescheduleProposal,
} from "@/lib/appointments/consultee-affordances";
import {
isApprovedStatus,
Expand Down Expand Up @@ -87,6 +89,27 @@
}
}

/**
* 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"
Expand Down Expand Up @@ -147,6 +170,9 @@
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);
Expand Down Expand Up @@ -237,10 +263,24 @@
return { kind: "view", label: "View" };
};

const overflowItems = (vm: AppointmentVM): OverflowItem[] => {

Check failure on line 266 in components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAAS3z4iF6uvZSGYNzt&open=AaAAS3z4iF6uvZSGYNzt&pullRequest=1177
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) &&
Expand Down Expand Up @@ -330,6 +370,13 @@
};

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],
Expand Down
17 changes: 15 additions & 2 deletions components/appointments/consultee/useEventActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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({
Expand Down
19 changes: 18 additions & 1 deletion components/appointments/detail/AppointmentDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 =
Expand All @@ -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 (
<DashboardErrorBoundary>
Expand Down Expand Up @@ -310,6 +319,14 @@ export function AppointmentDetailClient({
)}
</div>

{openProposal && (
<RescheduleProposalCard
appointmentId={appointmentId}
proposal={openProposal}
role={role}
/>
)}

<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,340px)] lg:items-start">
<div className="flex min-w-0 flex-col gap-4">
{vm.bucket === "past" && (
Expand Down
Loading
Loading