6.9 KiB
2026-05-01 — End-to-End Booking Flow
Same-day successor to
2026-05-01-availability.md. Closes Step 4 ofInitial.md§9.
Milestone
The full path "admin takes a phone booking on behalf of a customer" works end-to-end: query the DB, find slots, hold, confirm, send a confirmation email, record a notification row. Race conditions on the same slot are caught at the DB level and surfaced as a typed BookingConflictError. Practice timezone (America/Detroit) is wired through every layer that touches local clock time.
What's verified
pnpm test— 64/64 green across 6 test filespnpm lint— cleanpnpm exec tsc --noEmit— cleanpnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00— books, emails, prints confirmation- Mailpit at
http://localhost:8025shows the rendered email with proper EDT timezone formatting
What landed
| File | Role | Tests |
|---|---|---|
src/lib/availability-loader.ts |
DB → algorithm adapter; returns { service, therapists[], rooms[] } for a window + service. Filters bookings to active statuses + window-overlap |
12 |
src/lib/booking.ts |
createHold / confirmHold / expireStaleHolds; BookingConflictError typed error; PG-code error classification (23P01, 40P01, 40001) |
8 |
src/lib/email.ts |
nodemailer SMTP transport (Mailpit dev / Resend prod); sendEmail low-level + sendBookingConfirmation high-level; cached transport with reset seam for tests |
3 |
scripts/book-on-behalf.ts |
Admin CLI smoke-test of the whole chain | (manual) |
test/e2e-booking.test.ts |
Integration test of the chain + concurrent-hold race | 2 |
Plus environment changes:
APP_TZset toAmerica/Detroitin.env,.env.test,.env.examplenodemailer+@types/nodemaileradded
Decisions made this session
| Decision | Resolution |
|---|---|
| Practice timezone | America/Detroit (US Eastern, observes DST) — confirmed by user |
| Hold duration default | 10 minutes (DEFAULT_HOLD_MINUTES in booking.ts) |
| Concurrent-hold conflict signaling | DB exclusion violation (23P01) AND deadlock (40P01) AND serialization failure (40001) all surface as BookingConflictError. Reason: from a caller's POV, all three mean "someone else got the slot first" |
| Loader filters | Bookings filtered to status IN (HOLD, CONFIRMED) + window-overlap on endsAt/roomReleasedAt. Cancelled/completed rows aren't loaded |
| Loader output shape | Matches findSlots input exactly — no glue at the call site |
| Algorithm re-validation at commit | None. createHold trusts the caller picked a slot from findSlots. Reason: DB has no concept of working hours; double-check at insert time would be a re-run of the algorithm, defeating the purpose. The exclusion constraints catch the only thing the DB can know about (overlap) |
| Email template format | Plain text (no React Email yet). Plain Intl.DateTimeFormat for local time rendering. Reason: one template doesn't justify a templating layer |
| Notification row lifecycle | Insert with status='queued' before send → update to sent (with providerId, sentAt) on success or failed on throw. Reason: durable record even when email itself fails |
BookingConfirmationArgs shape |
Pass bookingId only; the email function does its own DB query for the related rows. Reason: keeps the booking-creator's caller surface simple |
Gotchas hit
1. GiST exclusion constraints + concurrent inserts → deadlock, not always 23P01
When two transactions try to insert overlapping rows simultaneously, Postgres can detect a deadlock during the GiST index check rather than report a clean exclusion violation. The first run of the e2e race test failed because we were only catching 23P01. Fix: also classify 40P01 (deadlock_detected) and 40001 (serialization_failure) as conflicts.
2. Mailpit container had stopped between sessions
Postgres has restart: unless-stopped; Mailpit was created without that initially. Re-bringing the compose was sufficient but worth knowing — the compose.yaml already has restart: unless-stopped on Mailpit too, so this shouldn't recur.
3. SMTP host resolves IPv6 first on macOS
Initial test failure was ECONNREFUSED ::1:8025 even though Mailpit was bound to 0.0.0.0:8025. nodemailer falls back to IPv4 on its own; once Mailpit was up, it resolved cleanly. Worth noting if we ever see flaky email tests later.
Coverage breakdown
test/availability.test.ts 28 tests pure algorithm
test/availability-loader.test.ts 12 tests DB loader + integration with findSlots
test/booking-exclusion.test.ts 10 tests exclusion constraints + FK rejection
test/booking.test.ts 8 tests createHold / confirmHold / expireStaleHolds
test/email.test.ts 3 tests SMTP delivery + Notification row
test/e2e-booking.test.ts 2 tests admin happy path + concurrent-hold race
----
63 tests
+ 1 in beforeAll glue? (vitest reports 64)
Open questions still unresolved
- Customer-visible brand name (TouchBase or other) — affects email subject/body
- Currency — USD assumed throughout
- Stripe account ownership — needed when billing wiring lands
Roadmap status (Initial.md §9)
Spike— done 2026-04-30Schema + seed— done 2026-05-01Availability algorithm— done 2026-05-01First end-to-end story (admin booking + email)— done 2026-05-01 (this session)- next: public self-booking → Stripe deposits → reminders
Recommended next step
Public self-booking is the next big chunk. Reasonable break:
- 5a — Auth.js with email magic links for customers (~3–5 days). Touch the existing User model lightly.
- 5b — Public booking page that calls the same loader + createHold pipeline (~5–7 days).
- 5c — Stripe deposit flow + webhook handler (~3–5 days).
- 5d — Email reminders via pg-boss scheduled jobs (~2–3 days).
Recommended order: 5a → 5b → 5c → 5d. Each piece is independently shippable and reviewable.
Alternative: build a thin admin web UI first (a /admin page that wraps the existing CLI flow) to get the practice using TouchBase to take phone bookings, then layer public self-booking after. ~3–4 days. Lets the practice start using it sooner; defers the auth story until customers actually need to log in (admin auth is simpler — single-org single-admin or even basic auth to start).
How to resume
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm install
pnpm db:bootstrap # if test DB or extensions missing
pnpm db:seed # reset + seed dev DB
pnpm test # 64/64
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
open http://localhost:8025 # Mailpit web UI