diff --git a/docs/progress/2026-05-02-customer-account.md b/docs/progress/2026-05-02-customer-account.md new file mode 100644 index 0000000..604e5fe --- /dev/null +++ b/docs/progress/2026-05-02-customer-account.md @@ -0,0 +1,101 @@ +# 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 + +~3–5 days. After 5d, **5e reminders** is the smallest remaining piece (~2–3 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 +``` diff --git a/src/app/account/bookings/page.tsx b/src/app/account/bookings/page.tsx new file mode 100644 index 0000000..46d1da9 --- /dev/null +++ b/src/app/account/bookings/page.tsx @@ -0,0 +1,175 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { cancelBooking } from "@/lib/booking"; +import { sendBookingCancellation } from "@/lib/email"; + +export const metadata = { title: "My bookings — TouchBase" }; +export const dynamic = "force-dynamic"; + +const TZ = process.env.APP_TZ ?? "America/Detroit"; + +function formatLocalLong(d: Date): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: TZ, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }).format(d); +} + +async function cancelAction(formData: FormData): Promise { + "use server"; + const bookingId = String(formData.get("bookingId") ?? ""); + if (!bookingId) return; + + const session = await auth(); + if (!session?.user) redirect("/login?callbackUrl=/account/bookings"); + + // Authorization: owner OR admin + const booking = await db.booking.findUnique({ + where: { id: bookingId }, + select: { customerId: true }, + }); + if (!booking) return; + const isOwner = booking.customerId === session!.user.id; + const isAdmin = session!.user.role === "ADMIN"; + if (!isOwner && !isAdmin) { + // Quietly redirect rather than 403 — pages have already filtered this out + redirect("/account/bookings"); + } + + const result = await cancelBooking(db, { + bookingId, + cancelledByUserId: session!.user.id, + reason: isOwner ? "customer cancellation" : "admin cancellation", + }); + if (result.kind === "cancelled" && !result.alreadyCancelled) { + await sendBookingCancellation({ db, bookingId }); + } + redirect("/account/bookings"); +} + +export default async function MyBookingsPage() { + const session = await auth(); + if (!session?.user) redirect("/login?callbackUrl=/account/bookings"); + + const userId = session!.user.id; + const now = new Date(); + + const [upcoming, past] = await Promise.all([ + db.booking.findMany({ + where: { + customerId: userId, + status: { in: ["HOLD", "CONFIRMED"] }, + startsAt: { gte: now }, + }, + include: { + service: { select: { name: true, durationMin: true } }, + }, + orderBy: { startsAt: "asc" }, + }), + db.booking.findMany({ + where: { + customerId: userId, + OR: [ + { startsAt: { lt: now } }, + { status: { in: ["CANCELLED", "COMPLETED", "NO_SHOW"] } }, + ], + }, + include: { + service: { select: { name: true, durationMin: true } }, + }, + orderBy: { startsAt: "desc" }, + take: 20, + }), + ]); + + return ( +
+

My bookings

+ +
+

+ Upcoming +

+ {upcoming.length === 0 ? ( +

+ No upcoming bookings. +

+ ) : ( +
    + {upcoming.map((b) => ( +
  • +
    +
    +
    {b.service.name}
    +
    + {formatLocalLong(b.startsAt)} +
    +
    + {b.service.durationMin} min +
    +
    +
    + + +
    +
    +
  • + ))} +
+ )} +
+ +
+

+ Past +

+ {past.length === 0 ? ( +

+ Nothing yet. +

+ ) : ( +
    + {past.map((b) => ( +
  • +
    +
    {b.service.name}
    +
    + {formatLocalLong(b.startsAt)} +
    +
    + + {b.status} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/account/layout.tsx b/src/app/account/layout.tsx new file mode 100644 index 0000000..eee6bae --- /dev/null +++ b/src/app/account/layout.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { auth, signOut } from "@/auth"; + +export const dynamic = "force-dynamic"; + +async function logout() { + "use server"; + await signOut({ redirectTo: "/" }); +} + +export default async function AccountLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await auth(); + if (!session?.user) redirect("/login?callbackUrl=/account/bookings"); + + return ( +
+
+
+ + TouchBase + + +
+
+ {session.user.email} +
+ +
+
+
+
{children}
+
+ ); +} diff --git a/src/app/book/done/page.tsx b/src/app/book/done/page.tsx index 9ee7cdc..9f89539 100644 --- a/src/app/book/done/page.tsx +++ b/src/app/book/done/page.tsx @@ -74,12 +74,20 @@ export default async function DonePage({ - - ← Back to services - +
+ + ← Back to services + + + My bookings → + +