From da0bc847888acda34fb7479fa43f1e6c0cdb09c4 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:00:03 +0530 Subject: [PATCH 1/6] feat(booking): a read model for the booking audit trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1322 gave every guarded transition an append-only BookingStatusHistory row and nothing ever read one. getBookingTimeline is that reader: it merges the status log with the reschedule proposals raised against the same appointment into one newest-first list, which is what #448 asked for under the name "RescheduleLog". No new table. The trail cannot be resolved by BookingStatusHistory.appointmentId, because no caller in lib/booking/transitions.ts's call graph passes meta.appointmentId, so that column is NULL on every row that exists. The reader collects the appointment's polymorphic keys instead — its request/event/trial id, every slot id and every reschedule request id — and matches them against entityId, which the writers do populate. SLOT and RESCHEDULE_REQUEST rows surface because of it. The appointmentId arm stays in the OR so rows written once the meta is wired need no edit here. Reading it is privileged-only. ADR 20 gives organization roles no per-session drill-in, so the scope parameter is narrowed to the single privileged kind at the type level and a non-`all` scope throws for an untyped caller. Every person the trail names is read through a select allow-list that stops at id and name (#946). Part of #1319 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --- lib/data/booking-history.ts | 243 ++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 lib/data/booking-history.ts diff --git a/lib/data/booking-history.ts b/lib/data/booking-history.ts new file mode 100644 index 000000000..1f7e2cbf8 --- /dev/null +++ b/lib/data/booking-history.ts @@ -0,0 +1,243 @@ +/** + * 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, + RescheduleRequestStatus, +} 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" as BookingHistoryEntity, + entityId: request.id, + from: null, + to: request.status as RescheduleRequestStatus, + 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 }; +} From 75b6097cd60495c1151d3faebaff3aa8a12ba892 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:00:25 +0530 Subject: [PATCH 2/6] feat(booking): expose the audit trail at GET /api/staff/appointments/[id]/timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thin shell over getBookingTimeline that mirrors the sibling /api/staff/appointments route: requirePrivilegedAuth is the gate, and passing it is what earns the `all` scope, so nothing widens it. Zod rejects a non-uuid param before it reaches the database, a missing appointment answers 404, and the read is throttled through participantReadLimiter under its own route slug — no staff route has a bucket of its own and this is the platform's 30/min-per-user read profile. Part of #1319 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --- .../[appointmentId]/timeline/route.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 app/api/staff/appointments/[appointmentId]/timeline/route.ts 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 }, + ); + } +} From 9bc28cc64c618b81940696daa7984622b08c873d Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:01:36 +0530 Subject: [PATCH 3/6] feat(booking): show the audit trail in the operator appointment detail modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staff and admin appointments pages already share one detail modal, opened from the row, so the timeline goes there rather than into a new surface. It mounts with the modal, which means the trail is fetched only for the appointment an operator actually opened. One row per event: an entity badge, the `from → to` edge, the actor's name or "system" for the crons, webhooks and sweeps that write no actorUserId, a relative timestamp, and the reason when one was recorded. Reschedule rows add how many times the proposal named and which round it was. Metadata only — the endpoint returns no note, no chat and no recording link, so there is none to render. The unrelated reflow in OperatorAppointmentsClient is prettier fixing formatting drift that was already on dev. Part of #1319 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --- .../dashboard/shared/AppointmentTimeline.tsx | 153 ++++++++++++++++++ .../shared/OperatorAppointmentsClient.tsx | 60 +++---- 2 files changed, 184 insertions(+), 29 deletions(-) create mode 100644 components/dashboard/shared/AppointmentTimeline.tsx 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 */}
    From 38f0408ab1f63a68e0b3373da315d138a38d5b81 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:02:13 +0530 Subject: [PATCH 4/6] test(booking): pin the merge order and the privileged-scope refusal Four cases over a mocked Prisma: the reschedule proposal interleaves with the status log by timestamp rather than clustering at either end; the actor select is exactly { id, name } and never an email; an org or personal scope throws before a single query runs; and a missing appointment resolves null without a second round trip. The merge case also asserts the history WHERE resolves by entityId, which is the only reason SLOT and RESCHEDULE_REQUEST rows appear at all. Part of #1319 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --- .../booking-timeline-read.test.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 __tests__/booking-algorithm/booking-timeline-read.test.ts 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(); + }); +}); From 561aed3a9f63a6eec5003a468b8ba5fd908688db Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:05:18 +0530 Subject: [PATCH 5/6] refactor(booking): drop two redundant enum casts in the timeline read model The array annotations already contextually type the mapped literals, so `as BookingHistoryEntity` and `as RescheduleRequestStatus` asserted what the compiler had inferred anyway. Removing them takes the now-unused RescheduleRequestStatus import with them. Part of #1319 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --- lib/data/booking-history.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/data/booking-history.ts b/lib/data/booking-history.ts index 1f7e2cbf8..a05788307 100644 --- a/lib/data/booking-history.ts +++ b/lib/data/booking-history.ts @@ -32,7 +32,6 @@ import type { BookingHistoryEntity, RescheduleInitiatorRole, - RescheduleRequestStatus, } from "@prisma/client"; import type { Scope } from "@/lib/api/scope/parse"; @@ -217,10 +216,10 @@ export async function getBookingTimeline( appointment.rescheduleRequests.map((request) => ({ id: request.id, kind: "reschedule" as const, - entity: "RESCHEDULE_REQUEST" as BookingHistoryEntity, + entity: "RESCHEDULE_REQUEST", entityId: request.id, from: null, - to: request.status as RescheduleRequestStatus, + to: request.status, reason: request.reason, actor: request.initiatedBy ? { id: request.initiatedBy.id, name: request.initiatedBy.name } From 8c96f9f6d021e8558cebd9e65324950f3fc19756 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:05:18 +0530 Subject: [PATCH 6/6] docs(booking): record the audit-trail surface in the changelog and README The wave-5 changelog gains a PR 8 entry covering the read model, why it resolves the trail through entityId rather than the always-NULL appointmentId column, the type-level privilege gate that keeps ADR 20 intact, and the staff route and modal that render it. The booking README gains a short "Reading the audit trail" section pointing at both. Part of #1319 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --- docs/booking/05-troubleshooting-and-changelog.md | 10 ++++++++++ docs/booking/README.md | 8 ++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) 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 |