customer account/bookings + cancellation email

This commit is contained in:
2026-05-02 09:07:19 -04:00
parent 4c70fe2f39
commit dbca91e06b
9 changed files with 575 additions and 18 deletions

View File

@@ -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
~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
```

View File

@@ -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<void> {
"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 (
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 text-2xl font-semibold tracking-tight">My bookings</h1>
<section className="mb-8">
<h2 className="mb-3 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Upcoming
</h2>
{upcoming.length === 0 ? (
<p className="rounded-md border border-zinc-200 bg-white p-4 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
No upcoming bookings.
</p>
) : (
<ul className="grid gap-3">
{upcoming.map((b) => (
<li
key={b.id}
className="rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950"
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-medium">{b.service.name}</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{formatLocalLong(b.startsAt)}
</div>
<div className="mt-1 text-xs text-zinc-500">
{b.service.durationMin} min
</div>
</div>
<form action={cancelAction}>
<input type="hidden" name="bookingId" value={b.id} />
<button
type="submit"
className="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/40"
>
Cancel
</button>
</form>
</div>
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="mb-3 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Past
</h2>
{past.length === 0 ? (
<p className="rounded-md border border-zinc-200 bg-white p-4 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
Nothing yet.
</p>
) : (
<ul className="grid gap-2">
{past.map((b) => (
<li
key={b.id}
className="flex items-center justify-between rounded-md border border-zinc-200 bg-white px-4 py-3 text-sm dark:border-zinc-800 dark:bg-zinc-950"
>
<div>
<div className="font-medium">{b.service.name}</div>
<div className="text-xs text-zinc-500">
{formatLocalLong(b.startsAt)}
</div>
</div>
<span
className={
b.status === "CANCELLED"
? "rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
: b.status === "NO_SHOW"
? "rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
: "rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
}
>
{b.status}
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}

View File

@@ -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 (
<div className="flex min-h-full flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-6 py-4 dark:border-zinc-800 dark:bg-zinc-950">
<div className="flex items-center gap-6">
<Link href="/" className="text-lg font-semibold tracking-tight">
TouchBase
</Link>
<nav className="flex items-center gap-4 text-sm text-zinc-600 dark:text-zinc-400">
<Link href="/account/bookings" className="hover:text-zinc-900 dark:hover:text-zinc-100">
My bookings
</Link>
<Link href="/" className="hover:text-zinc-900 dark:hover:text-zinc-100">
Book a session
</Link>
</nav>
</div>
<div className="flex items-center gap-3 text-sm text-zinc-600 dark:text-zinc-400">
<span>{session.user.email}</span>
<form action={logout}>
<button
type="submit"
className="rounded-md border border-zinc-300 px-2.5 py-1 text-xs hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900"
>
Sign out
</button>
</form>
</div>
</header>
<main className="flex-1 p-6">{children}</main>
</div>
);
}

View File

@@ -74,12 +74,20 @@ export default async function DonePage({
</dl>
</div>
<Link
href="/"
className="mt-6 block text-center text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
Back to services
</Link>
<div className="mt-6 flex items-center justify-between text-sm">
<Link
href="/"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
Back to services
</Link>
<Link
href="/account/bookings"
className="font-medium text-zinc-900 hover:underline dark:text-zinc-100"
>
My bookings
</Link>
</div>
</main>
<footer className="border-t border-zinc-200 px-6 py-4 text-xs text-zinc-500 dark:border-zinc-800">

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { addDays } from "date-fns";
import { fromZonedTime } from "date-fns-tz";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { findSlots } from "@/lib/availability";
import { loadAvailabilityState } from "@/lib/availability-loader";
@@ -55,6 +56,7 @@ export default async function BookPage({
searchParams: SearchParams;
}) {
const params = await searchParams;
const session = await auth();
const serviceId = params.serviceId;
if (!serviceId) redirect("/");
@@ -85,12 +87,21 @@ export default async function BookPage({
<Link href="/" className="text-lg font-semibold tracking-tight">
TouchBase
</Link>
<Link
href="/login"
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
Sign in
</Link>
{session?.user ? (
<Link
href="/account/bookings"
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
My bookings
</Link>
) : (
<Link
href="/login"
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
Sign in
</Link>
)}
</header>
<main className="mx-auto w-full max-w-3xl flex-1 p-8">

View File

@@ -1,9 +1,11 @@
import Link from "next/link";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export const dynamic = "force-dynamic";
export default async function Home() {
const session = await auth();
const services = await db.service.findMany({
where: { active: true },
orderBy: { durationMin: "asc" },
@@ -22,12 +24,21 @@ export default async function Home() {
<Link href="/" className="text-lg font-semibold tracking-tight">
TouchBase
</Link>
<Link
href="/login"
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
Sign in
</Link>
{session?.user ? (
<Link
href="/account/bookings"
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
My bookings
</Link>
) : (
<Link
href="/login"
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
Sign in
</Link>
)}
</header>
<main className="mx-auto w-full max-w-4xl flex-1 p-8">

View File

@@ -146,6 +146,53 @@ export async function confirmHold(
});
}
export type CancelBookingInput = {
bookingId: string;
cancelledByUserId: string;
reason?: string;
};
export type CancelBookingResult =
| { kind: "cancelled"; alreadyCancelled: false }
| { kind: "cancelled"; alreadyCancelled: true }
| { kind: "rejected"; reason: "completed" | "no_show" };
/**
* Cancel a booking. Idempotent — cancelling an already-CANCELLED booking
* returns successfully (alreadyCancelled: true). COMPLETED and NO_SHOW
* bookings are historical and cannot be cancelled.
*
* Caller is responsible for authorization (booking owner OR admin).
*/
export async function cancelBooking(
db: PrismaClient,
input: CancelBookingInput,
): Promise<CancelBookingResult> {
const existing = await db.booking.findUnique({
where: { id: input.bookingId },
select: { status: true },
});
if (!existing) throw new Error(`Booking not found: ${input.bookingId}`);
if (existing.status === "CANCELLED") {
return { kind: "cancelled", alreadyCancelled: true };
}
if (existing.status === "COMPLETED" || existing.status === "NO_SHOW") {
return { kind: "rejected", reason: existing.status === "COMPLETED" ? "completed" : "no_show" };
}
await db.booking.update({
where: { id: input.bookingId },
data: {
status: "CANCELLED",
cancelledAt: new Date(),
cancelledBy: input.cancelledByUserId,
cancelReason: input.reason ?? null,
},
});
return { kind: "cancelled", alreadyCancelled: false };
}
/**
* Sweep expired holds. Run periodically (pg-boss job, eventually). Returns count.
*/

View File

@@ -159,3 +159,53 @@ function formatLocal(d: Date, tz: string): string {
timeZoneName: "short",
}).format(d);
}
// ============================================================
// Booking cancellation
// ============================================================
export type BookingCancellationArgs = {
db: PrismaClient;
bookingId: string;
};
export async function sendBookingCancellation(
args: BookingCancellationArgs,
): Promise<SendResult> {
const booking = await args.db.booking.findUnique({
where: { id: args.bookingId },
include: {
customer: true,
service: true,
},
});
if (!booking) throw new Error(`Booking not found: ${args.bookingId}`);
const tz = process.env.APP_TZ ?? "America/Detroit";
const localStart = formatLocal(booking.startsAt, tz);
const subject = `Your ${booking.service.name} on ${localStart} is cancelled`;
const text = [
`Hi ${booking.customer.name ?? booking.customer.email},`,
"",
`Your ${booking.service.name} appointment on ${localStart} has been cancelled.`,
"",
booking.cancelReason ? `Reason: ${booking.cancelReason}` : null,
booking.cancelReason ? "" : null,
`If this was unexpected, reply to this email and we'll sort it out.`,
"",
`— TouchBase`,
]
.filter((l) => l !== null)
.join("\n");
return sendEmail({
db: args.db,
to: booking.customer.email,
subject,
text,
template: "booking_cancellation",
userId: booking.customerId,
bookingId: booking.id,
});
}

