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

102 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 test`**71/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.
## Recommended next step
**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
```bash
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
```