Skip to content
Open
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
159 changes: 159 additions & 0 deletions __tests__/booking-algorithm/booking-timeline-read.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
68 changes: 68 additions & 0 deletions app/api/staff/appointments/[appointmentId]/timeline/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
153 changes: 153 additions & 0 deletions components/dashboard/shared/AppointmentTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 }) {

Check warning on line 64 in components/dashboard/shared/AppointmentTimeline.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaBjoLxroeo9MKJ4dLxd&open=AaBjoLxroeo9MKJ4dLxd&pullRequest=1333
return (
<li className="relative border-l border-border pl-4">
<span className="absolute -left-[3px] top-2 h-1.5 w-1.5 rounded-full bg-muted-foreground/60" />
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="bg-muted text-muted-foreground">
{entityLabel(entry.entity)}
</Badge>
<span className="font-mono text-xs text-foreground">
{transitionLabel(entry)}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{actorLabel(entry)} • {relativeTime(entry.createdAt)}
{entry.kind === "reschedule" && (
<>
{" • "}
{entry.proposedSlotCount === 1
? "1 proposed time"
: `${entry.proposedSlotCount ?? 0} proposed times`}
{entry.round ? ` • round ${entry.round}` : ""}
</>
)}
</p>
{entry.reason && (
<p className="mt-1 text-xs text-muted-foreground/80">{entry.reason}</p>
)}
</li>
);
}

export function AppointmentTimeline({
appointmentId,
}: {
appointmentId: string;
}) {

Check warning on line 99 in components/dashboard/shared/AppointmentTimeline.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaBjoLxroeo9MKJ4dLxe&open=AaBjoLxroeo9MKJ4dLxe&pullRequest=1333
const { data, isLoading, error } = useQuery<BookingTimeline>({
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 (
<div>
<Label className="flex items-center gap-2 text-xs text-muted-foreground">
<History className="h-3.5 w-3.5" />
Timeline
</Label>

{isLoading ? (
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading the audit trail…
</div>
) : error ? (
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<AlertTriangle className="h-3.5 w-3.5" />
Could not load the audit trail.
</div>
) : entries.length === 0 ? (
<p className="mt-3 text-xs text-muted-foreground">
Nothing has moved on this booking yet.
</p>
) : (
<>
<ul className="mt-3 max-h-64 space-y-3 overflow-y-auto pr-1">
{entries.map((entry) => (
<TimelineRow key={`${entry.kind}:${entry.id}`} entry={entry} />
))}
</ul>
{data?.truncated && (
<p className="mt-2 text-xs text-muted-foreground/70">
Showing the most recent {entries.length} events; older ones are
not displayed.
</p>
)}
</>
)}

Check warning on line 150 in components/dashboard/shared/AppointmentTimeline.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaBjoLxroeo9MKJ4dLxg&open=AaBjoLxroeo9MKJ4dLxg&pullRequest=1333

Check warning on line 150 in components/dashboard/shared/AppointmentTimeline.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaBjoLxroeo9MKJ4dLxf&open=AaBjoLxroeo9MKJ4dLxf&pullRequest=1333
</div>
);
}
Loading
Loading