Files
touchbase/docs/progress/2026-05-02-booking-lifecycle.md
2026-05-02 14:05:30 -04:00

7.0 KiB

2026-05-02 — Booking Lifecycle (Phase C)

Companion to BuildLog.md. Predecessor: 2026-05-02-therapist-self-serve.md. Closes Phase C of the post-payments UX plan.

Milestone

The booking lifecycle has all the verbs the practice needs: create, view, reschedule, mark complete, mark no-show, cancel — for both customer and admin. Reschedule is atomic (cancel-old + create-new in one transaction; rollback on conflict so the customer never loses their old slot to a phantom new booking). Single "rescheduled" email replaces the cancel + new-confirmation pair. Booking detail pages exist for both customer and admin.

What landed

Path Role
src/lib/booking.ts +rescheduleBooking() (atomic, transactional, surfaces BookingConflictError if new slot collides — old booking stays intact). +markComplete() and +markNoShow() (CONFIRMED→target only, idempotent).
src/lib/email.ts +sendBookingRescheduled() template (one email summarizing old → new).
src/app/admin/bookings/[id]/page.tsx Detail page — all booking fields incl. cancel-audit, payment-status placeholder, room-released-at, action buttons (Mark complete / Mark no-show / Cancel). Mark-* buttons only appear on past CONFIRMED bookings.
src/app/account/bookings/[id]/page.tsx Customer detail page — service / when / therapist / room / price + Reschedule + Cancel. Owner-or-admin authorization on the action.
src/app/book/page.tsx Accepts fromBookingId param — shows "Rescheduling your X — pick a new time" banner; threads param through to slot links and the date picker form.
src/app/book/confirm/page.tsx Detects fromBookingId and routes through rescheduleBooking + sendBookingRescheduled instead of createHold + sendBookingConfirmation. Page heading and copy adapt. Conflict redirects back to /book with taken=1 and the fromBookingId preserved.
src/app/account/bookings/page.tsx Per-row Reschedule link → /book?serviceId=…&fromBookingId=…. Booking row is now itself a link to the detail page.
src/app/admin/bookings/page.tsx Booking row links to detail page. Therapist-name fallback for null.
test/booking.test.ts +8 tests: reschedule happy path, atomic rollback on conflict, rejects rescheduling CANCELLED, can change therapist/room on reschedule, markComplete CONFIRMED→COMPLETED, markComplete idempotent, markComplete rejects HOLD, markNoShow CONFIRMED→NO_SHOW.

What's verified

  • pnpm test79/79 green (was 71; +8 new in this session)
  • pnpm lint — clean
  • pnpm exec tsc --noEmit — clean
  • Live smoke test (curl + Mailpit, dev server):
    • Customer (alex@example.com): signed in via magic link → /account/bookings shows Reschedule + Cancel buttons → click Reschedule → /book?fromBookingId=… shows "Rescheduling your..." banner and slot links carry fromBookingId → click new slot → /book/confirm shows "Confirm reschedule" heading, "previous booking will be cancelled" copy, hidden fromBookingId field
    • Admin (admin@touchbase.local): /admin/bookings/{id} renders full detail with customer name, therapist link, room link, all fields, Cancel button (Mark complete / no-show buttons hidden because the booking is in the future — they appear only after the booking end time)

Decisions ratified

Decision Resolution
Reschedule semantics Atomic — cancel-old + create-new in one db.$transaction. If the new insert fails (conflict, FK, etc.), the entire transaction rolls back; old booking stays intact. Reason: customer should never lose their slot to a phantom new booking.
Reschedule email One rescheduled email, not cancel + new-confirmation pair. Reason: cleaner customer experience; one notification with old-vs-new framing.
Mark complete / no-show timing Buttons only render when status === "CONFIRMED" && booking.endsAt < now. Reason: marking a future booking complete is meaningless and almost always a misclick.
Mark complete / no-show idempotency Re-marking returns { ok: true } (no-op). Switching from one final state to another is rejected. Reason: prevents accidental status flips after the fact.
Cancel reason on reschedule "rescheduled" literal in Booking.cancelReason. Reason: distinguishable from customer/admin cancellation in audit; could drive metrics later.
Authorization on customer detail page Owner or admin can view; otherwise redirect to /account/bookings. Same on cancel action.
Authorization on admin detail page Layout already requires role=ADMIN; actions re-check on the safe side.
/account/bookings/[id] reachable by admins Yes — they may want to debug from a customer's POV. The cancel button still works.
Reschedule preserves service by default rescheduleBooking defaults newServiceId/therapist/room to the old booking's. The customer flow always passes the same serviceId; admin could pass different ones if/when we build admin-side reschedule.
Mark-* helpers return discriminated unions { ok: true } or { ok: false; reason: string }. Reason: same shape as cancelBooking so the UI can react without exceptions.

Gotchas hit

None — clean session.

Open questions

  1. Customer-visible brand name (still pending)
  2. Currency
  3. Stripe account ownership
  4. NEW: should reschedule require explicit confirmation if the old booking was CONFIRMED with a deposit? Not relevant until 5d Stripe.
  5. NEW: admin "reschedule on customer's behalf" UI flow — currently admins cancel and the customer rebooks. We have the rescheduleBooking helper; the admin UI for it is a future enhancement.

Roadmap status

UX-completeness:

  • A — Admin CRUD: done 2026-05-02
  • B — Therapist self-serve: done 2026-05-02
  • C — Booking lifecycle UX: done 2026-05-02 (this session)
  • D — PWA shell (per BuildLog.md §11)
  • E — Polish — 404, empty states, mobile review, dark-mode glance

Phase D — PWA shell. Per BuildLog.md §11, this is the "Stage 1" mobile story we promised at bootstrap:

  • manifest.webmanifest with name, icons, display: "standalone", theme color, start URL
  • A minimal service worker (next-pwa or hand-rolled with Workbox) — network-first for HTML, stale-while-revalidate for static assets, no caching of API/booking endpoints
  • iOS Safari quirks: separate apple-touch-icon, apple-mobile-web-app-capable, status bar style
  • Test on a phone or in DevTools "Add to Home Screen"

~half-day to a full day. After D, Phase E (~half-day): 404 page, empty-state polish, mobile-first audit, dark-mode glance.

How to resume

cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm db:seed
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
pnpm dev
# As alex: sign in → /account/bookings → click row to see detail → Reschedule → pick new time → confirm
# As admin: sign in → /admin/bookings → click row → view detail with action buttons