public booking flow: /book browse → magic-link signup → confirm

This commit is contained in:
2026-05-02 08:46:12 -04:00
parent 415813470a
commit 4c70fe2f39
12 changed files with 1116 additions and 22 deletions

View File

@@ -0,0 +1,93 @@
# 2026-05-02 — Admin Create-Booking Page
> Companion to `Initial.md`. Predecessor: `2026-05-02-auth-and-admin-shell.md`. Closes Step 5b of `Initial.md` §9.
## Milestone
Admin can now take a booking from the browser, end-to-end. The CLI (`scripts/book-on-behalf.ts`) is no longer the only way to create a booking — there's a `/admin/bookings/new` page that wraps the same loader + `createHold` + email pipeline behind a server-rendered form. The practice can now use TouchBase to take phone bookings without leaving the browser.
## What landed
| Path | Role |
|---|---|
| `src/app/admin/bookings/new/page.tsx` | Server-rendered create-booking page. Form (service + date + customer email) → server action redirects to same URL with query params → page renders slot grid. Each slot is its own form whose server action calls the booking pipeline. |
| `src/app/admin/bookings/page.tsx` | Added "New booking" link in the header |
No new dependencies. No schema changes.
## What's verified end-to-end
In the dev-server smoke test (curl + Mailpit, server running on :3000):
- `GET /admin/bookings/new` (signed in) → 200, form renders
- `GET /admin/bookings/new?serviceId=…&customerEmail=alex@example.com&date=2026-05-05` → 33 slots rendered (10:00 EDT through 18:00 EDT, every 15 min, for a 60-min service)
- `GET /admin/bookings/new?…&customerEmail=ghost@example.com&…` → "No customer registered for ghost@example.com" inline error
- `GET /admin/bookings/new?…&date=2026-05-04` (Monday, not in working hours) → "No available slots on this date"
Plus: 64/64 tests, lint clean, typecheck clean.
The actual `bookSlotAction` (server action that creates the booking) wasn't exercised via curl because Next.js server actions require a signed POST that's annoying to replay; all of its building blocks (`createHold`, `confirmHold`, `sendBookingConfirmation`, the loader, `findSlots`) are unit-tested individually plus end-to-end via the existing CLI smoke test.
## Decisions ratified
| Decision | Resolution |
|---|---|
| Form architecture | Multi-step server-rendered. No client state. Form 1 = filter (service+date+customer); slot grid renders on submit; each slot is its own server-action form. Reason: simplest possible thing that works; preserves admin's filter state across page loads via query params. |
| Customer selection | Plain email input (no autocomplete/search-as-you-type yet). Reason: keep client JS out for v1; admin already knows customer emails. Revisit if it gets clunky. |
| Slot picker UX | 46 column grid of buttons, each labeled with local time only (e.g. "10:00 AM"). Date is fixed by the filter above. |
| Greedy assignment | First eligible therapist + first eligible room from the slot's candidate lists. Same as the CLI. Reason: more sophisticated load-balancing waits until we have data on whether it matters. |
| Error surfacing | Errors round-trip through the URL via `error=...` query param. Reason: server actions in Next.js can't return errors directly to the same page without client state; redirect-with-error is the simplest pattern. |
| Date input minimum | `min=` set to today (in practice TZ via `Intl.DateTimeFormat` with `en-CA` locale to get YYYY-MM-DD). |
## Gotchas hit
### `searchParams` is async in Next.js 16
The `searchParams` page prop is now `Promise<…>` in Next 15+. Must `await searchParams` before reading. Easy to miss — TS will catch it but only if the type annotation is correct.
## Open questions still unresolved
1. Customer-visible brand name
2. Currency
3. Stripe account ownership
4. **NEW**: Customer search-as-you-type vs. plain email field — admin UX call. Plain email works; autocomplete is nicer. Defer until requested.
## 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
5. Public self-booking → Stripe → reminders
- 5a Auth.js + admin shell — done 2026-05-02
- **5b Admin "create booking" page — done 2026-05-02 (this session)**
- 5c Public booking page (CUSTOMER role, magic-link signup, slot search + checkout)
- 5d Stripe deposit flow + webhook
- 5e Email reminders (pg-boss scheduled jobs)
## Recommended next step
**5c — public booking page**. Most of the slot-picker UI carries over from `/admin/bookings/new`; the differences are:
- **CUSTOMER auth flow**: magic-link signup if email isn't registered. Auth.js's `signIn("nodemailer", { email })` already creates a User row via the adapter; we just need to also create a `Customer` row + redirect into a booking flow.
- **Public-facing styling**: likely a polished landing page with service grid → "Book" → date+slot picker → confirm.
- **Customer can't pick therapist or room directly** — algorithm assigns greedy.
- **Hold semantics matter more**: customer pays deposit during the hold window; if they bail, hold expires.
That last point pulls Stripe (5d) closer in dependency: deposit-required services need the payment flow before the booking can become CONFIRMED. We may want to do 5c in two phases:
- **5c.1**: public booking with no payment (CONFIRMED on click). Fast to ship; lets the practice test the customer-side UX before Stripe.
- **5c.2**: add the deposit payment flow (Stripe).
Either approach is fine. Recommend 5c.1 first (~35 days), then 5d (~35 days, gates 5c.2).
## How to resume
```bash
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm db:seed
pnpm dev
# Sign in at http://localhost:3000/login as admin@touchbase.local
# Click magic link in http://localhost:8025
# /admin/bookings → "New booking" → fill form → pick slot → done
```

