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>
|
||||
</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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user