From c768dda3a182060701e2b63c912616060505b447 Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Fri, 1 May 2026 18:59:19 -0400 Subject: [PATCH] booking flow, loader, email, admin cli --- docs/progress/2026-05-01-end-to-end.md | 110 +++++++++ package.json | 2 + pnpm-lock.yaml | 19 ++ scripts/book-on-behalf.ts | 106 +++++++++ src/lib/availability-loader.ts | 140 ++++++++++++ src/lib/booking.ts | 219 ++++++++++++++++++ src/lib/email.ts | 161 +++++++++++++ test/availability-loader.test.ts | 303 +++++++++++++++++++++++++ test/booking.test.ts | 208 +++++++++++++++++ test/e2e-booking.test.ts | 154 +++++++++++++ test/email.test.ts | 102 +++++++++ 11 files changed, 1524 insertions(+) create mode 100644 docs/progress/2026-05-01-end-to-end.md create mode 100644 scripts/book-on-behalf.ts create mode 100644 src/lib/availability-loader.ts create mode 100644 src/lib/booking.ts create mode 100644 src/lib/email.ts create mode 100644 test/availability-loader.test.ts create mode 100644 test/booking.test.ts create mode 100644 test/e2e-booking.test.ts create mode 100644 test/email.test.ts diff --git a/docs/progress/2026-05-01-end-to-end.md b/docs/progress/2026-05-01-end-to-end.md new file mode 100644 index 0000000..a2ccec2 --- /dev/null +++ b/docs/progress/2026-05-01-end-to-end.md @@ -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 (~3–5 days). Touch the existing User model lightly. +- **5b — Public booking page** that calls the same loader + createHold pipeline (~5–7 days). +- **5c — Stripe deposit flow + webhook handler** (~3–5 days). +- **5d — Email reminders** via pg-boss scheduled jobs (~2–3 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. ~3–4 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 +``` diff --git a/package.json b/package.json index 07c686d..7a68d7d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaa4431..76362c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/book-on-behalf.ts b/scripts/book-on-behalf.ts new file mode 100644 index 0000000..263446e --- /dev/null +++ b/scripts/book-on-behalf.ts @@ -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 +// +// 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 ", + ); + 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); +}); diff --git a/src/lib/availability-loader.ts b/src/lib/availability-loader.ts new file mode 100644 index 0000000..4ceeab7 --- /dev/null +++ b/src/lib/availability-loader.ts @@ -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 { + 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, + }; +} diff --git a/src/lib/booking.ts b/src/lib/booking.ts new file mode 100644 index 0000000..9a336cf --- /dev/null +++ b/src/lib/booking.ts @@ -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 { + 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 { + 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 { + 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(); + let cur: unknown = e; + while (cur && typeof cur === "object" && !seen.has(cur)) { + seen.add(cur); + const obj = cur as Record; + 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(); + let cur: unknown = e; + while (cur && typeof cur === "object" && !seen.has(cur)) { + seen.add(cur); + const obj = cur as Record; + 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; diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..a436807 --- /dev/null +++ b/src/lib/email.ts @@ -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 { + const from = process.env.SMTP_FROM ?? "TouchBase "; + 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 { + 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); +} diff --git a/test/availability-loader.test.ts b/test/availability-loader.test.ts new file mode 100644 index 0000000..dd7ac77 --- /dev/null +++ b/test/availability-loader.test.ts @@ -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:00–19: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); + }); +}); diff --git a/test/booking.test.ts b/test/booking.test.ts new file mode 100644 index 0000000..203311a --- /dev/null +++ b/test/booking.test.ts @@ -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"); + }); +}); diff --git a/test/e2e-booking.test.ts b/test/e2e-booking.test.ts new file mode 100644 index 0000000..ebde13f --- /dev/null +++ b/test/e2e-booking.test.ts @@ -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 "; + 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, + ); + }); +}); diff --git a/test/email.test.ts b/test/email.test.ts new file mode 100644 index 0000000..367fa8f --- /dev/null +++ b/test/email.test.ts @@ -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 "; + 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/); + }); +});