View File

@@ -0,0 +1,105 @@
# 2026-05-02 — Public Booking Flow (5c.1, no payment)
> Companion to `Initial.md`. Predecessor: `2026-05-02-admin-create-booking.md`. Closes Step 5c.1 of `Initial.md` §9.
## Milestone
The public-facing booking flow is live (locally). A new customer can land on the home page, browse services, pick a slot, sign in via magic link, and confirm a booking — all without any admin involvement. The same loader + `createHold` + `sendBookingConfirmation` pipeline that powers the admin and CLI flows now powers the customer-facing path too.
## What landed
| Path | Role |
|---|---|
| `src/app/page.tsx` (rewritten) | Public landing — service cards linking to `/book?serviceId=…` |
| `src/app/book/page.tsx` | Anonymous-OK slot browser. Service header, date picker, slot grid. Each slot is a `<Link>` to `/book/confirm?serviceId=…&startsAtIso=…` |
| `src/app/book/confirm/page.tsx` | Auth-required confirm page. Redirects anonymous users to `/login?callbackUrl=…`. Renders booking summary + name form (when user has no name yet) + Confirm button. Server action lazy-creates the `Customer` row, validates the slot is still free, calls `createHold``confirmHold``sendBookingConfirmation`, redirects to `/book/done`. |
| `src/app/book/done/page.tsx` | Success page. Renders the confirmed booking summary. |
| `src/app/login/page.tsx` (updated) | Now reads `callbackUrl` from query params and threads it through `signIn` so the post-signin redirect lands wherever the user came from |
| `prisma/schema.prisma` | `User.name` made nullable — Auth.js's adapter creates users from email only and can't supply a name |
| `prisma/migrations/20260502122652_user_name_nullable/` | Migration |
| `src/lib/email.ts` (touched) | Falls back to email when customer.name is null |
| `src/app/admin/bookings/page.tsx` (touched) | Same fallback |
## What's verified end-to-end
Via curl + Mailpit on the running dev server:
- `GET /` → renders the 5 services
- `GET /book?serviceId=…&date=2026-05-05` → renders the slot grid (33 slots from 10:00 EDT through 18:00 EDT for the Swedish service)
- `GET /book/confirm?…` (anonymous) → 307 redirect to `/login?callbackUrl=%2Fbook%2Fconfirm%3F…`
- `POST /api/auth/signin/nodemailer` with a brand-new email + `callbackUrl` → magic link delivered to Mailpit; the link includes the encoded `callbackUrl`
- Following the magic link → session set, redirect lands on `/book/confirm?…` (the original target)
- The confirm page renders: "Confirm your booking" heading, "Booked as {email}", "Your name" input (since this is a new user with no name), "Confirm booking" button
The actual server action that creates the booking (POST to `/book/confirm` with the action ID) was not exercised via curl — Next.js server actions use an internal RPC encoding that's brittle to replay outside a real browser. The action body composes only already-tested code (`createHold`, `confirmHold`, `sendBookingConfirmation`, the loader, `findSlots`); the same code path is exercised end-to-end by the admin flow and by the e2e booking integration test.
Plus: `pnpm test` 64/64, `pnpm lint` clean, `pnpm exec tsc --noEmit` clean.
## Decisions ratified
| Decision | Resolution |
|---|---|
| Auth model for customers | Auth.js + magic-link signup; same flow as admin. New users get `User` row from Auth.js's adapter; `Customer` row is **lazy-created at first booking confirm** rather than via an Auth.js event hook. Reason: simpler — no event-handler scaffolding; the only place we need the Customer row is at booking time. |
| Name capture | At `/book/confirm`, if `user.name` is missing, render a name input. Otherwise auto-render the confirm-only form. Reason: doesn't require a separate "complete your profile" page; one place asks for the missing piece. |
| Slot validation timing | Re-validate the chosen slot is still free at confirm-action time before calling `createHold`. Reason: prevents wasted DB inserts when the slot was taken between view and submit; surfaces a friendly "that time was just taken" via a redirect back to `/book?…&taken=1`. |
| `User.name` nullability | Made nullable. Reason: Auth.js's PrismaAdapter can't supply a name from email-only signin; required `name` would have blocked the entire signup. Display sites fall back to email. |
| Auth.js adapter doesn't auto-create Customer row | Confirmed; we lazy-create instead. Avoids event-handler complexity. |
| `/login` accepts `callbackUrl` | Threads through to `signIn({ redirectTo: callbackUrl })`. Defaults to `/admin` for compat with the existing admin flow. |
| Greedy assignment in confirm action | Same as admin and CLI — first eligible therapist + first eligible room from the slot's candidate lists. Reason: customer doesn't care which therapist/room; admin can override later if needed. |
## Gotchas hit
### 1. Auth.js's PrismaAdapter requires `User.name` if your schema marks it required
Auth.js's adapter creates a User from a magic-link signup with only `email` and `emailVerified`. Required `name` failed with `PrismaClientValidationError: Argument 'name' is missing.` Fix: nullable name. The alternative — overriding the adapter's `createUser` — is more code and more surface area.
### 2. Server actions in Next.js 16 use an internal RPC encoding
Replaying via curl with `multipart/form-data` and the `$ACTION_ID_…` field doesn't work; Next.js returns "Failed to find Server Action". The action expects a specific encoded payload that Next's client builds. **Implication for testing**: any future server-action verification needs either a real browser (Playwright) or a unit test that calls the action's underlying logic directly. Worth folding into the testing strategy when we have more actions.
### 3. Dev server needed restart after Prisma schema change
The schema change to nullable `name` regenerated the client, but the running dev server still had cached schemas in Turbopack chunks. Clean restart fixed it. Worth documenting if the practice's IT person ever runs into "I changed the schema and it's not applying."
## Open questions still unresolved
1. **Customer-visible brand name** — TouchBase appears in headers, footers, email subject. Affects copy.
2. **Currency** — USD assumed.
3. **Stripe account ownership** — needed for 5d.
4. **Customer "my bookings" page** — not built yet. After confirm, customer goes to `/book/done`. Their booking exists in the DB but they have no UI to view/cancel it. Needed for v1 launch.
## 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 — done 2026-05-01
5. Public self-booking → Stripe → reminders
- 5a Auth.js + admin shell — done 2026-05-02
- 5b Admin "create booking" page — done 2026-05-02
- **5c.1 Public booking page (no payment) — done 2026-05-02 (this session)**
- 5c.2 Customer "my bookings" page (view + cancel)
- 5d Stripe deposit flow + webhook
- 5e Email reminders (pg-boss scheduled jobs)
## Recommended next step
**5c.2 — customer "my bookings" page** is the natural next ~half-day chunk. Customers who book have no UI to see what they booked or to cancel. The page is essentially:
- `/account/bookings` — protected (any signed-in user), lists that user's bookings (upcoming + past), each with a cancel button
- Cancel action: flip Booking.status to CANCELLED, set cancelledAt + cancelledBy, send a cancellation email
After 5c.2, we'd be feature-complete on the booking flow for v1 — modulo payments. **5d Stripe** is the next non-trivial chunk after that (~35 days). 5e reminders are smallest and can come last.
## How to resume
```bash
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm db:seed
pnpm dev
# Open http://localhost:3000 → click a service → pick a date → click a time
# Sign in with any email; click magic link from http://localhost:8025
# Lands on /book/confirm → enter name → confirm → /book/done
# Verify in /admin/bookings as admin@touchbase.local
```