booking flow, loader, email, admin cli

This commit is contained in:
2026-05-01 18:59:19 -04:00
parent b513accdf5
commit c768dda3a1
11 changed files with 1524 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
# 2026-05-01 — End-to-End Booking Flow
> Same-day successor to `2026-05-01-availability.md`. Closes Step 4 of `Initial.md` §9.
## Milestone
The full path "admin takes a phone booking on behalf of a customer" works end-to-end: query the DB, find slots, hold, confirm, send a confirmation email, record a notification row. Race conditions on the same slot are caught at the DB level and surfaced as a typed `BookingConflictError`. Practice timezone (`America/Detroit`) is wired through every layer that touches local clock time.
## What's verified
- `pnpm test`**64/64 green** across 6 test files
- `pnpm lint` — clean
- `pnpm exec tsc --noEmit` — clean
- `pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00` — books, emails, prints confirmation
- Mailpit at `http://localhost:8025` shows the rendered email with proper EDT timezone formatting
## What landed
| File | Role | Tests |
|---|---|---|
| `src/lib/availability-loader.ts` | DB → algorithm adapter; returns `{ service, therapists[], rooms[] }` for a window + service. Filters bookings to active statuses + window-overlap | 12 |
| `src/lib/booking.ts` | `createHold` / `confirmHold` / `expireStaleHolds`; `BookingConflictError` typed error; PG-code error classification (23P01, 40P01, 40001) | 8 |
| `src/lib/email.ts` | nodemailer SMTP transport (Mailpit dev / Resend prod); `sendEmail` low-level + `sendBookingConfirmation` high-level; cached transport with reset seam for tests | 3 |
| `scripts/book-on-behalf.ts` | Admin CLI smoke-test of the whole chain | (manual) |
| `test/e2e-booking.test.ts` | Integration test of the chain + concurrent-hold race | 2 |
Plus environment changes:
- `APP_TZ` set to `America/Detroit` in `.env`, `.env.test`, `.env.example`
- `nodemailer` + `@types/nodemailer` added
## Decisions made this session
| Decision | Resolution |
|---|---|
| Practice timezone | **`America/Detroit`** (US Eastern, observes DST) — confirmed by user |
| Hold duration default | 10 minutes (`DEFAULT_HOLD_MINUTES` in `booking.ts`) |
| Concurrent-hold conflict signaling | DB exclusion violation (23P01) AND deadlock (40P01) AND serialization failure (40001) all surface as `BookingConflictError`. Reason: from a caller's POV, all three mean "someone else got the slot first" |
| Loader filters | Bookings filtered to `status IN (HOLD, CONFIRMED)` + window-overlap on `endsAt`/`roomReleasedAt`. Cancelled/completed rows aren't loaded |
| Loader output shape | Matches `findSlots` input exactly — no glue at the call site |
| Algorithm re-validation at commit | None. `createHold` trusts the caller picked a slot from `findSlots`. Reason: DB has no concept of working hours; double-check at insert time would be a re-run of the algorithm, defeating the purpose. The exclusion constraints catch the only thing the DB *can* know about (overlap) |
| Email template format | Plain text (no React Email yet). Plain `Intl.DateTimeFormat` for local time rendering. Reason: one template doesn't justify a templating layer |
| Notification row lifecycle | Insert with `status='queued'` before send → update to `sent` (with `providerId`, `sentAt`) on success or `failed` on throw. Reason: durable record even when email itself fails |
| `BookingConfirmationArgs` shape | Pass `bookingId` only; the email function does its own DB query for the related rows. Reason: keeps the booking-creator's caller surface simple |
## Gotchas hit
### 1. GiST exclusion constraints + concurrent inserts → deadlock, not always 23P01
When two transactions try to insert overlapping rows simultaneously, Postgres can detect a **deadlock during the GiST index check** rather than report a clean exclusion violation. The first run of the e2e race test failed because we were only catching `23P01`. Fix: also classify `40P01` (deadlock_detected) and `40001` (serialization_failure) as conflicts.
### 2. Mailpit container had stopped between sessions
Postgres has `restart: unless-stopped`; Mailpit was created without that initially. Re-bringing the compose was sufficient but worth knowing — the `compose.yaml` already has `restart: unless-stopped` on Mailpit too, so this shouldn't recur.
### 3. SMTP host resolves IPv6 first on macOS
Initial test failure was `ECONNREFUSED ::1:8025` even though Mailpit was bound to `0.0.0.0:8025`. nodemailer falls back to IPv4 on its own; once Mailpit was up, it resolved cleanly. Worth noting if we ever see flaky email tests later.
## Coverage breakdown
```
test/availability.test.ts 28 tests pure algorithm
test/availability-loader.test.ts 12 tests DB loader + integration with findSlots
test/booking-exclusion.test.ts 10 tests exclusion constraints + FK rejection
test/booking.test.ts 8 tests createHold / confirmHold / expireStaleHolds
test/email.test.ts 3 tests SMTP delivery + Notification row
test/e2e-booking.test.ts 2 tests admin happy path + concurrent-hold race
----
63 tests
+ 1 in beforeAll glue? (vitest reports 64)
```
## Open questions still unresolved
1. Customer-visible brand name (TouchBase or other) — affects email subject/body
2. Currency — USD assumed throughout
3. Stripe account ownership — needed when billing wiring lands
## 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 (admin booking + email)~~**done 2026-05-01** (this session)
5. **next**: public self-booking → Stripe deposits → reminders
## Recommended next step
Public self-booking is the next big chunk. Reasonable break:
- **5a — Auth.js with email magic links** for customers (~35 days). Touch the existing User model lightly.
- **5b — Public booking page** that calls the same loader + createHold pipeline (~57 days).
- **5c — Stripe deposit flow + webhook handler** (~35 days).
- **5d — Email reminders** via pg-boss scheduled jobs (~23 days).
Recommended order: 5a → 5b → 5c → 5d. Each piece is independently shippable and reviewable.
Alternative: build a thin admin web UI first (a /admin page that wraps the existing CLI flow) to get the practice using TouchBase to take phone bookings, then layer public self-booking after. ~34 days. Lets the practice start using it sooner; defers the auth story until customers actually need to log in (admin auth is simpler — single-org single-admin or even basic auth to start).
## How to resume
```bash
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm install
pnpm db:bootstrap # if test DB or extensions missing
pnpm db:seed # reset + seed dev DB
pnpm test # 64/64
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
open http://localhost:8025 # Mailpit web UI
```

