Files
touchbase/docs/progress/2026-05-01-end-to-end.md

111 lines
6.9 KiB
Markdown
Raw Normal View History

2026-05-01 18:59:19 -04:00
# 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 (~35 days). Touch the existing User model lightly.
- **5b — Public booking page** that calls the same loader + createHold pipeline (~57 days).
- **5c — Stripe deposit flow + webhook handler** (~35 days).
- **5d — Email reminders** via pg-boss scheduled jobs (~23 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. ~34 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
```