customer account/bookings + cancellation email
This commit is contained in:
101
docs/progress/2026-05-02-customer-account.md
Normal file
101
docs/progress/2026-05-02-customer-account.md
Normal 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
|
||||||
|
|
||||||
|
~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
|
||||||
|
```
|
||||||
175
src/app/account/bookings/page.tsx
Normal file
175
src/app/account/bookings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/app/account/layout.tsx
Normal file
51
src/app/account/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,12 +74,20 @@ export default async function DonePage({
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<div className="mt-6 flex items-center justify-between text-sm">
|
||||||
href="/"
|
<Link
|
||||||
className="mt-6 block text-center text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
href="/"
|
||||||
>
|
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||||
← Back to services
|
>
|
||||||
</Link>
|
← 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>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t border-zinc-200 px-6 py-4 text-xs text-zinc-500 dark:border-zinc-800">
|
<footer className="border-t border-zinc-200 px-6 py-4 text-xs text-zinc-500 dark:border-zinc-800">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { addDays } from "date-fns";
|
import { addDays } from "date-fns";
|
||||||
import { fromZonedTime } from "date-fns-tz";
|
import { fromZonedTime } from "date-fns-tz";
|
||||||
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { findSlots } from "@/lib/availability";
|
import { findSlots } from "@/lib/availability";
|
||||||
import { loadAvailabilityState } from "@/lib/availability-loader";
|
import { loadAvailabilityState } from "@/lib/availability-loader";
|
||||||
@@ -55,6 +56,7 @@ export default async function BookPage({
|
|||||||
searchParams: SearchParams;
|
searchParams: SearchParams;
|
||||||
}) {
|
}) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
const session = await auth();
|
||||||
const serviceId = params.serviceId;
|
const serviceId = params.serviceId;
|
||||||
if (!serviceId) redirect("/");
|
if (!serviceId) redirect("/");
|
||||||
|
|
||||||
@@ -85,12 +87,21 @@ export default async function BookPage({
|
|||||||
<Link href="/" className="text-lg font-semibold tracking-tight">
|
<Link href="/" className="text-lg font-semibold tracking-tight">
|
||||||
TouchBase
|
TouchBase
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{session?.user ? (
|
||||||
href="/login"
|
<Link
|
||||||
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
href="/account/bookings"
|
||||||
>
|
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||||
Sign in
|
>
|
||||||
</Link>
|
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>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-3xl flex-1 p-8">
|
<main className="mx-auto w-full max-w-3xl flex-1 p-8">
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
|
const session = await auth();
|
||||||
const services = await db.service.findMany({
|
const services = await db.service.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
orderBy: { durationMin: "asc" },
|
orderBy: { durationMin: "asc" },
|
||||||
@@ -22,12 +24,21 @@ export default async function Home() {
|
|||||||
<Link href="/" className="text-lg font-semibold tracking-tight">
|
<Link href="/" className="text-lg font-semibold tracking-tight">
|
||||||
TouchBase
|
TouchBase
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{session?.user ? (
|
||||||
href="/login"
|
<Link
|
||||||
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
href="/account/bookings"
|
||||||
>
|
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||||
Sign in
|
>
|
||||||
</Link>
|
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>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-4xl flex-1 p-8">
|
<main className="mx-auto w-full max-w-4xl flex-1 p-8">
|
||||||
|
|||||||
@@ -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.
|
* Sweep expired holds. Run periodically (pg-boss job, eventually). Returns count.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -159,3 +159,53 @@ function formatLocal(d: Date, tz: string): string {
|
|||||||
timeZoneName: "short",
|
timeZoneName: "short",
|
||||||
}).format(d);
|
}).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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PrismaPg } from "@prisma/adapter-pg";
|
|||||||
import { PrismaClient } from "@/generated/prisma/client";
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
import {
|
import {
|
||||||
BookingConflictError,
|
BookingConflictError,
|
||||||
|
cancelBooking,
|
||||||
confirmHold,
|
confirmHold,
|
||||||
createHold,
|
createHold,
|
||||||
expireStaleHolds,
|
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", () => {
|
describe("expireStaleHolds", () => {
|
||||||
test("expires only HOLDs whose holdExpiresAt is past", async () => {
|
test("expires only HOLDs whose holdExpiresAt is past", async () => {
|
||||||
// Create one fresh hold (10 min default) and manually backdate another.
|
// Create one fresh hold (10 min default) and manually backdate another.
|
||||||
|
|||||||
Reference in New Issue
Block a user