View File

@@ -27,6 +27,7 @@
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"next": "16.2.4",
"nodemailer": "^8.0.7",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
@@ -35,6 +36,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^22",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",

19
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
next:
specifier: 16.2.4
version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
nodemailer:
specifier: ^8.0.7
version: 8.0.7
pg:
specifier: ^8.20.0
version: 8.20.0
@@ -42,6 +45,9 @@ importers:
'@types/node':
specifier: ^22
version: 22.19.17
'@types/nodemailer':
specifier: ^8.0.0
version: 8.0.0
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
@@ -986,6 +992,9 @@ packages:
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/nodemailer@8.0.0':
resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==}
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
@@ -2209,6 +2218,10 @@ packages:
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
nodemailer@8.0.7:
resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==}
engines: {node: '>=6.0.0'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -3629,6 +3642,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/nodemailer@8.0.0':
dependencies:
'@types/node': 22.19.17
'@types/pg@8.20.0':
dependencies:
'@types/node': 22.19.17
@@ -5019,6 +5036,8 @@ snapshots:
node-releases@2.0.38: {}
nodemailer@8.0.7: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}

106
scripts/book-on-behalf.ts Normal file
View File

@@ -0,0 +1,106 @@
// Admin smoke-test: take a booking on behalf of a customer end-to-end.
//
// Usage:
// pnpm tsx scripts/book-on-behalf.ts <customerEmail> <serviceName> <localISO>
//
// Example:
// pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
//
// Walks: lookup → loadAvailabilityState → findSlots → pick first candidate
// pair → createHold → confirmHold → sendBookingConfirmation.
import "dotenv/config";
import { fromZonedTime } from "date-fns-tz";
import { addMinutes } from "date-fns";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client";
import { findSlots } from "../src/lib/availability";
import { loadAvailabilityState } from "../src/lib/availability-loader";
import { confirmHold, createHold } from "../src/lib/booking";
import { sendBookingConfirmation } from "../src/lib/email";
async function main() {
const [customerEmail, serviceName, localIso] = process.argv.slice(2);
if (!customerEmail || !serviceName || !localIso) {
console.error(
"usage: pnpm tsx scripts/book-on-behalf.ts <customerEmail> <serviceName> <localISO>",
);
process.exit(2);
}
const tz = process.env.APP_TZ ?? "America/Detroit";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const db = new PrismaClient({ adapter });
try {
const customer = await db.user.findUnique({
where: { email: customerEmail },
include: { customer: true },
});
if (!customer || !customer.customer) {
throw new Error(`No customer found for email: ${customerEmail}`);
}
const service = await db.service.findFirst({
where: { name: serviceName, active: true },
});
if (!service) throw new Error(`No active service named: ${serviceName}`);
const startsAt = fromZonedTime(localIso, tz);
const window = {
from: startsAt,
to: addMinutes(startsAt, service.durationMin + 1),
};
const state = await loadAvailabilityState(db, {
from: window.from,
to: window.to,
serviceId: service.id,
});
if (!state) throw new Error("Could not load availability state");
const slots = findSlots({
service: state.service,
therapists: state.therapists,
rooms: state.rooms,
practiceTz: tz,
from: window.from,
to: window.to,
});
const slot = slots.find((s) => s.startsAt.getTime() === startsAt.getTime());
if (!slot) {
console.error(`Requested slot ${localIso} (${startsAt.toISOString()}) is not available.`);
console.error(`Found ${slots.length} slot(s) in window:`);
for (const s of slots) console.error(` - ${s.startsAt.toISOString()}`);
process.exit(1);
}
const therapistId = slot.candidateTherapistIds[0];
const roomId = slot.candidateRoomIds[0];
console.log(`Booking ${service.name} for ${customer.name}:`);
console.log(` When: ${startsAt.toISOString()} (${localIso} ${tz})`);
console.log(` Therapist: ${therapistId}`);
console.log(` Room: ${roomId}`);
const hold = await createHold(db, {
customerId: customer.id,
serviceId: service.id,
therapistId,
roomId,
startsAt,
});
await confirmHold(db, hold.id);
const result = await sendBookingConfirmation({ db, bookingId: hold.id });
console.log(` Booking: ${hold.id} (CONFIRMED)`);
console.log(` Email: ${result.status} (notification ${result.notificationId})`);
console.log(` View: http://localhost:8025`);
} finally {
await db.$disconnect();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,140 @@
// DB → algorithm adapter. Keeps the pure availability module free of Prisma.
import type { PrismaClient } from "@/generated/prisma/client";
import type {
RoomState,
ServiceLite,
TherapistState,
} from "@/lib/availability";
export type AvailabilityState = {
service: ServiceLite;
therapists: TherapistState[];
rooms: RoomState[];
};
export type LoadAvailabilityArgs = {
from: Date;
to: Date;
serviceId: string;
};
/**
* Load the resource state needed to find slots for a given service in a window.
* Returns null if the service doesn't exist or is inactive.
*
* Filters bookings/overrides/blocks to only those that overlap the window —
* keeps payload small for long horizons (e.g. 30-day customer search).
*/
export async function loadAvailabilityState(
db: PrismaClient,
args: LoadAvailabilityArgs,
): Promise<AvailabilityState | null> {
const service = await db.service.findUnique({
where: { id: args.serviceId },
});
if (!service || !service.active) return null;
const therapistRows = await db.therapist.findMany({
where: {
active: true,
services: { some: { serviceId: args.serviceId } },
},
include: {
tags: { select: { tag: true } },
services: {
where: { serviceId: args.serviceId },
select: { serviceId: true },
},
workingHours: true,
overrides: {
where: {
endsAt: { gt: args.from },
startsAt: { lt: args.to },
},
select: { startsAt: true, endsAt: true, kind: true },
},
bookings: {
where: {
status: { in: ["HOLD", "CONFIRMED"] },
endsAt: { gt: args.from },
startsAt: { lt: args.to },
},
select: { startsAt: true, endsAt: true },
},
},
});
const therapists: TherapistState[] = therapistRows.map((t) => ({
id: t.userId,
active: t.active,
tags: new Set(t.tags.map((tt) => tt.tag)),
serviceIds: new Set(t.services.map((s) => s.serviceId)),
workingHours: t.workingHours.map((w) => ({
weekday: w.weekday,
startMin: w.startMin,
endMin: w.endMin,
effectiveFrom: w.effectiveFrom ?? null,
effectiveTo: w.effectiveTo ?? null,
})),
overrides: t.overrides.map((o) => ({
kind: o.kind,
startsAt: o.startsAt,
endsAt: o.endsAt,
})),
bookings: t.bookings.map((b) => ({
startsAt: b.startsAt,
endsAt: b.endsAt,
})),
}));
const roomRows = await db.room.findMany({
where: { active: true },
include: {
tags: { select: { tag: true } },
blocks: {
where: {
endsAt: { gt: args.from },
startsAt: { lt: args.to },
},
select: { startsAt: true, endsAt: true },
},
bookings: {
where: {
status: { in: ["HOLD", "CONFIRMED"] },
roomReleasedAt: { gt: args.from },
startsAt: { lt: args.to },
},
select: { startsAt: true, roomReleasedAt: true },
},
},
});
const rooms: RoomState[] = roomRows.map((r) => ({
id: r.id,
active: r.active,
tags: new Set(r.tags.map((rt) => rt.tag)),
blocks: r.blocks.map((b) => ({
startsAt: b.startsAt,
endsAt: b.endsAt,
})),
bookings: r.bookings.map((b) => ({
startsAt: b.startsAt,
// For room booking footprint, the "end" is when the room is released
// (= booking endsAt + service buffer).
endsAt: b.roomReleasedAt,
})),
}));
return {
service: {
id: service.id,
durationMin: service.durationMin,
bufferAfterMin: service.bufferAfterMin,
requiredTherapistTags: service.requiredTherapistTags,
requiredRoomTags: service.requiredRoomTags,
},
therapists,
rooms,
};
}

219
src/lib/booking.ts Normal file
View File

@@ -0,0 +1,219 @@
// Booking commit helpers. All multi-row work goes through here so the
// transactional and conflict-detection logic lives in one place.
import type { PrismaClient } from "@/generated/prisma/client";
const PG_EXCLUSION_VIOLATION = "23P01";
const PG_FOREIGN_KEY_VIOLATION = "23503";
const PG_DEADLOCK_DETECTED = "40P01";
const PG_SERIALIZATION_FAILURE = "40001";
const DEFAULT_HOLD_MINUTES = 10;
export class BookingConflictError extends Error {
readonly code = "BOOKING_CONFLICT";
readonly constraint?: string;
constructor(constraint?: string) {
super(
constraint === "Booking_no_room_overlap"
? "Room is no longer available for this slot"
: constraint === "Booking_no_therapist_overlap"
? "Therapist is no longer available for this slot"
: "Slot is no longer available",
);
this.constraint = constraint;
this.name = "BookingConflictError";
}
}
export type CreateHoldInput = {
customerId: string;
serviceId: string;
therapistId: string;
roomId: string;
startsAt: Date;
holdMinutes?: number; // default 10
};
export type Hold = {
id: string;
customerId: string;
serviceId: string;
therapistId: string;
roomId: string;
startsAt: Date;
endsAt: Date;
roomReleasedAt: Date;
holdExpiresAt: Date;
priceCents: number;
depositCents: number;
};
/**
* Insert a booking in HOLD status. The DB exclusion constraints are the
* authoritative double-booking guard — if a concurrent caller booked the same
* therapist/room slot, this throws BookingConflictError. The application's
* findSlots search is best-effort; this insert is the truth.
*
* Caller is responsible for validating the slot was previously offered by
* findSlots — this helper does NOT recheck working hours, tag eligibility,
* or the ServiceTherapist allowlist. (Rationale: the DB has no context for
* working-hours rules, so we either re-run the algorithm or trust the caller.
* For a single trusted server boundary, trusting the caller is fine.)
*/
export async function createHold(
db: PrismaClient,
input: CreateHoldInput,
): Promise<Hold> {
const service = await db.service.findUnique({
where: { id: input.serviceId },
select: {
id: true,
durationMin: true,
bufferAfterMin: true,
priceCents: true,
depositCents: true,
active: true,
},
});
if (!service) throw new Error(`Service not found: ${input.serviceId}`);
if (!service.active) throw new Error(`Service is inactive: ${input.serviceId}`);
const startsAt = input.startsAt;
const endsAt = new Date(startsAt.getTime() + service.durationMin * 60_000);
const roomReleasedAt = new Date(
endsAt.getTime() + service.bufferAfterMin * 60_000,
);
const holdMinutes = input.holdMinutes ?? DEFAULT_HOLD_MINUTES;
const holdExpiresAt = new Date(Date.now() + holdMinutes * 60_000);
try {
const row = await db.booking.create({
data: {
customerId: input.customerId,
therapistId: input.therapistId,
roomId: input.roomId,
serviceId: input.serviceId,
startsAt,
endsAt,
roomReleasedAt,
status: "HOLD",
holdExpiresAt,
priceCents: service.priceCents,
depositCents: service.depositCents,
},
});
return {
id: row.id,
customerId: row.customerId,
serviceId: row.serviceId,
therapistId: row.therapistId,
roomId: row.roomId,
startsAt: row.startsAt,
endsAt: row.endsAt,
roomReleasedAt: row.roomReleasedAt,
holdExpiresAt: row.holdExpiresAt!,
priceCents: row.priceCents,
depositCents: row.depositCents,
};
} catch (e) {
const constraint = extractConstraint(e);
if (constraint?.includes("_overlap")) {
throw new BookingConflictError(constraint);
}
// Concurrent inserts on the same slot can produce a deadlock or
// serialization failure during GiST exclusion check instead of a
// straight 23P01. From the user's perspective both mean "someone else
// got the slot" — surface as a conflict.
const code = extractPgCode(e);
if (code === PG_DEADLOCK_DETECTED || code === PG_SERIALIZATION_FAILURE) {
throw new BookingConflictError();
}
throw e;
}
}
/**
* Confirm a hold. Idempotent — confirming an already-CONFIRMED row is a no-op.
*/
export async function confirmHold(
db: PrismaClient,
bookingId: string,
): Promise<void> {
await db.booking.update({
where: { id: bookingId },
data: { status: "CONFIRMED", holdExpiresAt: null },
});
}
/**
* Sweep expired holds. Run periodically (pg-boss job, eventually). Returns count.
*/
export async function expireStaleHolds(db: PrismaClient): Promise<number> {
const now = new Date();
const result = await db.booking.updateMany({
where: {
status: "HOLD",
holdExpiresAt: { lt: now },
},
data: {
status: "CANCELLED",
cancelledAt: now,
cancelReason: "hold expired",
},
});
return result.count;
}
/**
* Walk the error chain looking for a Postgres constraint name. Prisma 7 wraps
* driver errors several layers deep — meta may live on the outer error, on a
* `cause`, or on the underlying DriverAdapterError. We also recognize the
* exclusion-violation message text as a fallback.
*/
function extractConstraint(e: unknown): string | undefined {
const seen = new Set<unknown>();
let cur: unknown = e;
while (cur && typeof cur === "object" && !seen.has(cur)) {
seen.add(cur);
const obj = cur as Record<string, unknown>;
const meta = obj.meta;
if (meta && typeof meta === "object") {
const c = (meta as { constraint?: unknown }).constraint;
if (typeof c === "string") return c;
}
if (typeof obj.message === "string") {
// pg-driver text e.g. `conflicting key value violates exclusion constraint "Booking_no_room_overlap"`
const m = obj.message.match(/exclusion constraint\s+"([^"]+)"/);
if (m) return m[1];
}
cur = obj.cause ?? undefined;
}
return undefined;
}
function extractPgCode(e: unknown): string | undefined {
const seen = new Set<unknown>();
let cur: unknown = e;
while (cur && typeof cur === "object" && !seen.has(cur)) {
seen.add(cur);
const obj = cur as Record<string, unknown>;
const code = obj.code;
if (typeof code === "string" && /^[0-9A-Z]{5}$/.test(code)) return code;
if (typeof obj.message === "string") {
// pg adapter sometimes leaves the code only in the message
const m = obj.message.match(/\b([0-9A-Z]{5})\b/);
if (m) return m[1];
}
cur = obj.cause ?? undefined;
}
return undefined;
}
// Exports kept for tests and callers that want to classify errors themselves.
export const PG_CODES = {
EXCLUSION_VIOLATION: PG_EXCLUSION_VIOLATION,
FOREIGN_KEY_VIOLATION: PG_FOREIGN_KEY_VIOLATION,
DEADLOCK_DETECTED: PG_DEADLOCK_DETECTED,
SERIALIZATION_FAILURE: PG_SERIALIZATION_FAILURE,
} as const;

161
src/lib/email.ts Normal file
View File

@@ -0,0 +1,161 @@
// SMTP transport + transactional email sender. Mailpit in dev, Resend in prod.
// Both speak SMTP, so swap is a config change.
import { createHash } from "node:crypto";
import nodemailer, { type Transporter } from "nodemailer";
import type { PrismaClient } from "@/generated/prisma/client";
let transporter: Transporter | null = null;
function getTransporter(): Transporter {
if (transporter) return transporter;
const port = Number(process.env.SMTP_PORT ?? 1025);
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST ?? "localhost",
port,
secure: port === 465,
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
});
return transporter;
}
/** Test seam — reset cached transport between tests if env changes. */
export function resetEmailTransport(): void {
transporter = null;
}
export type SendResult = {
notificationId: string;
providerId: string | null;
status: "sent" | "failed";
};
type SendEmailArgs = {
db: PrismaClient;
to: string;
subject: string;
text: string;
html?: string;
template: string;
userId?: string;
bookingId?: string;
};
export async function sendEmail(args: SendEmailArgs): Promise<SendResult> {
const from = process.env.SMTP_FROM ?? "TouchBase <noreply@touchbase.local>";
const bodyHash = createHash("sha256")
.update(args.subject)
.update("\n")
.update(args.text)
.digest("hex");
const noti = await args.db.notification.create({
data: {
userId: args.userId,
bookingId: args.bookingId,
channel: "email",
template: args.template,
to: args.to,
subject: args.subject,
bodyHash,
status: "queued",
},
});
try {
const info = await getTransporter().sendMail({
from,
to: args.to,
subject: args.subject,
text: args.text,
html: args.html,
});
await args.db.notification.update({
where: { id: noti.id },
data: {
status: "sent",
providerId: info.messageId ?? null,
sentAt: new Date(),
},
});
return {
notificationId: noti.id,
providerId: info.messageId ?? null,
status: "sent",
};
} catch (e) {
await args.db.notification.update({
where: { id: noti.id },
data: { status: "failed" },
});
throw e;
}
}
// ============================================================
// Booking confirmation
// ============================================================
export type BookingConfirmationArgs = {
db: PrismaClient;
bookingId: string;
};
export async function sendBookingConfirmation(
args: BookingConfirmationArgs,
): Promise<SendResult> {
const booking = await args.db.booking.findUnique({
where: { id: args.bookingId },
include: {
customer: true,
service: true,
therapist: { include: { user: true } },
room: 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 localEnd = formatLocal(booking.endsAt, tz);
const subject = `Your ${booking.service.name} is confirmed`;
const text = [
`Hi ${booking.customer.name},`,
"",
`Your appointment is confirmed.`,
"",
`Service: ${booking.service.name}`,
`When: ${localStart} ${localEnd}`,
`Therapist: ${booking.therapist.user.name}`,
`Room: ${booking.room.name}`,
"",
`If you need to reschedule or cancel, reply to this email.`,
"",
`— TouchBase`,
].join("\n");
return sendEmail({
db: args.db,
to: booking.customer.email,
subject,
text,
template: "booking_confirmation",
userId: booking.customerId,
bookingId: booking.id,
});
}
function formatLocal(d: Date, tz: string): string {
return new Intl.DateTimeFormat("en-US", {
timeZone: tz,
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}).format(d);
}

View File

@@ -0,0 +1,303 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { loadAvailabilityState } from "@/lib/availability-loader";
import { findSlots } from "@/lib/availability";
import { seed, type SeedResult } from "@/lib/seed";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const db = new PrismaClient({ adapter });
let fx: SeedResult;
let serviceId: string;
let prenatalServiceId: string;
let therapistMei: string;
let therapistDaniel: string;
let roomA: string;
beforeAll(async () => {
fx = await seed(db);
serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id;
prenatalServiceId = fx.services.find((s) => s.name === "75-minute Prenatal")!.id;
therapistMei = fx.therapists.find((t) => t.name === "Mei Tanaka")!.id;
therapistDaniel = fx.therapists.find((t) => t.name === "Daniel Costa")!.id;
roomA = fx.rooms[0].id;
});
afterAll(async () => {
await db.$disconnect();
});
beforeEach(async () => {
await db.booking.deleteMany();
await db.availabilityOverride.deleteMany();
await db.roomBlock.deleteMany();
});
const D = (iso: string) => new Date(iso);
const FROM = D("2026-05-05T00:00:00Z");
const TO = D("2026-05-12T00:00:00Z");
describe("loadAvailabilityState", () => {
test("returns null for unknown service", async () => {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId: "no-such-service",
});
expect(result).toBeNull();
});
test("returns null for inactive service", async () => {
await db.service.update({
where: { id: serviceId },
data: { active: false },
});
try {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
expect(result).toBeNull();
} finally {
await db.service.update({
where: { id: serviceId },
data: { active: true },
});
}
});
test("Swedish service: all 10 seeded therapists are eligible", async () => {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
expect(result).not.toBeNull();
expect(result!.therapists).toHaveLength(10);
});
test("Prenatal service: only the 3 prenatal-cert therapists are loaded", async () => {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId: prenatalServiceId,
});
expect(result).not.toBeNull();
// ServiceTherapist allowlist filters to qualified therapists only.
expect(result!.therapists).toHaveLength(3);
for (const t of result!.therapists) {
expect(t.tags.has("prenatal-cert")).toBe(true);
}
});
test("returns service in ServiceLite shape", async () => {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
expect(result!.service).toEqual({
id: serviceId,
durationMin: 60,
bufferAfterMin: 15,
requiredTherapistTags: ["swedish"],
requiredRoomTags: [],
});
});
test("loads all 10 active rooms with tag sets", async () => {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
expect(result!.rooms).toHaveLength(10);
const prenatalRooms = result!.rooms.filter((r) =>
r.tags.has("prenatal-table"),
);
expect(prenatalRooms.length).toBeGreaterThanOrEqual(1);
});
test("inactive room is excluded", async () => {
await db.room.update({
where: { id: roomA },
data: { active: false },
});
try {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
expect(result!.rooms.find((r) => r.id === roomA)).toBeUndefined();
} finally {
await db.room.update({
where: { id: roomA },
data: { active: true },
});
}
});
test("only loads bookings within the window for therapists", async () => {
// One booking inside the window, one outside (next month).
await db.booking.createMany({
data: [
{
customerId: fx.customers[0].id,
therapistId: therapistMei,
roomId: roomA,
serviceId,
startsAt: D("2026-05-06T14:00:00Z"),
endsAt: D("2026-05-06T15:00:00Z"),
roomReleasedAt: D("2026-05-06T15:15:00Z"),
status: "CONFIRMED",
},
{
customerId: fx.customers[0].id,
therapistId: therapistMei,
roomId: roomA,
serviceId,
startsAt: D("2026-06-15T14:00:00Z"),
endsAt: D("2026-06-15T15:00:00Z"),
roomReleasedAt: D("2026-06-15T15:15:00Z"),
status: "CONFIRMED",
},
],
});
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
const mei = result!.therapists.find((t) => t.id === therapistMei)!;
expect(mei.bookings).toHaveLength(1);
expect(mei.bookings[0].startsAt).toEqual(D("2026-05-06T14:00:00Z"));
});
test("CANCELLED and COMPLETED bookings are NOT loaded (only active)", async () => {
await db.booking.create({
data: {
customerId: fx.customers[0].id,
therapistId: therapistMei,
roomId: roomA,
serviceId,
startsAt: D("2026-05-06T14:00:00Z"),
endsAt: D("2026-05-06T15:00:00Z"),
roomReleasedAt: D("2026-05-06T15:15:00Z"),
status: "CANCELLED",
},
});
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
const mei = result!.therapists.find((t) => t.id === therapistMei)!;
expect(mei.bookings).toHaveLength(0);
});
test("BLOCK overrides are loaded for the window", async () => {
await db.availabilityOverride.create({
data: {
therapistId: therapistMei,
startsAt: D("2026-05-06T14:00:00Z"),
endsAt: D("2026-05-06T18:00:00Z"),
kind: "BLOCK",
reason: "doctor visit",
},
});
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
const mei = result!.therapists.find((t) => t.id === therapistMei)!;
expect(mei.overrides).toHaveLength(1);
expect(mei.overrides[0].kind).toBe("BLOCK");
});
test("RoomBlocks within the window are loaded", async () => {
await db.roomBlock.create({
data: {
roomId: roomA,
startsAt: D("2026-05-07T14:00:00Z"),
endsAt: D("2026-05-07T15:00:00Z"),
reason: "deep clean",
},
});
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
const room = result!.rooms.find((r) => r.id === roomA)!;
expect(room.blocks).toHaveLength(1);
});
test("loader output integrates with findSlots end-to-end", async () => {
// Tuesday May 5 in America/Detroit: working hours Tue-Sat 10:0019:00 local.
// Detroit is UTC-4 (EDT in May), so 10:00 local = 14:00 UTC.
const result = await loadAvailabilityState(db, {
from: D("2026-05-05T14:00:00Z"), // 10:00 local
to: D("2026-05-05T16:00:00Z"), // 12:00 local
serviceId,
});
expect(result).not.toBeNull();
const slots = findSlots({
service: result!.service,
therapists: result!.therapists,
rooms: result!.rooms,
practiceTz: "America/Detroit",
from: D("2026-05-05T14:00:00Z"),
to: D("2026-05-05T16:00:00Z"),
});
// 60-min service in a 2-hour window: 5 slots (10:00, 10:15, 10:30, 10:45, 11:00).
expect(slots).toHaveLength(5);
// Every slot should have all 10 therapists and all 10 rooms as candidates
// (no bookings exist in this window after beforeEach).
for (const slot of slots) {
expect(slot.candidateTherapistIds).toHaveLength(10);
expect(slot.candidateRoomIds).toHaveLength(10);
}
});
test("a CONFIRMED booking removes that therapist from candidate list end-to-end", async () => {
await db.booking.create({
data: {
customerId: fx.customers[0].id,
therapistId: therapistDaniel,
roomId: roomA,
serviceId,
startsAt: D("2026-05-05T14:00:00Z"), // 10:00 local
endsAt: D("2026-05-05T15:00:00Z"), // 11:00 local
roomReleasedAt: D("2026-05-05T15:15:00Z"),
status: "CONFIRMED",
},
});
const result = await loadAvailabilityState(db, {
from: D("2026-05-05T14:00:00Z"),
to: D("2026-05-05T16:00:00Z"),
serviceId,
});
const slots = findSlots({
service: result!.service,
therapists: result!.therapists,
rooms: result!.rooms,
practiceTz: "America/Detroit",
from: D("2026-05-05T14:00:00Z"),
to: D("2026-05-05T16:00:00Z"),
});
// 10:00 slot should not have Daniel as a candidate.
const tenAm = slots.find(
(s) => s.startsAt.toISOString() === "2026-05-05T14:00:00.000Z",
)!;
expect(tenAm.candidateTherapistIds).not.toContain(therapistDaniel);
expect(tenAm.candidateTherapistIds).toHaveLength(9);
});
});

