booking flow, loader, email, admin cli
This commit is contained in:
110
docs/progress/2026-05-01-end-to-end.md
Normal file
110
docs/progress/2026-05-01-end-to-end.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user