feat(booking): the audit trail is readable — a timeline read model, a staff route, and the operator detail view - #1333
feat(booking): the audit trail is readable — a timeline read model, a staff route, and the operator detail view#1333teetangh wants to merge 6 commits into
Conversation
#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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…[id]/timeline 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…l modal 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
… 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…ADME 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
✅ Deploy Preview for familiarise ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Warning Review limit reachedNext included review available in 40 minutes. View limit detailsLimit details: You’ve used the included review currently available. Your 60 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (7)
Comment |
|
|
@coderabbitai full review |
|




What
The read side of the booking audit trail (wave-5 PR 8 of #1319, the staff half of #448). #1322 made every CAS transition append a
BookingStatusHistoryrow; nothing displayed it and no staff booking surface existed.lib/data/booking-history.ts:getBookingTimeline(appointmentId, scope)merges the appointment's status-history rows (matched by the appointment's polymorphic ids: request/event/trial id, every slot id, every reschedule-request id, plusappointmentIdfor rows that carry it) with itsRescheduleRequestrows, newest first, actors as{ id, name }only. Scope is typed tokind: "all"; an org or personal caller is a compile error and a runtime throw.GET /api/staff/appointments/[appointmentId]/timelinebehindrequirePrivilegedAuth()(identical to the sibling staff appointments route), zod on the param, the platform read limiter under its own slug.OperatorAppointmentsClient) gains a Timeline section renderingfrom → to, entity badge, actor or "system", reason, relative time. Metadata only, per ADR 20: no notes, no chat, no recording links, no org surface.Finding
BookingStatusHistory.appointmentIdis NULL on every row today: no caller of the transition helpers passesmeta.appointmentId. The read model does not depend on it. Threading it at the writers is a follow-up registered on #1319.Verification
Cold
tscclean; eslint zero warnings; booking-algorithm + payments + enterprise 197 suites / 2,159 tests green (4 new). One reflow inOperatorAppointmentsClient.tsxis prettier fixing drift already ondev.Part of #1319. Part of #448 (staff visibility; the org drill-in stays out by ADR 20).
🤖 Generated with Claude Code
https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7