7.9 KiB
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 ofInitial.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 servicesGET /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/nodemailerwith a brand-new email +callbackUrl→ magic link delivered to Mailpit; the link includes the encodedcallbackUrl- 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
- Customer-visible brand name — TouchBase appears in headers, footers, email subject. Affects copy.
- Currency — USD assumed.
- Stripe account ownership — needed for 5d.
- 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)
- Spike — done 2026-04-30
- Schema + seed — done 2026-05-01
- Availability algorithm — done 2026-05-01
- First end-to-end story — done 2026-05-01
- 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 (~3–5 days). 5e reminders are smallest and can come last.
How to resume
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