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-tz": "^3.2.0",
|
||||
"next": "16.2.4",
|
||||
"nodemailer": "^8.0.7",
|
||||
"pg": "^8.20.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -35,6 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^22",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
||||
next:
|
||||
specifier: 16.2.4
|
||||
version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
nodemailer:
|
||||
specifier: ^8.0.7
|
||||
version: 8.0.7
|
||||
pg:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
@@ -42,6 +45,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22
|
||||
version: 22.19.17
|
||||
'@types/nodemailer':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
'@types/pg':
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
@@ -986,6 +992,9 @@ packages:
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
'@types/nodemailer@8.0.0':
|
||||
resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==}
|
||||
|
||||
'@types/pg@8.20.0':
|
||||
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
||||
|
||||
@@ -2209,6 +2218,10 @@ packages:
|
||||
node-releases@2.0.38:
|
||||
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
|
||||
|
||||
nodemailer@8.0.7:
|
||||
resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3629,6 +3642,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/nodemailer@8.0.0':
|
||||
dependencies:
|
||||
'@types/node': 22.19.17
|
||||
|
||||
'@types/pg@8.20.0':
|
||||
dependencies:
|
||||
'@types/node': 22.19.17
|
||||
@@ -5019,6 +5036,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.38: {}
|
||||
|
||||
nodemailer@8.0.7: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
106
scripts/book-on-behalf.ts
Normal file
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