booking flow, loader, email, admin cli
This commit is contained in:
110
docs/progress/2026-05-01-end-to-end.md
Normal file
110
docs/progress/2026-05-01-end-to-end.md
Normal 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 (~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
|
||||||
|
```
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.2.4
|
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)
|
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:
|
pg:
|
||||||
specifier: ^8.20.0
|
specifier: ^8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
@@ -42,6 +45,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22
|
specifier: ^22
|
||||||
version: 22.19.17
|
version: 22.19.17
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^8.0.0
|
||||||
|
version: 8.0.0
|
||||||
'@types/pg':
|
'@types/pg':
|
||||||
specifier: ^8.20.0
|
specifier: ^8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
@@ -986,6 +992,9 @@ packages:
|
|||||||
'@types/node@22.19.17':
|
'@types/node@22.19.17':
|
||||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
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':
|
'@types/pg@8.20.0':
|
||||||
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
||||||
|
|
||||||
@@ -2209,6 +2218,10 @@ packages:
|
|||||||
node-releases@2.0.38:
|
node-releases@2.0.38:
|
||||||
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
|
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:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3629,6 +3642,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.17
|
||||||
|
|
||||||
'@types/pg@8.20.0':
|
'@types/pg@8.20.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 22.19.17
|
||||||
@@ -5019,6 +5036,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.38: {}
|
node-releases@2.0.38: {}
|
||||||
|
|
||||||
|
nodemailer@8.0.7: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
|
|||||||
106
scripts/book-on-behalf.ts
Normal file
106
scripts/book-on-behalf.ts
Normal 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);
|
||||||
|
});
|
||||||
140
src/lib/availability-loader.ts
Normal file
140
src/lib/availability-loader.ts
Normal 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
219
src/lib/booking.ts
Normal 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
161
src/lib/email.ts
Normal 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);
|
||||||
|
}
|
||||||
303
test/availability-loader.test.ts
Normal file
303
test/availability-loader.test.ts
Normal 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: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);
|
||||||
|
});
|
||||||
|
});
|
||||||
208
test/booking.test.ts
Normal file
208
test/booking.test.ts
Normal 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
154
test/e2e-booking.test.ts
Normal 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
102
test/email.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user