208
test/booking.test.ts Normal file
View File

@@ -0,0 +1,208 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import {
BookingConflictError,
confirmHold,
createHold,
expireStaleHolds,
} from "@/lib/booking";
import { seed, type SeedResult } from "@/lib/seed";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const db = new PrismaClient({ adapter });
let fx: SeedResult;
let serviceId: string;
let therapistA: string;
let therapistB: string;
let roomA: string;
let roomB: string;
let customerId: string;
beforeAll(async () => {
fx = await seed(db);
serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id;
therapistA = fx.therapists[0].id;
therapistB = fx.therapists[1].id;
roomA = fx.rooms[0].id;
roomB = fx.rooms[1].id;
customerId = fx.customers[0].id;
});
afterAll(async () => {
await db.$disconnect();
});
beforeEach(async () => {
await db.booking.deleteMany();
});
const D = (iso: string) => new Date(iso);
describe("createHold", () => {
test("inserts a HOLD with computed endsAt and roomReleasedAt", async () => {
const startsAt = D("2026-05-05T14:00:00Z");
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt,
});
// 60-min Swedish, 15-min buffer
expect(hold.endsAt).toEqual(D("2026-05-05T15:00:00Z"));
expect(hold.roomReleasedAt).toEqual(D("2026-05-05T15:15:00Z"));
expect(hold.priceCents).toBe(9000);
expect(hold.depositCents).toBe(2000);
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("HOLD");
expect(row?.holdExpiresAt).toBeTruthy();
});
test("conflicting createHold (same therapist, overlapping) throws BookingConflictError", async () => {
await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await expect(
createHold(db, {
customerId,
serviceId,
therapistId: therapistA, // same therapist
roomId: roomB, // different room
startsAt: D("2026-05-05T14:30:00Z"),
}),
).rejects.toBeInstanceOf(BookingConflictError);
});
test("conflicting createHold (same room within buffer) throws BookingConflictError", async () => {
await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
// Next slot after 11:00 is 11:15 because of the buffer.
await expect(
createHold(db, {
customerId,
serviceId,
therapistId: therapistB,
roomId: roomA, // same room
startsAt: D("2026-05-05T15:00:00Z"), // exactly at end — within buffer
}),
).rejects.toBeInstanceOf(BookingConflictError);
});
test("non-overlapping createHold succeeds after the room buffer", async () => {
await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
const hold2 = await createHold(db, {
customerId,
serviceId,
therapistId: therapistB,
roomId: roomA,
startsAt: D("2026-05-05T15:15:00Z"), // exactly when buffer ends
});
expect(hold2.id).toBeTruthy();
});
test("inactive service is rejected", async () => {
await db.service.update({
where: { id: serviceId },
data: { active: false },
});
try {
await expect(
createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
}),
).rejects.toThrow(/inactive/);
} finally {
await db.service.update({
where: { id: serviceId },
data: { active: true },
});
}
});
test("custom holdMinutes is honored", async () => {
const before = Date.now();
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
holdMinutes: 30,
});
const expected = before + 30 * 60_000;
// Allow 5 seconds of drift for the test execution time.
expect(hold.holdExpiresAt.getTime()).toBeGreaterThanOrEqual(expected - 5000);
expect(hold.holdExpiresAt.getTime()).toBeLessThanOrEqual(expected + 5000);
});
});
describe("confirmHold", () => {
test("flips a HOLD to CONFIRMED and clears holdExpiresAt", async () => {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, hold.id);
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("CONFIRMED");
expect(row?.holdExpiresAt).toBeNull();
});
});
describe("expireStaleHolds", () => {
test("expires only HOLDs whose holdExpiresAt is past", async () => {
// Create one fresh hold (10 min default) and manually backdate another.
const fresh = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
const stale = await createHold(db, {
customerId,
serviceId,
therapistId: therapistB,
roomId: roomB,
startsAt: D("2026-05-05T16:00:00Z"),
});
await db.booking.update({
where: { id: stale.id },
data: { holdExpiresAt: new Date(Date.now() - 60_000) },
});
const count = await expireStaleHolds(db);
expect(count).toBe(1);
const freshRow = await db.booking.findUnique({ where: { id: fresh.id } });
const staleRow = await db.booking.findUnique({ where: { id: stale.id } });
expect(freshRow?.status).toBe("HOLD");
expect(staleRow?.status).toBe("CANCELLED");
expect(staleRow?.cancelReason).toBe("hold expired");
});
});

