diff --git a/__tests__/booking-algorithm/booking-timeline-read.test.ts b/__tests__/booking-algorithm/booking-timeline-read.test.ts new file mode 100644 index 000000000..6f575622f --- /dev/null +++ b/__tests__/booking-algorithm/booking-timeline-read.test.ts @@ -0,0 +1,159 @@ +/** + * @jest-environment node + */ + +/** + * #1319 PR 8 / #448 — the audit-trail read model. + * + * Two properties are pinned. The timeline is the MERGE of two sources, so a + * reschedule proposal has to interleave with the status log by timestamp + * rather than land in a block at either end; and the read is privileged-only, + * so an organization or personal scope must be refused before a single query + * runs (ADR 20 — org roles get no per-session drill-in). + */ + +import { getBookingTimeline } from "../../lib/data/booking-history"; + +const appointmentFindUnique = jest.fn(); +const historyFindMany = jest.fn(); + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + appointment: { + findUnique: (args: unknown) => appointmentFindUnique(args), + }, + bookingStatusHistory: { + findMany: (args: unknown) => historyFindMany(args), + }, + }, +})); + +const PRIVILEGED = { kind: "all" } as const; + +function appointmentRow() { + return { + id: "appt-1", + consultationId: "consult-1", + subscriptionId: null, + webinarId: null, + classId: null, + trialSession: null, + slotsOfAppointment: [{ id: "slot-1" }], + rescheduleRequests: [ + { + id: "resched-1", + status: "ACCEPTED", + round: 1, + reason: "clashes with a standup", + initiatorRole: "CONSULTEE", + // Deliberately BETWEEN the two history rows below. + createdAt: new Date("2026-09-02T11:00:00.000Z"), + resolvedAt: new Date("2026-09-02T12:00:00.000Z"), + initiatedBy: { id: "user-1", name: "Asha" }, + _count: { proposedSlots: 3 }, + }, + ], + }; +} + +function historyRows() { + return [ + { + id: "hist-2", + entity: "SLOT", + entityId: "slot-1", + fromStatus: "SCHEDULED", + toStatus: "RESCHEDULED", + reason: null, + createdAt: new Date("2026-09-02T12:00:00.000Z"), + actorUser: null, + }, + { + id: "hist-1", + entity: "CONSULTATION", + entityId: "consult-1", + fromStatus: "PENDING", + toStatus: "APPROVED", + reason: "approved by the consultant", + createdAt: new Date("2026-09-02T10:00:00.000Z"), + actorUser: { id: "user-2", name: "Ravi" }, + }, + ]; +} + +describe("getBookingTimeline", () => { + beforeEach(() => { + appointmentFindUnique.mockResolvedValue(appointmentRow()); + historyFindMany.mockResolvedValue(historyRows()); + }); + + it("merges status history with reschedule proposals, newest first", async () => { + const timeline = await getBookingTimeline("appt-1", PRIVILEGED); + + expect(timeline).not.toBeNull(); + expect(timeline!.entries.map((entry) => entry.id)).toEqual([ + "hist-2", + "resched-1", + "hist-1", + ]); + + const [slotMove, proposal, approval] = timeline!.entries; + expect(slotMove).toMatchObject({ + kind: "status", + entity: "SLOT", + from: "SCHEDULED", + to: "RESCHEDULED", + actor: null, + }); + // The proposal is the row the RescheduleRequest table contributes: no + // from-state, and the proposed-time count the #448 ask named. + expect(proposal).toMatchObject({ + kind: "reschedule", + entity: "RESCHEDULE_REQUEST", + from: null, + to: "ACCEPTED", + proposedSlotCount: 3, + round: 1, + actor: { id: "user-1", name: "Asha" }, + }); + expect(approval).toMatchObject({ kind: "status", to: "APPROVED" }); + expect(timeline!.truncated).toBe(false); + + // The trail is resolved by entityId, not by the (always NULL) appointmentId + // column — the SLOT and RESCHEDULE_REQUEST rows only surface because of it. + const where = historyFindMany.mock.calls[0][0].where; + expect(where.OR[1].entityId.in).toEqual( + expect.arrayContaining(["consult-1", "slot-1", "resched-1"]), + ); + }); + + it("never selects an actor's email", async () => { + await getBookingTimeline("appt-1", PRIVILEGED); + + const actorSelect = + historyFindMany.mock.calls[0][0].select.actorUser.select; + expect(actorSelect).toEqual({ id: true, name: true }); + }); + + it("refuses a non-privileged scope before touching the database", async () => { + await expect( + // Cast: the type signature already rejects this, and the throw is the + // backstop for an untyped caller. + getBookingTimeline("appt-1", { kind: "org", orgId: "org-1" } as never), + ).rejects.toThrow(/privileged-only/i); + + await expect( + getBookingTimeline("appt-1", { kind: "personal" } as never), + ).rejects.toThrow(/privileged-only/i); + + expect(appointmentFindUnique).not.toHaveBeenCalled(); + expect(historyFindMany).not.toHaveBeenCalled(); + }); + + it("returns null for an appointment that does not exist", async () => { + appointmentFindUnique.mockResolvedValue(null); + await expect(getBookingTimeline("missing", PRIVILEGED)).resolves.toBeNull(); + expect(historyFindMany).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/staff/appointments/[appointmentId]/timeline/route.ts b/app/api/staff/appointments/[appointmentId]/timeline/route.ts new file mode 100644 index 000000000..ecc1fe559 --- /dev/null +++ b/app/api/staff/appointments/[appointmentId]/timeline/route.ts @@ -0,0 +1,68 @@ +/** + * GET /api/staff/appointments/[appointmentId]/timeline — the booking audit + * trail for one appointment (#1319 PR 8, #448). + * + * Thin shell over `getBookingTimeline`, mirroring the sibling + * `/api/staff/appointments` route exactly: `requirePrivilegedAuth` is the gate, + * and passing it is what earns the `all` scope, so no membership check widens + * it (#674 defect 13). ADR 20 keeps this off the organization surfaces — an org + * role has no per-session drill-in to hang a timeline off. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +import { requirePrivilegedAuth } from "@/lib/auth-helpers"; +import { getBookingTimeline } from "@/lib/data/booking-history"; +import { applyRateLimit, participantReadLimiter } from "@/lib/rate-limit"; + +interface RouteParams { + params: Promise<{ appointmentId: string }>; +} + +// Appointment.id is `@default(uuid())`, so anything else is a broken link +// rather than a miss — reject it before it reaches the database. +const TimelineParams = z.object({ appointmentId: z.string().uuid() }); + +export async function GET(_req: NextRequest, { params }: RouteParams) { + try { + const auth = await requirePrivilegedAuth(); + if (auth.error) return auth.error; + + const parsed = TimelineParams.safeParse(await params); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid appointment id" }, + { status: 400 }, + ); + } + + // No staff route has its own bucket, and this one is a per-appointment read + // an operator opens by hand. `participantReadLimiter` is the platform's + // 30/min-per-user read profile; the route slug keeps it from sharing a + // counter with the participant lists, as `applyRateLimit` documents. + const limited = await applyRateLimit( + participantReadLimiter, + `staff-timeline:${auth.session.user.id}`, + ); + if (limited) return limited; + + const timeline = await getBookingTimeline(parsed.data.appointmentId, { + kind: "all", + }); + if (!timeline) { + return NextResponse.json( + { error: "Appointment not found" }, + { status: 404 }, + ); + } + + return NextResponse.json(timeline); + } catch (error) { + console.error("Error fetching appointment timeline:", error); + return NextResponse.json( + { error: "Failed to fetch appointment timeline" }, + { status: 500 }, + ); + } +} diff --git a/components/dashboard/shared/AppointmentTimeline.tsx b/components/dashboard/shared/AppointmentTimeline.tsx new file mode 100644 index 000000000..0ead449c0 --- /dev/null +++ b/components/dashboard/shared/AppointmentTimeline.tsx @@ -0,0 +1,153 @@ +"use client"; + +/** + * The booking audit trail for one appointment, rendered inside the operator + * appointment detail modal (#1319 PR 8, #448). + * + * Metadata only, by construction: the endpoint returns status edges, + * attribution and proposal counts and nothing else, so there is no note, no + * chat and no recording link to render here. ADR 20 keeps the whole surface + * off the organization dashboards — staff and admin share this component + * because they share the appointments page it lives on, and nobody else + * reaches either. + */ + +import { useQuery } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; +import { AlertTriangle, History, Loader2 } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import type { + BookingTimeline, + BookingTimelineEntry, +} from "@/lib/data/booking-history"; + +/** + * `BookingHistoryEntity` values read as SQL enums; these are the operator's + * words for the same seven lifecycles. Unknown keys fall through to the raw + * value rather than rendering blank, so a new enum member is legible on the + * day it ships rather than the day someone edits this map. + */ +const ENTITY_LABEL: Record = { + CONSULTATION: "Consultation", + SUBSCRIPTION: "Subscription", + WEBINAR: "Webinar", + CLASS: "Class", + TRIAL: "Trial", + RESCHEDULE_REQUEST: "Reschedule", + SLOT: "Slot", +}; + +function entityLabel(entity: string): string { + return ENTITY_LABEL[entity] ?? entity.replace(/_/g, " ").toLowerCase(); +} + +function relativeTime(iso: string): string { + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return "unknown time"; + return formatDistanceToNow(at, { addSuffix: true }); +} + +/** + * An unattributed row is a cron, a webhook or a sweep — every automated writer + * omits `actorUserId`. Saying "system" is more honest than an empty cell. + */ +function actorLabel(entry: BookingTimelineEntry): string { + return entry.actor?.name?.trim() || "system"; +} + +function transitionLabel(entry: BookingTimelineEntry): string { + return entry.from ? `${entry.from} → ${entry.to}` : entry.to; +} + +function TimelineRow({ entry }: { entry: BookingTimelineEntry }) { + return ( +
  • + +
    + + {entityLabel(entry.entity)} + + + {transitionLabel(entry)} + +
    +

    + {actorLabel(entry)} • {relativeTime(entry.createdAt)} + {entry.kind === "reschedule" && ( + <> + {" • "} + {entry.proposedSlotCount === 1 + ? "1 proposed time" + : `${entry.proposedSlotCount ?? 0} proposed times`} + {entry.round ? ` • round ${entry.round}` : ""} + + )} +

    + {entry.reason && ( +

    {entry.reason}

    + )} +
  • + ); +} + +export function AppointmentTimeline({ + appointmentId, +}: { + appointmentId: string; +}) { + const { data, isLoading, error } = useQuery({ + queryKey: ["staff-appointment-timeline", appointmentId], + queryFn: async () => { + const response = await fetch( + `/api/staff/appointments/${appointmentId}/timeline`, + ); + if (!response.ok) throw new Error("Failed to fetch appointment timeline"); + return response.json(); + }, + staleTime: 30 * 1000, + refetchOnWindowFocus: false, + }); + + const entries = data?.entries ?? []; + + return ( +
    + + + {isLoading ? ( +
    + + Loading the audit trail… +
    + ) : error ? ( +
    + + Could not load the audit trail. +
    + ) : entries.length === 0 ? ( +

    + Nothing has moved on this booking yet. +

    + ) : ( + <> +
      + {entries.map((entry) => ( + + ))} +
    + {data?.truncated && ( +

    + Showing the most recent {entries.length} events; older ones are + not displayed. +

    + )} + + )} +
    + ); +} diff --git a/components/dashboard/shared/OperatorAppointmentsClient.tsx b/components/dashboard/shared/OperatorAppointmentsClient.tsx index 0aee893b9..241a33e78 100644 --- a/components/dashboard/shared/OperatorAppointmentsClient.tsx +++ b/components/dashboard/shared/OperatorAppointmentsClient.tsx @@ -49,6 +49,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/hooks/use-toast"; +import { AppointmentTimeline } from "./AppointmentTimeline"; import type { StaffAppointment, StaffAppointmentsPayload, @@ -169,35 +170,30 @@ export function OperatorAppointmentsClient() { return () => clearTimeout(timer); }, [searchQuery]); - const { - data, - isLoading, - isFetching, - refetch, - error, - } = useQuery({ - queryKey: appointmentsKey({ - page, - type: typeFilter, - status: activeTab, - search: debouncedSearch, - }), - // Tab, type, page and search all live in the key, so each combination is - // its own query. Same fix as #346 on the appointments list. - placeholderData: keepPreviousData, - queryFn: async () => { - const params = new URLSearchParams(); - params.set("page", page.toString()); - if (typeFilter !== "all") params.set("type", typeFilter.toUpperCase()); - if (activeTab !== "all") params.set("status", activeTab); - if (debouncedSearch) params.set("search", debouncedSearch); + const { data, isLoading, isFetching, refetch, error } = + useQuery({ + queryKey: appointmentsKey({ + page, + type: typeFilter, + status: activeTab, + search: debouncedSearch, + }), + // Tab, type, page and search all live in the key, so each combination is + // its own query. Same fix as #346 on the appointments list. + placeholderData: keepPreviousData, + queryFn: async () => { + const params = new URLSearchParams(); + params.set("page", page.toString()); + if (typeFilter !== "all") params.set("type", typeFilter.toUpperCase()); + if (activeTab !== "all") params.set("status", activeTab); + if (debouncedSearch) params.set("search", debouncedSearch); - const response = await fetch(`/api/staff/appointments?${params}`); - if (!response.ok) throw new Error("Failed to fetch appointments"); - return response.json(); - }, - refetchOnWindowFocus: false, - }); + const response = await fetch(`/api/staff/appointments?${params}`); + if (!response.ok) throw new Error("Failed to fetch appointments"); + return response.json(); + }, + refetchOnWindowFocus: false, + }); useEffect(() => { if (error) { @@ -254,7 +250,9 @@ export function OperatorAppointmentsClient() {

    {counts.all}

    -

    Total Appointments

    +

    + Total Appointments +

    @@ -705,6 +703,10 @@ export function OperatorAppointmentsClient() { )} + {/* Audit trail (#1319 PR 8 / #448) — mounted with the modal, so + the trail is fetched only for the row an operator opened. */} + + {/* Staff Notes */}
    diff --git a/docs/booking/05-troubleshooting-and-changelog.md b/docs/booking/05-troubleshooting-and-changelog.md index cfbd56014..20d0b0b77 100644 --- a/docs/booking/05-troubleshooting-and-changelog.md +++ b/docs/booking/05-troubleshooting-and-changelog.md @@ -178,6 +178,16 @@ Part of #1319. This PR makes an abandoned checkout release its slot by definitio **Redis health is probed once per two seconds per instance.** `checkRedisHealth(force)` caches its PING so a booking no longer pays one probe per atom; `/api/health` passes `force` to keep its own reading live. +### PR 8 — booking status-history surface (`feat/booking-status-history-surface`) + +**The audit trail has a reader.** The status log that `BookingStatusHistory` has been accumulating since PR 2 was written by every guarded transition and read by nothing at all, which meant that support staff investigating a booking still had only its current state to go on. `lib/data/booking-history.ts` closes that gap with `getBookingTimeline`, which returns the newest-first merge of two sources: the status rows recorded for the appointment, and the reschedule proposals raised against it, each carrying the count of concrete times it named. Those two sources together are what issue #448 asked for under the name "RescheduleLog", so no new table was added to hold the same facts a third time. + +**The trail is resolved by `entityId`, not by `appointmentId`.** `BookingStatusHistory.appointmentId` is nullable, and no writer in the `lib/booking/transitions.ts` call graph passes `meta.appointmentId` today, so the column is NULL on every row that exists. A reader that filtered on it would therefore return an empty list for every appointment on the platform. The read model instead collects the appointment's own polymorphic keys — its consultation, subscription, webinar, class or trial id, every one of its slot ids, and every reschedule request id — and matches those against `entityId`, which is the column the writers actually populate. That is the only reason `SLOT` and `RESCHEDULE_REQUEST` rows appear on the timeline. The `appointmentId` arm stays in the query so that rows written once the metadata is threaded through are picked up without a further edit. + +**Reading it is privileged-only, and it says so in the type.** ADR 20 gives an organization role no per-session drill-in whatsoever, so `getBookingTimeline` takes a scope narrowed to the single `all` kind rather than a general `Scope` it would have to branch on. An organization or personal caller is a compile error, and an untyped one throws before the first query runs. Every person the trail names — the actor on a status row, the initiator of a proposal — is read through a select allow-list that stops at id and name, so no email leaves the database (#946). + +**The surface is the operator appointment detail modal.** `GET /api/staff/appointments/[appointmentId]/timeline` is a thin shell over the read model that mirrors the sibling `/api/staff/appointments` route exactly: `requirePrivilegedAuth` is the gate, and passing it is what earns the widest scope on the platform. The param is validated as a UUID, a missing appointment answers 404, and the read is throttled through `participantReadLimiter` under its own route slug. The staff and admin appointments pages already share one detail modal opened from the row, so the timeline renders there — one line per event with an entity badge, the `from → to` edge, the actor's name or "system" for the crons, webhooks and sweeps that record no actor, a relative timestamp, and the reason when one was written. It is metadata only: no notes, no chat, no recording links. + ## Changelog: 2026-08-14 — documentation refresh Docs-only pass shipped as the final PR of the #1169 booking + maintenance productionization train, closing the long-standing booking-docs drift item #1013. No code changed in this entry. diff --git a/docs/booking/README.md b/docs/booking/README.md index 6d59cfd37..35011ac15 100644 --- a/docs/booking/README.md +++ b/docs/booking/README.md @@ -37,6 +37,10 @@ graph TD - **`isTentative` flag** -- marks slots pending payment or reschedule; cleaned up by cron after 24 hours (`TENTATIVE_EXPIRATION_HOURS = 24`, reduced from 7 days by #833); users can self-release via `DELETE /api/checkout/pending/[paymentId]` (#849) - **`startDay`/`endDay` DayOfWeek enum + `startTimeUtc`/`endTimeUtc` Int** -- source of truth for weekly availability (minutes since midnight UTC, 0-1439; supports overnight/cross-midnight slots) +## Reading the audit trail + +Every guarded status transition appends one `BookingStatusHistory` row inside the same transaction as the state change, and the reschedule proposals raised against a booking are kept as `RescheduleRequest` rows. Those two tables together are the booking's audit trail, and the way to read them is `getBookingTimeline` in [`lib/data/booking-history.ts`](../../lib/data/booking-history.ts), which merges both sources into a single newest-first list of status edges, actors and reasons. It resolves the trail through the polymorphic `entityId` column rather than through the nullable `appointmentId`, because no writer populates the latter today, and that is what makes the slot and reschedule rows visible. The surface over it is `GET /api/staff/appointments/[appointmentId]/timeline`, which renders in the operator appointment detail modal on the staff and admin appointments pages. Reading it requires ADMIN or STAFF: ADR 20 gives organization roles no per-session drill-in, so the read model's scope parameter accepts only the privileged `all` kind and refuses anything else. + ## Source Code Map ### Backend Services (`utils/slotAllocation/`) @@ -72,8 +76,8 @@ graph TD ### API Routes (`app/api/bookings/`) -| Pattern | Method | Purpose | -| ----------------------------------------- | ------ | --------------------------- | +| Pattern | Method | Purpose | +| ------------------------------------------- | ------ | --------------------------- | | `/api/bookings/consultations/{id}/allocate` | PATCH | Allocate consultation slots | | `/api/bookings/consultations/{id}/validate` | POST | Validate consultation slots | | `/api/bookings/subscriptions/{id}/allocate` | PATCH | Allocate subscription slots | diff --git a/lib/data/booking-history.ts b/lib/data/booking-history.ts new file mode 100644 index 000000000..a05788307 --- /dev/null +++ b/lib/data/booking-history.ts @@ -0,0 +1,242 @@ +/** + * The read side of the booking audit trail — #1319 PR 8, closing the #448 + * "no staff booking surface exists" ask. + * + * #1322 gave every guarded transition an append-only `BookingStatusHistory` + * row; nothing read them. This is that reader, and it is deliberately the only + * one: `getBookingTimeline` merges the status log with the reschedule + * proposals raised against the same appointment into a single newest-first + * list, which is what the #448 "RescheduleLog" ask asked for. No new table + * exists or is needed — the two sources already hold the whole story. + * + * Two rules shape the query below. + * + * `BookingStatusHistory.appointmentId` is nullable and, as of this PR, no + * writer in `lib/booking/transitions.ts`'s call graph passes it: every caller + * omits `meta.appointmentId`, so the column is NULL on every row in existence. + * Resolving the trail by that column alone would therefore return nothing. The + * reader instead collects the appointment's own polymorphic keys — its + * request/event/trial id, every one of its slot ids, and every reschedule + * request id — and matches them against `entityId`, which is the column the + * writers actually populate. The `appointmentId` arm stays in the OR so rows + * written once the meta is wired through are picked up without a second edit. + * + * Reading it is privileged-only. ADR 20 + * (`docs/enterprise/70-design-decisions/20-org-visibility-into-member-sessions.md`) + * gives organization roles no per-session drill-in at all, so the scope + * parameter is narrowed to the single privileged kind at the type level rather + * than branched on at runtime, and every user the trail names is read through + * a select allow-list that stops at id and name (#946 — no email, ever). + */ + +import type { + BookingHistoryEntity, + RescheduleInitiatorRole, +} from "@prisma/client"; + +import type { Scope } from "@/lib/api/scope/parse"; +import prisma from "@/lib/prisma"; + +/** + * The only `Scope` that may read another person's audit trail. Narrowing the + * parameter this way makes an org or personal caller a compile error rather + * than a runtime branch someone could later widen by accident (ADR 20). + */ +export type PrivilegedScope = Extract; + +/** Attribution carries a name, never an email or an avatar (#946). */ +export interface BookingTimelineActor { + id: string; + name: string | null; +} + +export interface BookingTimelineEntry { + id: string; + /** + * `status` is one `BookingStatusHistory` row — a state machine edge that + * fired. `reschedule` is one `RescheduleRequest` — the proposal itself, + * stamped at the moment it was raised. + */ + kind: "status" | "reschedule"; + entity: BookingHistoryEntity; + entityId: string; + /** + * Null on reschedule rows: a proposal is raised, it does not move out of a + * prior state. On status rows this is the pre-image the CAS observed, which + * is `"UNKNOWN"` when a concurrent writer moved the row between the pre-read + * and the update (the documented A12 limitation). + */ + from: string | null; + /** On reschedule rows this is where the proposal ended up, not a transition. */ + to: string; + reason: string | null; + actor: BookingTimelineActor | null; + /** ISO-8601, so the payload is JSON-safe verbatim on both paths. */ + createdAt: string; + /** Reschedule rows only — how many concrete times the proposal named. */ + proposedSlotCount?: number; + /** Reschedule rows only — 1 is the opening proposal, 2 the single counter. */ + round?: number; + /** Reschedule rows only — which side raised it. */ + initiatorRole?: RescheduleInitiatorRole; + /** Reschedule rows only — null while the proposal is still open. */ + resolvedAt?: string | null; +} + +export interface BookingTimeline { + appointmentId: string; + /** Newest first. */ + entries: BookingTimelineEntry[]; + /** True when older status rows exist beyond `TIMELINE_LIMIT` and were dropped. */ + truncated: boolean; +} + +/** + * A year-long subscription with a weekly session accumulates hundreds of slot + * transitions, and this feeds a modal. Operators want the recent story; the + * flag below tells them when there is more behind it. + */ +const TIMELINE_LIMIT = 200; + +/** Deterministic, collation-independent tie-break for equal timestamps. */ +function compareIds(a: string, b: string): number { + if (a < b) return -1; + return a > b ? 1 : 0; +} + +/** + * The merged audit trail for one appointment, newest first, or `null` when no + * such appointment exists. + * + * A soft-deleted appointment is deliberately still readable: the tombstone is + * precisely the case an operator opens this surface to explain (#448, and rule + * 2 of the booking doctrine — nothing is deleted, so nothing disappears from + * the audit trail either). + */ +export async function getBookingTimeline( + appointmentId: string, + scope: PrivilegedScope, +): Promise { + // The type signature is the real gate; this catches an untyped caller and a + // future `Scope` variant that widens `all` (ADR 20 — fail closed). + if (scope?.kind !== "all") { + throw new Error( + 'getBookingTimeline is privileged-only: pass { kind: "all" }. Organization and personal scopes get no session drill-in (ADR 20).', + ); + } + + // One round trip for the appointment's polymorphic keys AND its reschedule + // proposals — PG_POOL_MAX=1 serialises Prisma reads on this host, so a + // nested select beats two awaits. + const appointment = await prisma.appointment.findUnique({ + where: { id: appointmentId }, + select: { + id: true, + consultationId: true, + subscriptionId: true, + webinarId: true, + classId: true, + trialSession: { select: { id: true } }, + // Every slot, including the CANCELLED and RESCHEDULED tombstones: a + // released slot is exactly what the operator came here to see. + slotsOfAppointment: { select: { id: true } }, + rescheduleRequests: { + select: { + id: true, + status: true, + round: true, + reason: true, + initiatorRole: true, + createdAt: true, + resolvedAt: true, + // Allow-list, not `include`: the initiator is a person, so the + // select stops at id and name (#946). + initiatedBy: { select: { id: true, name: true } }, + _count: { select: { proposedSlots: true } }, + }, + orderBy: { createdAt: "desc" }, + }, + }, + }); + if (!appointment) return null; + + const entityIds = [ + appointment.consultationId, + appointment.subscriptionId, + appointment.webinarId, + appointment.classId, + appointment.trialSession?.id, + ...appointment.slotsOfAppointment.map((slot) => slot.id), + ...appointment.rescheduleRequests.map((request) => request.id), + ].filter((id): id is string => Boolean(id)); + + const history = await prisma.bookingStatusHistory.findMany({ + where: { + OR: [ + { appointmentId: appointment.id }, + // Guarded: an empty `in` matches nothing, but an appointment with no + // sub-entity and no slots would otherwise build a pointless predicate. + ...(entityIds.length > 0 ? [{ entityId: { in: entityIds } }] : []), + ], + }, + select: { + id: true, + entity: true, + entityId: true, + fromStatus: true, + toStatus: true, + reason: true, + createdAt: true, + actorUser: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "desc" }, + // One over the limit, so "there is more" is measured rather than guessed. + take: TIMELINE_LIMIT + 1, + }); + + const truncated = history.length > TIMELINE_LIMIT; + + const statusEntries: BookingTimelineEntry[] = history + .slice(0, TIMELINE_LIMIT) + .map((row) => ({ + id: row.id, + kind: "status" as const, + entity: row.entity, + entityId: row.entityId, + from: row.fromStatus, + to: row.toStatus, + reason: row.reason, + actor: row.actorUser + ? { id: row.actorUser.id, name: row.actorUser.name } + : null, + createdAt: row.createdAt.toISOString(), + })); + + const rescheduleEntries: BookingTimelineEntry[] = + appointment.rescheduleRequests.map((request) => ({ + id: request.id, + kind: "reschedule" as const, + entity: "RESCHEDULE_REQUEST", + entityId: request.id, + from: null, + to: request.status, + reason: request.reason, + actor: request.initiatedBy + ? { id: request.initiatedBy.id, name: request.initiatedBy.name } + : null, + createdAt: request.createdAt.toISOString(), + proposedSlotCount: request._count.proposedSlots, + round: request.round, + initiatorRole: request.initiatorRole, + resolvedAt: request.resolvedAt ? request.resolvedAt.toISOString() : null, + })); + + const entries = [...statusEntries, ...rescheduleEntries] + .sort((a, b) => { + const delta = Date.parse(b.createdAt) - Date.parse(a.createdAt); + return delta !== 0 ? delta : compareIds(a.id, b.id); + }) + .slice(0, TIMELINE_LIMIT); + + return { appointmentId: appointment.id, entries, truncated }; +}