# 2026-05-01 — End-to-End Booking Flow > Same-day successor to `2026-05-01-availability.md`. Closes Step 4 of `Initial.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 files - `pnpm lint` — clean - `pnpm exec tsc --noEmit` — clean - `pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00` — books, emails, prints confirmation - Mailpit at `http://localhost:8025` shows 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_TZ` set to `America/Detroit` in `.env`, `.env.test`, `.env.example` - `nodemailer` + `@types/nodemailer` added ## 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 1. Customer-visible brand name (TouchBase or other) — affects email subject/body 2. Currency — USD assumed throughout 3. Stripe account ownership — needed when billing wiring lands ## Roadmap status (`Initial.md` §9) 1. ~~Spike~~ — done 2026-04-30 2. ~~Schema + seed~~ — done 2026-05-01 3. ~~Availability algorithm~~ — done 2026-05-01 4. ~~First end-to-end story (admin booking + email)~~ — **done 2026-05-01** (this session) 5. **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 ```bash 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 ```