154
test/e2e-booking.test.ts Normal file
View File

@@ -0,0 +1,154 @@
// End-to-end: simulate "admin takes a phone booking on behalf of a customer".
// Exercises load → find → hold → confirm → email in one flow, then validates
// the DB-level safety net catches a concurrent attempt at the same slot.
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { findSlots } from "@/lib/availability";
import { loadAvailabilityState } from "@/lib/availability-loader";
import {
BookingConflictError,
confirmHold,
createHold,
} from "@/lib/booking";
import { resetEmailTransport, sendBookingConfirmation } from "@/lib/email";
import { seed, type SeedResult } from "@/lib/seed";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const db = new PrismaClient({ adapter });
const MAILPIT = "http://localhost:8025";
const TZ = "America/Detroit";
let fx: SeedResult;
let serviceId: string;
let customer: { id: string; email: string };
beforeAll(async () => {
fx = await seed(db);
serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id;
customer = fx.customers[0];
process.env.SMTP_HOST = "localhost";
process.env.SMTP_PORT = "1025";
process.env.SMTP_FROM = "TouchBase <noreply@touchbase.local>";
process.env.APP_TZ = TZ;
resetEmailTransport();
});
afterAll(async () => {
await db.$disconnect();
});
beforeEach(async () => {
await db.notification.deleteMany();
await db.booking.deleteMany();
await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
});
// 2026-05-05 10:00 America/Detroit (EDT) = 14:00 UTC
const SLOT_LOCAL_10AM_TUE = new Date("2026-05-05T14:00:00Z");
describe("end-to-end: admin takes a booking", () => {
test("happy path: load → find → hold → confirm → email → notification recorded", async () => {
// Window: 2 hours starting at 10:00 local Tue.
const from = SLOT_LOCAL_10AM_TUE;
const to = new Date("2026-05-05T16:00:00Z"); // 12:00 local
const state = await loadAvailabilityState(db, { from, to, serviceId });
expect(state).not.toBeNull();
const slots = findSlots({
service: state!.service,
therapists: state!.therapists,
rooms: state!.rooms,
practiceTz: TZ,
from,
to,
});
expect(slots.length).toBeGreaterThan(0);
const slot = slots.find((s) => s.startsAt.getTime() === from.getTime())!;
expect(slot).toBeDefined();
expect(slot.candidateTherapistIds.length).toBeGreaterThan(0);
expect(slot.candidateRoomIds.length).toBeGreaterThan(0);
const hold = await createHold(db, {
customerId: customer.id,
serviceId,
therapistId: slot.candidateTherapistIds[0],
roomId: slot.candidateRoomIds[0],
startsAt: slot.startsAt,
});
await confirmHold(db, hold.id);
const result = await sendBookingConfirmation({ db, bookingId: hold.id });
expect(result.status).toBe("sent");
// Booking is CONFIRMED in DB
const dbRow = await db.booking.findUnique({ where: { id: hold.id } });
expect(dbRow?.status).toBe("CONFIRMED");
expect(dbRow?.holdExpiresAt).toBeNull();
// Notification row recorded with status=sent
const noti = await db.notification.findUnique({
where: { id: result.notificationId },
});
expect(noti?.status).toBe("sent");
// Mailpit received exactly one message addressed to the customer
const res = await fetch(`${MAILPIT}/api/v1/messages`);
const json = (await res.json()) as { messages: { To: { Address: string }[] }[] };
expect(json.messages).toHaveLength(1);
expect(json.messages[0].To[0].Address).toBe(customer.email);
});
test("a second concurrent createHold for the same slot is rejected by the DB", async () => {
const from = SLOT_LOCAL_10AM_TUE;
const to = new Date("2026-05-05T16:00:00Z");
const state = await loadAvailabilityState(db, { from, to, serviceId });
const slots = findSlots({
service: state!.service,
therapists: state!.therapists,
rooms: state!.rooms,
practiceTz: TZ,
from,
to,
});
const slot = slots[0];
// Race: two callers see the slot as available and both try to create a HOLD.
const therapist = slot.candidateTherapistIds[0];
const room = slot.candidateRoomIds[0];
const [firstResult, secondResult] = await Promise.allSettled([
createHold(db, {
customerId: customer.id,
serviceId,
therapistId: therapist,
roomId: room,
startsAt: slot.startsAt,
}),
createHold(db, {
customerId: fx.customers[1].id,
serviceId,
therapistId: therapist,
roomId: room,
startsAt: slot.startsAt,
}),
]);
// Exactly one wins; the other is rejected as a conflict.
const fulfilled = [firstResult, secondResult].filter(
(r) => r.status === "fulfilled",
);
const rejected = [firstResult, secondResult].filter(
(r) => r.status === "rejected",
);
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(
BookingConflictError,
);
});
});

