111 lines
6.9 KiB
Markdown
111 lines
6.9 KiB
Markdown
|
|
# 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
|
|||
|
|
```
|