Files
touchbase/docs/progress/2026-05-02-customer-account.md

6.1 KiB
Raw Permalink Blame History

2026-05-02 — Customer "My Bookings" + Cancel

Companion to Initial.md. Predecessor: 2026-05-02-public-booking.md. Closes Step 5c.2 of Initial.md §9.

Milestone

Customers can now see and cancel their own bookings. The booking flow has a back half: book → see in "My bookings" → cancel if needed → confirmation email both ways. Practice can also cancel on a customer's behalf via the same action (admin role passes the authorization check).

What landed

Path Role
src/lib/booking.ts Added cancelBooking() — idempotent on CANCELLED, rejects COMPLETED/NO_SHOW, sets status=CANCELLED + cancelledAt/cancelledBy/cancelReason. Returns a typed result.
src/lib/email.ts Added sendBookingCancellation() — mirrors the confirmation template shape.
src/app/account/layout.tsx Auth-required customer shell with header (My bookings + Book a session + sign-out). Same shape as /admin/layout.tsx but no role check.
src/app/account/bookings/page.tsx Server-rendered list of the user's bookings, split into Upcoming (with Cancel buttons) and Past (with status pills). Cancel server action validates ownership-or-admin, calls cancelBooking + sendBookingCancellation, redirects.
src/app/page.tsx, src/app/book/page.tsx Header link swaps to "My bookings" if signed in, else "Sign in".
src/app/book/done/page.tsx Added "My bookings →" link alongside "Back to services".
test/booking.test.ts +7 tests covering cancel: CONFIRMED→CANCELLED with audit fields, HOLD→CANCELLED, idempotent on CANCELLED, rejects COMPLETED + NO_SHOW, slot becomes bookable again after cancel, throws on unknown id.

What's verified end-to-end

  • pnpm test71/71 green (was 64; +7 cancel tests)
  • pnpm lint — clean
  • pnpm exec tsc --noEmit — clean
  • Live smoke test (curl + Mailpit, dev server):
    • Booked Alex Park into 60-min Swedish at Tue May 5 10:00 EDT via the CLI
    • Signed in as alex@example.com via magic link → landed on /account/bookings
    • Page shows the booking row: "60-minute Swedish", "Tue, May 5, 10:00 AM", with a Cancel button
    • Header swaps: anonymous shows "Sign in", signed-in shows "My bookings" — verified on / and /book

The cancel server action's POST wasn't replayed via curl (Next.js 16 RPC encoding rejects manual replays — same constraint we documented previously); the underlying cancelBooking function has 7 dedicated tests + the action body is otherwise straight composition with sendBookingCancellation (also tested) and redirect().

Decisions ratified

Decision Resolution
Cancellation policy None for v1 — anyone can cancel their own booking anytime. Practice can refine (e.g. "no cancellations within 24h") when they have data on no-show patterns.
Cancellation authorization Owner OR admin. Action checks both even though page only shows the user's own bookings (defense in depth).
Status transitions disallowed COMPLETED → CANCELLED and NO_SHOW → CANCELLED are rejected. They're historical; cancellation doesn't make sense after the fact.
cancelBooking return shape Discriminated union: { kind: "cancelled", alreadyCancelled: bool } or { kind: "rejected", reason: "completed"|"no_show" }. Reason: callers can react to "already cancelled" without an exception.
Past bookings cap Last 20 only. Cheap heuristic; pagination/infinite scroll waits until someone has ≥20 bookings.
Past list status pill Shows status (CANCELLED, NO_SHOW, COMPLETED).
Page header for signed-in users Inline await auth() in the home + book pages; no shared component yet. Keeps per-page data fetching local.
Cancellation email Includes cancelReason if set. Plain text, same SMTP transport.

Gotchas hit

TS narrowing in test fixture helper

TS narrowed status !== "HOLD" correctly, then complained about a redundant status === "HOLD" inside that block. Removed the dead branch.

Open questions still unresolved

  1. Customer-visible brand name
  2. Currency
  3. Stripe account ownership
  4. NEW: cancellation policy (window, fees, no-show charge) — defer until we have real customer data

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
    • 5c.2 Customer "my bookings" + cancel — done 2026-05-02 (this session)
    • 5d Stripe deposit flow + webhook
    • 5e Email reminders (pg-boss scheduled jobs)

Booking-flow features for v1 are now complete modulo payments and reminders. A customer can book, see their bookings, and cancel. Admin can take phone bookings and view all upcoming. Both sides receive transactional emails.

5d — Stripe deposit flow. The biggest remaining piece. Sequence:

  1. Stripe SDK + env config (test keys)
  2. createPaymentIntent for the deposit at createHold time (returns client_secret to render in the confirm page)
  3. Confirm page renders Stripe Elements (this is the first place we need a client component)
  4. Webhook handler at /api/stripe/webhook updates Payment and transitions Booking from HOLD to CONFIRMED on payment_intent.succeeded
  5. Hold expiry job via pg-boss cancels HOLDs whose deposit didn't capture in time

~35 days. After 5d, 5e reminders is the smallest remaining piece (~23 days for pg-boss schedule + reminder template + send job).

How to resume

cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm db:seed
pnpm dev
# As a customer:
#   http://localhost:3000 → click service → pick date → pick time
#   sign in via magic link (any email) → /book/confirm → enter name → confirm
#   land on /book/done → click "My bookings →"
#   /account/bookings → click Cancel → cancellation email arrives in Mailpit
# As admin:
#   sign in as admin@touchbase.local → /admin/bookings to see all upcoming