102
test/email.test.ts Normal file
View File

@@ -0,0 +1,102 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { createHold } from "@/lib/booking";
import { resetEmailTransport, sendBookingConfirmation } from "@/lib/email";
import { seed, type SeedResult } from "@/lib/seed";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const db = new PrismaClient({ adapter });
const MAILPIT = "http://localhost:8025";
let fx: SeedResult;
let serviceId: string;
let therapistA: string;
let roomA: string;
let customer: { id: string; email: string };
beforeAll(async () => {
fx = await seed(db);
serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id;
therapistA = fx.therapists[0].id;
roomA = fx.rooms[0].id;
customer = fx.customers[0];
// Point email transport at Mailpit even though .env.test doesn't define it.
process.env.SMTP_HOST = "localhost";
process.env.SMTP_PORT = "1025";
process.env.SMTP_FROM = "TouchBase <noreply@touchbase.local>";
resetEmailTransport();
});
afterAll(async () => {
await db.$disconnect();
});
beforeEach(async () => {
await db.notification.deleteMany();
await db.booking.deleteMany();
// Clear Mailpit between tests to keep assertions clean.
await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
});
const D = (iso: string) => new Date(iso);
describe("sendBookingConfirmation", () => {
test("delivers an email to Mailpit and writes a sent Notification row", async () => {
const hold = await createHold(db, {
customerId: customer.id,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
const result = await sendBookingConfirmation({ db, bookingId: hold.id });
expect(result.status).toBe("sent");
// Notification row recorded
const noti = await db.notification.findUnique({
where: { id: result.notificationId },
});
expect(noti?.status).toBe("sent");
expect(noti?.template).toBe("booking_confirmation");
expect(noti?.bookingId).toBe(hold.id);
expect(noti?.userId).toBe(customer.id);
expect(noti?.to).toBe(customer.email);
expect(noti?.sentAt).toBeTruthy();
// Mailpit received the message
const res = await fetch(`${MAILPIT}/api/v1/messages`);
const json = (await res.json()) as { messages: { To: { Address: string }[]; Subject: string }[] };
expect(json.messages).toHaveLength(1);
expect(json.messages[0].To[0].Address).toBe(customer.email);
expect(json.messages[0].Subject).toContain("60-minute Swedish");
});
test("formats local time in the practice timezone (America/Detroit)", async () => {
process.env.APP_TZ = "America/Detroit";
const hold = await createHold(db, {
customerId: customer.id,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"), // 10:00 EDT
});
await sendBookingConfirmation({ db, bookingId: hold.id });
const res = await fetch(`${MAILPIT}/api/v1/messages`);
const list = (await res.json()) as { messages: { ID: string }[] };
const msgRes = await fetch(`${MAILPIT}/api/v1/message/${list.messages[0].ID}`);
const msg = (await msgRes.json()) as { Text: string };
// 14:00 UTC → 10:00 EDT
expect(msg.Text).toContain("10:00");
expect(msg.Text).toContain("EDT");
});
test("missing booking id throws", async () => {
await expect(
sendBookingConfirmation({ db, bookingId: "no-such-booking" }),
).rejects.toThrow(/not found/);
});
});