View File

@@ -3,6 +3,7 @@ import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import {
BookingConflictError,
cancelBooking,
confirmHold,
createHold,
expireStaleHolds,
@@ -174,6 +175,108 @@ describe("confirmHold", () => {
});
});
describe("cancelBooking", () => {
async function makeBooking(status: "HOLD" | "CONFIRMED" | "CANCELLED" | "COMPLETED" | "NO_SHOW" = "CONFIRMED") {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
if (status !== "HOLD") {
await db.booking.update({
where: { id: hold.id },
data: { status, holdExpiresAt: null },
});
}
return hold;
}
test("cancels a CONFIRMED booking and records audit fields", async () => {
const hold = await makeBooking("CONFIRMED");
const result = await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
reason: "test cancel",
});
expect(result).toEqual({ kind: "cancelled", alreadyCancelled: false });
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("CANCELLED");
expect(row?.cancelledBy).toBe(customerId);
expect(row?.cancelReason).toBe("test cancel");
expect(row?.cancelledAt).toBeTruthy();
});
test("cancels a HOLD booking", async () => {
const hold = await makeBooking("HOLD");
const result = await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
});
expect(result.kind).toBe("cancelled");
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("CANCELLED");
});
test("is idempotent — cancelling an already-CANCELLED booking returns alreadyCancelled", async () => {
const hold = await makeBooking("CANCELLED");
const result = await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
});
expect(result).toEqual({ kind: "cancelled", alreadyCancelled: true });
});
test("rejects cancelling a COMPLETED booking", async () => {
const hold = await makeBooking("COMPLETED");
const result = await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
});
expect(result).toEqual({ kind: "rejected", reason: "completed" });
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("COMPLETED"); // unchanged
});
test("rejects cancelling a NO_SHOW booking", async () => {
const hold = await makeBooking("NO_SHOW");
const result = await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
});
expect(result).toEqual({ kind: "rejected", reason: "no_show" });
});
test("after cancelling, the slot becomes bookable again (DB exclusion no longer blocks)", async () => {
const hold = await makeBooking("CONFIRMED");
await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
});
// Same therapist + same time should now succeed
const newHold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
expect(newHold.id).toBeTruthy();
expect(newHold.id).not.toBe(hold.id);
});
test("throws on unknown bookingId", async () => {
await expect(
cancelBooking(db, {
bookingId: "no-such-booking",
cancelledByUserId: customerId,
}),
).rejects.toThrow(/not found/);
});
});
describe("expireStaleHolds", () => {
test("expires only HOLDs whose holdExpiresAt is past", async () => {
// Create one fresh hold (10 min default) and manually backdate another.