added availability

This commit is contained in:
2026-05-01 18:24:09 -04:00
parent ed7cae1acd
commit 036512f590
22 changed files with 8313 additions and 4 deletions

23
caddy/Caddyfile Normal file
View File

@@ -0,0 +1,23 @@
{
# email replaced via env at deploy time
email {$ACME_EMAIL:admin@example.com}
}
{$APP_DOMAIN:localhost} {
encode zstd gzip
@stripe path /api/stripe/webhook
handle @stripe {
# Stripe needs raw body — Next.js handler reads raw bytes.
reverse_proxy app:3000
}
handle {
reverse_proxy app:3000
}
log {
output stderr
format console
}
}

67
compose.yaml Normal file
View File

@@ -0,0 +1,67 @@
name: touchbase
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: touchbase
POSTGRES_PASSWORD: touchbase
POSTGRES_DB: touchbase_dev
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U touchbase -d touchbase_dev"]
interval: 5s
timeout: 5s
retries: 10
mailpit:
image: axllent/mailpit:latest
restart: unless-stopped
ports:
- "1025:1025" # SMTP
- "8025:8025" # web UI
environment:
MP_MAX_MESSAGES: "1000"
MP_SMTP_AUTH_ACCEPT_ANY: "1"
MP_SMTP_AUTH_ALLOW_INSECURE: "1"
app:
image: touchbase/app:dev
profiles: [prod]
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgresql://touchbase:touchbase@postgres:5432/touchbase_dev?schema=public
APP_URL: ${APP_URL:-http://localhost:3000}
expose:
- "3000"
caddy:
image: caddy:2-alpine
profiles: [prod]
restart: unless-stopped
depends_on:
- app
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
pgdata:
caddy_data:
caddy_config:

View File

@@ -0,0 +1,11 @@
-- Runs once on first container start (POSTGRES_DB = touchbase_dev already exists).
CREATE DATABASE touchbase_test OWNER touchbase;
\connect touchbase_dev
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
\connect touchbase_test
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

@@ -0,0 +1,168 @@
# 2026-04-30 — Bootstrap & Exclusion-Constraint Spike
> Snapshot at `2026-04-30T17:12Z`. Companion to `Initial.md` (planning) at `/Users/noise/Documents/obsidian/Massage/Initial.md`.
## Milestone
The five bootstrap tasks from the kickoff plan are complete. The repository is initialized, Postgres is running under docker-compose, the schema-managed migration includes the two `EXCLUDE USING gist` constraints that prevent therapist and room double-booking at the database level, and a Vitest suite of seven tests proves the constraints behave as specified.
This is the foundation everything else builds on per `Initial.md` §9 step 1.
## What's verified
- `pnpm install` — clean
- `pnpm lint` — clean (0 warnings, 0 errors)
- `pnpm exec tsc --noEmit` — clean
- `pnpm test`**7/7 tests green**
- `docker-compose up -d postgres mailpit` — healthy on first or second try (see "Gotchas")
- `pnpm exec prisma migrate dev` — applies cleanly; constraints visible via `\d "Booking"`
## Repo layout
```
touchbase/
├── caddy/Caddyfile # prod reverse proxy (profile: prod)
├── compose.yaml # postgres + mailpit always; app + caddy under prod profile
├── db/init/01-extensions-and-test-db.sql # creates touchbase_test, installs btree_gist + pgcrypto
├── docs/progress/ # this dir — milestone snapshots
├── prisma/
│ ├── migrations/20260430170142_booking_spike/migration.sql
│ ├── schema.prisma # Booking model only; full schema lands later
│ └── ...
├── scripts/db-bootstrap.sh # idempotent fallback for the bind-mount race
├── src/
│ ├── app/ # Next.js App Router (untouched starter for now)
│ ├── generated/prisma/ # generated, gitignored
│ └── lib/db.ts # singleton PrismaClient with PrismaPg adapter
├── test/
│ ├── booking-exclusion.test.ts # 7 tests
│ └── setup.ts # loads .env.test and refuses non-test DBs
├── .env, .env.test, .env.example
├── compose.yaml, Caddyfile (above)
├── package.json, pnpm-workspace.yaml, .nvmrc
├── prisma.config.ts, vitest.config.ts
└── eslint.config.mjs, tsconfig.json, next.config.ts (defaults)
```
## Decisions ratified this session
| Decision | Resolution |
|---|---|
| Project name | **TouchBase** |
| Code path | `/Users/noise/Documents/code/touchbase/` (outside Obsidian vault) |
| Email transport | SMTP via `nodemailer`. Dev: Mailpit (`localhost:1025`). Prod: Resend |
| Email provider chosen between Resend and Postmark | **Resend** (assistant's call, not user-overridden) |
| Node engines | `>=22`; `.nvmrc` pinned to `22` |
| Package manager | pnpm 10 |
| Currency | Assumed USD (`priceCents`) — pending user confirmation |
| `APP_TZ` placeholder | `America/Los_Angeles` — pending user confirmation |
## Stack snapshot (resolved versions)
- Next.js 16.2.4 (App Router) + React 19.2.4 + Tailwind 4.2.4
- Prisma 7.8.0 with the new `prisma-client` generator (TS-native client at `src/generated/prisma/`)
- `@prisma/adapter-pg` 7.8.0 + `pg` 8.20.0 — required by Prisma 7 (no built-in connection)
- Vitest 4.1.5 + dotenv-cli 9.0.0 + zod 4.3.6
- Postgres 16-alpine with `btree_gist` and `pgcrypto` extensions
- Mailpit (latest) for dev SMTP
- Caddy 2-alpine for prod TLS/proxy
## Database state
Two databases on the single Postgres container:
- `touchbase_dev` — used by `pnpm dev` and `prisma migrate dev`
- `touchbase_test` — used by `pnpm test`; reset between test files
Both have `btree_gist` and `pgcrypto` extensions installed. Migrations are applied to both via separate flows (`prisma migrate dev` for dev, `dotenv-cli -e .env.test -- prisma migrate deploy` for test).
Migration `20260430170142_booking_spike` creates:
- `BookingStatus` enum: `HOLD | CONFIRMED | COMPLETED | NO_SHOW | CANCELLED`
- `Booking` table (no FKs yet — opaque text IDs for `customerId`, `therapistId`, `roomId`, `serviceId`)
- 4 btree indexes for query patterns we expect
- **`Booking_no_therapist_overlap`** — `EXCLUDE USING gist` on `(therapistId, tstzrange(startsAt, endsAt))`, scoped to active statuses
- **`Booking_no_room_overlap`** — same shape but on `(roomId, tstzrange(startsAt, roomReleasedAt))` so the post-service buffer is enforced
`roomReleasedAt = endsAt + service.bufferAfterMin`. Stored on the row so the constraint is single-table without a join.
## Test cases (all passing)
In `test/booking-exclusion.test.ts`:
1. Non-overlapping bookings on the same therapist succeed
2. Overlapping bookings on the same therapist are rejected by the DB (raw SQLSTATE 23P01)
3. Two therapists, same time slot, different rooms — both succeed
4. Room buffer is enforced — second booking inside the buffer window is rejected
5. A booking starting exactly when the room is released is allowed (range is `[)`)
6. CANCELLED bookings do not block new bookings on the same slot
7. HOLD blocks CONFIRMED in the same slot — race-condition safety net
## Gotchas hit and how they were resolved
### 1. Docker Desktop bind-mount race on first Postgres start
The `/docker-entrypoint-initdb.d/*` glob expanded to the literal pattern (zero matches) the first time the container started, so init scripts were skipped — `touchbase_test` wasn't created and extensions weren't installed.
**Fix:** `docker-compose down -v` and re-up resolved it. As a permanent safety net, `scripts/db-bootstrap.sh` is idempotent and re-applies the same setup. Wired up as `pnpm db:bootstrap`. The README of the spike directory (this doc, until we have a real README) is the place this lives.
### 2. Prisma 7 dropped `datasourceUrl` and `datasources` constructor options
`new PrismaClient({ datasourceUrl: ... })` — the pattern that worked through Prisma 6 — now throws `PrismaClientConstructorValidationError`. Prisma 7 requires a driver adapter.
**Fix:** added `@prisma/adapter-pg` and `pg`, and `PrismaClient` is now constructed as:
```ts
new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) })
```
Encapsulated in `src/lib/db.ts`. Worth noting that all training-data examples for Prisma will be wrong on this point — assume Prisma 7 docs are authoritative going forward.
### 3. Prisma 7 error shape doesn't surface SQLSTATE at top level
`PrismaClientKnownRequestError` for an exclusion violation didn't expose `error.code === '23P01'` in any obvious place. The constraint name *is* present in `error.meta.constraint`.
**Fix:** `findPgCode` in `test/booking-exclusion.test.ts:47` walks the error and falls back to constraint-name presence. Loose — a foreign-key or unique violation would also be classified as 23P01 by this fallback. Acceptable for the spike; tighten before this helper is used outside tests.
### 4. Vitest 4 renamed `fileParallel` → `fileParallelism`
Quiet runtime-OK but caught by `tsc --noEmit`.
**Fix:** updated `vitest.config.ts`.
### 5. Docker Compose plugin not installed
The `docker compose` (space) form failed; `docker-compose` (hyphenated, v5.1.2 from Homebrew) works. All scripts and docs use the hyphenated form.
## Open questions for the user
These are non-blocking but matter before the public booking page lands:
1. **Practice timezone.** `APP_TZ` is a placeholder. The system clock observed is EDT during this session — but that may just be the developer location. What's the practice's local timezone?
2. **Public-facing name.** Is "TouchBase" the brand customers will see, or just the codebase name? Affects email templates, page titles, etc.
3. **Currency.** USD assumed. Confirm.
4. **Stripe account ownership.** Personal vs. LLC vs. existing entity — flagged for when billing wiring lands (~week 45).
## Next step (recommended)
Build out the **full schema** per `Initial.md` §4 in one migration: User + Customer + Therapist + Room + Service + WorkingHours + AvailabilityOverride + tagging tables + RoomBlock + Notification + AuditLog + the FK retrofit for Booking. Then a seed script with 10 therapists, 10 rooms, ~5 services, ~10 tags. Then start on the **availability algorithm** as a pure-function module with a fixture-driven test suite — the load-bearing piece per `Initial.md` §5.
Auth.js and the customer-facing UI come after the algorithm is provably correct against fixtures.
## How to resume in a new session
```bash
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
# If touchbase_test is missing or extensions not installed:
pnpm db:bootstrap
pnpm install
pnpm test # should be 7/7 green
pnpm dev # starts Next.js on http://localhost:3000 (still the create-next-app starter)
```
Mailpit web UI: http://localhost:8025
## Files of interest for cross-reference
- Planning: `/Users/noise/Documents/obsidian/Massage/Initial.md`
- Schema: `prisma/schema.prisma`
- Constraint SQL: `prisma/migrations/20260430170142_booking_spike/migration.sql`
- Tests: `test/booking-exclusion.test.ts`
- DB singleton: `src/lib/db.ts`
- Compose: `compose.yaml`
- Bootstrap script: `scripts/db-bootstrap.sh`

View File

@@ -0,0 +1,173 @@
# 2026-05-01 — Full Schema + Seed
> Companion to `Initial.md` (planning) at `/Users/noise/Documents/obsidian/Massage/Initial.md`. Predecessor: `2026-04-30-bootstrap.md`.
## Milestone
The full data model from `Initial.md` §4 is in the database, the existing exclusion-constraint tests have been refactored to use real foreign-key-respecting fixtures from a new seed function, and three new tests prove the FK constraints reject orphan inserts. Seeded with realistic fixture data: 10 therapists with mixed qualifications, 10 rooms with mixed capabilities, 5 services covering the full tag-matching surface area.
This closes Step 2 of `Initial.md` §9 ("Schema + migrations + seed script"). Up next: the availability algorithm.
## What's verified
- `pnpm install` — clean
- `pnpm lint` — clean
- `pnpm exec tsc --noEmit` — clean
- `pnpm test`**10/10 green** (7 exclusion + 3 FK)
- `pnpm db:seed` — seeds 1 admin + 10 therapists + 10 rooms + 5 services + 3 customers; idempotent via `TRUNCATE … CASCADE`
- Schema visible via `\dt` on `touchbase_dev`: 15 tables (admin tables) + `_prisma_migrations`
- Both exclusion constraints from the spike survived the schema migration alongside the new FK constraints — verified via `\d "Booking"`
## What's in the database (15 tables)
| Table | Notes |
|---|---|
| `User` | base identity; `role` enum drives which side relation populates |
| `Customer` | extends User; `notes` field flagged for column-encryption before prod |
| `Therapist` | extends User; relations to tags, working hours, overrides, services |
| `TherapistTag` | composite PK; free-form tag strings |
| `Room`, `RoomTag`, `RoomBlock` | symmetric to therapist side |
| `Service` | `requiredTherapistTags` and `requiredRoomTags` as Postgres `text[]` |
| `ServiceTherapist` | explicit allowlist of who-performs-what |
| `WorkingHours` | minutes-from-midnight in **practice-local TZ**; effective-from/to for schedule changes |
| `AvailabilityOverride` | BLOCK or EXTRA_HOURS variants |
| `Booking` | retrofitted with FKs to User, Therapist, Room, Service; exclusion constraints intact |
| `Payment` | linked to Booking via cascading FK |
| `Notification` | snapshot semantics — no FK to User intentionally |
| `AuditLog` | `actorId` is nullable for system actions |
## Migration history
```
prisma/migrations/
├── 20260430170142_booking_spike/ ← Booking + 2 exclusion constraints
└── 20260430172029_full_schema/ ← all other tables + FK retrofit on Booking
```
Both migrations replay cleanly on a reset (verified during the test-DB reset).
## Test coverage now (10 cases)
In `test/booking-exclusion.test.ts`:
**Exclusion constraints (7)**
1. Non-overlapping bookings on the same therapist succeed
2. Overlapping therapist bookings rejected (raw SQLSTATE 23P01)
3. Two therapists, same time, different rooms succeed
4. Room buffer enforced — second booking inside the buffer window rejected
5. Booking starting exactly at room-released time succeeds (range is `[)`)
6. CANCELLED bookings don't block new bookings
7. HOLD blocks CONFIRMED in the same slot
**Foreign keys (3)**
8. Non-existent `therapistId` rejected
9. Non-existent `roomId` rejected
10. Non-existent `customerId` rejected
## Seed snapshot
Verified via `psql`:
| Service | Required tags | Eligible therapists |
|---|---|---|
| 60-min Swedish | t:swedish | 10 |
| 90-min Deep Tissue | t:deep-tissue | 4 |
| 75-min Prenatal | t:prenatal-cert + r:prenatal-table | 3 |
| 90-min Hot Stone | t:hot-stone-cert + r:hot-stone-equipped | 3 |
| 60-min Lymphatic Drainage | t:lymphatic | 4 |
Therapists have realistic tag distributions (everyone does Swedish; specialty distributions vary). All therapists have default working hours TueSat 10:0019:00 in the practice TZ.
## Decisions made this session
| Decision | Resolution |
|---|---|
| Boundary semantics for booking overlaps | `[)` half-open. A booking at 11:00 may follow one ending at 11:00. |
| Service buffer storage | Stored on `Booking.roomReleasedAt` so the room exclusion constraint is single-column |
| Tag vocabulary | Free-form strings (no controlled-vocab table); admin UI manages vocabulary |
| Service eligibility | Two-condition: (a) tag intersection AND (b) explicit `ServiceTherapist` allowlist row |
| FK delete behavior | `Restrict` for Booking-side FKs (User, Therapist, Room, Service); `Cascade` for owned children (Payment, tags, working hours, etc.) |
| Seed strategy | TRUNCATE-based wipe + insert. Idempotent. Same function used by CLI and tests |
| Test seeding cadence | Once per file in `beforeAll`, then `db.booking.deleteMany()` per test |
| TS execution for scripts | `tsx` (modern default; avoids ts-node config) |
| Currency | USD assumed (`Service.priceCents`, `Payment.currency = "usd"`) — pending user confirmation |
| Service prices (seed) | $90$135; deposits 2025% |
| Default working hours (seed) | TueSat 10:0019:00 — practice-typical |
## Gotchas hit
### 1. Prisma 7 AI-agent guard on destructive migrate operations
`prisma migrate reset` (and presumably `db push --force-reset`, etc.) refuses to run when invoked by an AI agent without an explicit `PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION` env var carrying the literal text of the user's consenting message. The error message provides the exact protocol — communicate the action, motivation, destructive nature, prod/dev assessment, then ask. No known way to disable this guard, and probably shouldn't be disabled.
**Implication**: any future migration reset, schema reset, or db wipe needs a user confirmation round-trip. Worth knowing for session pacing.
### 2. Prisma 7 dropped `--skip-seed`
The `db:test:reset` script had `--skip-seed`, which Prisma 7 doesn't recognize. Removed.
### 3. Non-FK-respecting test fixtures break under FK retrofit
Tests previously used opaque text IDs ("therapist-A", "room-1") which worked when Booking had no FK. Once FKs were in place, those tests would fail with FK violations before reaching the constraint they were testing. Solved by seeding real records in `beforeAll` and reading their IDs into module-level variables.
## Open questions for the user
Still pending from prior session, all non-blocking but worth resolving before public launch:
1. **Practice timezone**`APP_TZ=America/Los_Angeles` placeholder; system clock is EDT, but that may be developer-only.
2. **Public-facing brand name** — is "TouchBase" customer-visible or internal-only?
3. **Currency** — USD assumed.
4. **Stripe account** — for when we wire billing.
## Repo state
```
touchbase/
├── compose.yaml, Caddyfile, db/init/
├── prisma/
│ ├── migrations/20260430170142_booking_spike/
│ ├── migrations/20260430172029_full_schema/ ← NEW
│ ├── schema.prisma ← FULL
│ └── seed.ts ← NEW (CLI)
├── scripts/db-bootstrap.sh
├── src/
│ ├── app/ (Next.js starter, untouched)
│ ├── generated/prisma/ (gitignored)
│ └── lib/
│ ├── db.ts
│ └── seed.ts ← NEW (function)
├── test/
│ ├── booking-exclusion.test.ts ← REFACTORED (now 10 tests)
│ └── setup.ts
├── docs/progress/
│ ├── 2026-04-30-bootstrap.md
│ └── 2026-05-01-schema-and-seed.md ← THIS DOC
├── .env, .env.test, .env.example, .nvmrc
├── package.json (added: db:seed; modified: db:test:reset)
├── pnpm-workspace.yaml, prisma.config.ts, vitest.config.ts
└── eslint.config.mjs, tsconfig.json, next.config.ts
```
## How to resume in a new session
```bash
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm install # if anything changed
pnpm db:seed # reset + reseed dev DB (NB: TRUNCATEs everything)
pnpm test # should be 10/10 green
```
## Next step (recommended, starting immediately after this snapshot)
Per `Initial.md` §5: **the pure availability algorithm**. A function `findSlots(service, window, resourceState, options)` that returns slots with candidate therapist/room sets, given working hours, overrides, room blocks, existing bookings, and tag-matching constraints.
Key design choices going in:
- **Pure**: takes resource state as input, no DB inside the function. A separate loader does the DB hit. This makes the algorithm trivially testable with fixtures.
- **TZ-aware**: working hours are wall-clock-local; bookings are UTC. We use `date-fns-tz` (Temporal isn't reliably available on Node yet).
- **Output shape**: each slot returns `{ startsAt, endsAt, candidateTherapists, candidateRooms }`. Greedy assignment happens at booking-commit time, not in the slot search.
- **DST**: spring-forward day specifically — local 02:0003:00 doesn't exist, so working hours that span it lose an hour.
- **Slot granularity**: configurable, default 15 min. Not service-aligned.
- **Window**: default 30 days forward.
Tests should cover: working-hours match/miss, BLOCK overrides, EXTRA_HOURS overrides, room blocks, room buffer collisions, therapist tag mismatch, room tag mismatch, ServiceTherapist allowlist exclusion, therapist preference filter, empty result, DST day.

View File

@@ -2,25 +2,51 @@
"name": "touchbase", "name": "touchbase",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"engines": {
"node": ">=22"
},
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:reset": "prisma migrate reset --force",
"db:studio": "prisma studio",
"db:test:reset": "dotenv -e .env.test -- prisma migrate reset --force",
"db:bootstrap": "scripts/db-bootstrap.sh",
"db:seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"next": "16.2.4", "next": "16.2.4",
"pg": "^8.20.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^22",
"@types/pg": "^8.20.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/ui": "^4.1.5",
"dotenv": "^17.4.2",
"dotenv-cli": "^9.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.4", "eslint-config-next": "16.2.4",
"prisma": "^7.8.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "tsx": "^4.21.0",
"typescript": "^5",
"vitest": "^4.1.5"
} }
} }

5761
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,8 @@
ignoredBuiltDependencies: ignoredBuiltDependencies:
- sharp - sharp
- unrs-resolver - unrs-resolver
onlyBuiltDependencies:
- "@prisma/client"
- "@prisma/engines"
- prisma

14
prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@@ -0,0 +1,62 @@
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('HOLD', 'CONFIRMED', 'COMPLETED', 'NO_SHOW', 'CANCELLED');
-- CreateTable
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"therapistId" TEXT NOT NULL,
"roomId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"startsAt" TIMESTAMPTZ(3) NOT NULL,
"endsAt" TIMESTAMPTZ(3) NOT NULL,
"roomReleasedAt" TIMESTAMPTZ(3) NOT NULL,
"status" "BookingStatus" NOT NULL DEFAULT 'HOLD',
"holdExpiresAt" TIMESTAMPTZ(3),
"priceCents" INTEGER NOT NULL DEFAULT 0,
"depositCents" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Booking_startsAt_idx" ON "Booking"("startsAt");
-- CreateIndex
CREATE INDEX "Booking_therapistId_startsAt_idx" ON "Booking"("therapistId", "startsAt");
-- CreateIndex
CREATE INDEX "Booking_roomId_startsAt_idx" ON "Booking"("roomId", "startsAt");
-- CreateIndex
CREATE INDEX "Booking_status_holdExpiresAt_idx" ON "Booking"("status", "holdExpiresAt");
-- Required extension for combining a UUID/text equality with a range type in EXCLUDE constraints.
-- Created out-of-band by db/init/01-extensions-and-test-db.sql; safe to re-run.
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- Double-booking safety net. App-level availability search prevents most conflicts;
-- these constraints are the last line of defense for race conditions and bugs.
-- Only HOLD and CONFIRMED rows are subject to the constraint — cancelled/completed
-- rows are historical and may overlap freely (e.g., a no-show then a same-slot booking).
-- A therapist can only be in one active booking at a time.
ALTER TABLE "Booking"
ADD CONSTRAINT "Booking_no_therapist_overlap"
EXCLUDE USING gist (
"therapistId" WITH =,
tstzrange("startsAt", "endsAt", '[)') WITH &&
)
WHERE (status IN ('HOLD', 'CONFIRMED'));
-- A room can only host one active booking at a time, including its post-service buffer.
ALTER TABLE "Booking"
ADD CONSTRAINT "Booking_no_room_overlap"
EXCLUDE USING gist (
"roomId" WITH =,
tstzrange("startsAt", "roomReleasedAt", '[)') WITH &&
)
WHERE (status IN ('HOLD', 'CONFIRMED'));

View File

@@ -0,0 +1,291 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('CUSTOMER', 'THERAPIST', 'ADMIN');
-- CreateEnum
CREATE TYPE "OverrideKind" AS ENUM ('BLOCK', 'EXTRA_HOURS');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('NONE', 'PENDING', 'AUTHORIZED', 'CAPTURED', 'REFUNDED', 'FAILED');
-- CreateEnum
CREATE TYPE "PaymentKind" AS ENUM ('DEPOSIT', 'BALANCE', 'REFUND');
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "cancelReason" TEXT,
ADD COLUMN "cancelledAt" TIMESTAMPTZ(3),
ADD COLUMN "cancelledBy" TEXT,
ADD COLUMN "notes" TEXT,
ADD COLUMN "paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'NONE',
ADD COLUMN "stripePaymentIntentId" TEXT;
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" TIMESTAMPTZ(3),
"name" TEXT NOT NULL,
"phone" TEXT,
"role" "Role" NOT NULL DEFAULT 'CUSTOMER',
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
"deletedAt" TIMESTAMPTZ(3),
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Customer" (
"userId" TEXT NOT NULL,
"notes" TEXT,
"stripeCustomerId" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Customer_pkey" PRIMARY KEY ("userId")
);
-- CreateTable
CREATE TABLE "Therapist" (
"userId" TEXT NOT NULL,
"bio" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Therapist_pkey" PRIMARY KEY ("userId")
);
-- CreateTable
CREATE TABLE "TherapistTag" (
"therapistId" TEXT NOT NULL,
"tag" TEXT NOT NULL,
CONSTRAINT "TherapistTag_pkey" PRIMARY KEY ("therapistId","tag")
);
-- CreateTable
CREATE TABLE "Room" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Room_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RoomTag" (
"roomId" TEXT NOT NULL,
"tag" TEXT NOT NULL,
CONSTRAINT "RoomTag_pkey" PRIMARY KEY ("roomId","tag")
);
-- CreateTable
CREATE TABLE "RoomBlock" (
"id" TEXT NOT NULL,
"roomId" TEXT NOT NULL,
"startsAt" TIMESTAMPTZ(3) NOT NULL,
"endsAt" TIMESTAMPTZ(3) NOT NULL,
"reason" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RoomBlock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Service" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"durationMin" INTEGER NOT NULL,
"bufferAfterMin" INTEGER NOT NULL DEFAULT 15,
"priceCents" INTEGER NOT NULL,
"depositCents" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"requiredTherapistTags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"requiredRoomTags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Service_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ServiceTherapist" (
"serviceId" TEXT NOT NULL,
"therapistId" TEXT NOT NULL,
CONSTRAINT "ServiceTherapist_pkey" PRIMARY KEY ("serviceId","therapistId")
);
-- CreateTable
CREATE TABLE "WorkingHours" (
"id" TEXT NOT NULL,
"therapistId" TEXT NOT NULL,
"weekday" INTEGER NOT NULL,
"startMin" INTEGER NOT NULL,
"endMin" INTEGER NOT NULL,
"effectiveFrom" TIMESTAMPTZ(3),
"effectiveTo" TIMESTAMPTZ(3),
CONSTRAINT "WorkingHours_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AvailabilityOverride" (
"id" TEXT NOT NULL,
"therapistId" TEXT NOT NULL,
"startsAt" TIMESTAMPTZ(3) NOT NULL,
"endsAt" TIMESTAMPTZ(3) NOT NULL,
"kind" "OverrideKind" NOT NULL,
"reason" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AvailabilityOverride_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payment" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"kind" "PaymentKind" NOT NULL,
"amountCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'usd',
"stripePaymentIntentId" TEXT,
"status" "PaymentStatus" NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"userId" TEXT,
"bookingId" TEXT,
"channel" TEXT NOT NULL,
"template" TEXT NOT NULL,
"to" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"bodyHash" TEXT NOT NULL,
"status" TEXT NOT NULL,
"providerId" TEXT,
"sentAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"actorId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"meta" JSONB,
"ip" TEXT,
"ua" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_role_idx" ON "User"("role");
-- CreateIndex
CREATE INDEX "User_deletedAt_idx" ON "User"("deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "Customer_stripeCustomerId_key" ON "Customer"("stripeCustomerId");
-- CreateIndex
CREATE INDEX "TherapistTag_tag_idx" ON "TherapistTag"("tag");
-- CreateIndex
CREATE INDEX "RoomTag_tag_idx" ON "RoomTag"("tag");
-- CreateIndex
CREATE INDEX "RoomBlock_roomId_startsAt_idx" ON "RoomBlock"("roomId", "startsAt");
-- CreateIndex
CREATE INDEX "ServiceTherapist_therapistId_idx" ON "ServiceTherapist"("therapistId");
-- CreateIndex
CREATE INDEX "WorkingHours_therapistId_weekday_idx" ON "WorkingHours"("therapistId", "weekday");
-- CreateIndex
CREATE INDEX "AvailabilityOverride_therapistId_startsAt_idx" ON "AvailabilityOverride"("therapistId", "startsAt");
-- CreateIndex
CREATE UNIQUE INDEX "Payment_stripePaymentIntentId_key" ON "Payment"("stripePaymentIntentId");
-- CreateIndex
CREATE INDEX "Payment_bookingId_idx" ON "Payment"("bookingId");
-- CreateIndex
CREATE INDEX "Notification_bookingId_idx" ON "Notification"("bookingId");
-- CreateIndex
CREATE INDEX "Notification_userId_idx" ON "Notification"("userId");
-- CreateIndex
CREATE INDEX "Notification_status_idx" ON "Notification"("status");
-- CreateIndex
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
-- CreateIndex
CREATE INDEX "AuditLog_actorId_createdAt_idx" ON "AuditLog"("actorId", "createdAt");
-- AddForeignKey
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Therapist" ADD CONSTRAINT "Therapist_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TherapistTag" ADD CONSTRAINT "TherapistTag_therapistId_fkey" FOREIGN KEY ("therapistId") REFERENCES "Therapist"("userId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoomTag" ADD CONSTRAINT "RoomTag_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoomBlock" ADD CONSTRAINT "RoomBlock_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServiceTherapist" ADD CONSTRAINT "ServiceTherapist_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServiceTherapist" ADD CONSTRAINT "ServiceTherapist_therapistId_fkey" FOREIGN KEY ("therapistId") REFERENCES "Therapist"("userId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkingHours" ADD CONSTRAINT "WorkingHours_therapistId_fkey" FOREIGN KEY ("therapistId") REFERENCES "Therapist"("userId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AvailabilityOverride" ADD CONSTRAINT "AvailabilityOverride_therapistId_fkey" FOREIGN KEY ("therapistId") REFERENCES "Therapist"("userId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_therapistId_fkey" FOREIGN KEY ("therapistId") REFERENCES "Therapist"("userId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

318
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,318 @@
// TouchBase schema. See /Users/noise/Documents/obsidian/Massage/Initial.md §4 for design rationale.
// All timestamps are timestamptz (UTC at rest); WorkingHours is the only "wall-clock-local" model.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// ============================================================
// Identity & roles
// ============================================================
enum Role {
CUSTOMER
THERAPIST
ADMIN
}
model User {
id String @id @default(cuid())
email String @unique
emailVerified DateTime? @db.Timestamptz(3)
name String
phone String?
role Role @default(CUSTOMER)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
deletedAt DateTime? @db.Timestamptz(3)
customer Customer?
therapist Therapist?
bookings Booking[] @relation("CustomerBookings")
audits AuditLog[] @relation("ActorAudit")
@@index([role])
@@index([deletedAt])
}
model Customer {
userId String @id
notes String? // Front-desk notes. Sensitive — column-level encrypt before prod.
stripeCustomerId String? @unique
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Therapist {
userId String @id
bio String?
active Boolean @default(true)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
tags TherapistTag[]
workingHours WorkingHours[]
overrides AvailabilityOverride[]
services ServiceTherapist[]
bookings Booking[]
}
model TherapistTag {
therapistId String
tag String
therapist Therapist @relation(fields: [therapistId], references: [userId], onDelete: Cascade)
@@id([therapistId, tag])
@@index([tag])
}
// ============================================================
// Resources
// ============================================================
model Room {
id String @id @default(cuid())
name String
active Boolean @default(true)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
tags RoomTag[]
blocks RoomBlock[]
bookings Booking[]
}
model RoomTag {
roomId String
tag String
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@id([roomId, tag])
@@index([tag])
}
model RoomBlock {
id String @id @default(cuid())
roomId String
startsAt DateTime @db.Timestamptz(3)
endsAt DateTime @db.Timestamptz(3)
reason String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@index([roomId, startsAt])
}
// ============================================================
// Services
// ============================================================
model Service {
id String @id @default(cuid())
name String
description String?
durationMin Int
bufferAfterMin Int @default(15)
priceCents Int
depositCents Int @default(0)
active Boolean @default(true)
requiredTherapistTags String[] @default([])
requiredRoomTags String[] @default([])
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
therapists ServiceTherapist[]
bookings Booking[]
}
// Explicit allowlist of therapists who perform a service.
// Tag intersection is the necessary condition; this is the additional opt-in.
model ServiceTherapist {
serviceId String
therapistId String
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
therapist Therapist @relation(fields: [therapistId], references: [userId], onDelete: Cascade)
@@id([serviceId, therapistId])
@@index([therapistId])
}
// ============================================================
// Availability
// ============================================================
model WorkingHours {
id String @id @default(cuid())
therapistId String
weekday Int // 0=Sun .. 6=Sat
startMin Int // minutes from midnight, in APP_TZ (practice-local wall clock)
endMin Int
effectiveFrom DateTime? @db.Timestamptz(3)
effectiveTo DateTime? @db.Timestamptz(3)
therapist Therapist @relation(fields: [therapistId], references: [userId], onDelete: Cascade)
@@index([therapistId, weekday])
}
enum OverrideKind {
BLOCK // PTO, sick — blocks an underlying working hours interval
EXTRA_HOURS // ad-hoc availability outside normal working hours
}
model AvailabilityOverride {
id String @id @default(cuid())
therapistId String
startsAt DateTime @db.Timestamptz(3)
endsAt DateTime @db.Timestamptz(3)
kind OverrideKind
reason String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
therapist Therapist @relation(fields: [therapistId], references: [userId], onDelete: Cascade)
@@index([therapistId, startsAt])
}
// ============================================================
// Bookings
// ============================================================
enum BookingStatus {
HOLD
CONFIRMED
COMPLETED
NO_SHOW
CANCELLED
}
enum PaymentStatus {
NONE
PENDING
AUTHORIZED
CAPTURED
REFUNDED
FAILED
}
model Booking {
id String @id @default(cuid())
customerId String
therapistId String
roomId String
serviceId String
startsAt DateTime @db.Timestamptz(3)
endsAt DateTime @db.Timestamptz(3)
// = endsAt + service.bufferAfterMin. Stored on the row so the room exclusion
// constraint is single-column without a join. Recompute when service buffer changes.
roomReleasedAt DateTime @db.Timestamptz(3)
status BookingStatus @default(HOLD)
holdExpiresAt DateTime? @db.Timestamptz(3)
priceCents Int @default(0)
depositCents Int @default(0)
paymentStatus PaymentStatus @default(NONE)
stripePaymentIntentId String?
notes String? // Front-desk notes specific to this booking
cancelledAt DateTime? @db.Timestamptz(3)
cancelledBy String? // user id (free text — actor may be a system process)
cancelReason String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
customer User @relation("CustomerBookings", fields: [customerId], references: [id], onDelete: Restrict)
therapist Therapist @relation(fields: [therapistId], references: [userId], onDelete: Restrict)
room Room @relation(fields: [roomId], references: [id], onDelete: Restrict)
service Service @relation(fields: [serviceId], references: [id], onDelete: Restrict)
payments Payment[]
@@index([startsAt])
@@index([therapistId, startsAt])
@@index([roomId, startsAt])
@@index([status, holdExpiresAt])
}
// ============================================================
// Payments
// ============================================================
enum PaymentKind {
DEPOSIT
BALANCE
REFUND
}
model Payment {
id String @id @default(cuid())
bookingId String
kind PaymentKind
amountCents Int
currency String @default("usd")
stripePaymentIntentId String? @unique
status PaymentStatus
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
@@index([bookingId])
}
// ============================================================
// Notifications & audit
// ============================================================
// Snapshot semantics: we record the email address as it was at send time
// (user may change theirs later). No FK to User intentionally.
model Notification {
id String @id @default(cuid())
userId String?
bookingId String?
channel String // "email"
template String // "booking_confirmation", "reminder_24h", etc.
to String // address snapshot
subject String
bodyHash String // hash of rendered body for audit; full body not retained long-term
status String // "queued" | "sent" | "failed" | "bounced"
providerId String?
sentAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
@@index([bookingId])
@@index([userId])
@@index([status])
}
model AuditLog {
id String @id @default(cuid())
actorId String?
action String // "booking.created", "user.viewed_customer_notes", ...
entityType String
entityId String
meta Json?
ip String?
ua String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
actor User? @relation("ActorAudit", fields: [actorId], references: [id], onDelete: SetNull)
@@index([entityType, entityId])
@@index([actorId, createdAt])
}

23
prisma/seed.ts Normal file
View File

@@ -0,0 +1,23 @@
// CLI entry point. Loaded via `pnpm db:seed`.
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client";
import { seed } from "../src/lib/seed";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const db = new PrismaClient({ adapter });
(async () => {
const result = await seed(db);
console.log("Seeded:");
console.log(` admin: 1 (${result.admin.id})`);
console.log(` therapists: ${result.therapists.length}`);
console.log(` rooms: ${result.rooms.length}`);
console.log(` services: ${result.services.length}`);
console.log(` customers: ${result.customers.length}`);
await db.$disconnect();
})().catch(async (e) => {
console.error(e);
await db.$disconnect();
process.exit(1);
});

26
scripts/db-bootstrap.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Idempotent: ensures touchbase_test DB exists and both DBs have required extensions.
# Run after `docker-compose up -d postgres` if the init scripts didn't fire (bind-mount race on first start).
set -euo pipefail
PGUSER="${PGUSER:-touchbase}"
PGPASSWORD="${PGPASSWORD:-touchbase}"
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5432}"
export PGPASSWORD
psql_run() { docker exec -e PGPASSWORD touchbase-postgres-1 psql -U "$PGUSER" -d "$1" -v ON_ERROR_STOP=1 -c "$2"; }
# Create test DB if missing
exists=$(docker exec -e PGPASSWORD touchbase-postgres-1 psql -U "$PGUSER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='touchbase_test'")
if [ -z "$exists" ]; then
psql_run postgres "CREATE DATABASE touchbase_test OWNER $PGUSER"
fi
for db in touchbase_dev touchbase_test; do
psql_run "$db" "CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE EXTENSION IF NOT EXISTS pgcrypto;"
done
echo "DB bootstrap OK."

316
src/lib/availability.ts Normal file
View File

@@ -0,0 +1,316 @@
// Pure availability algorithm. No DB dependency — caller passes resource state in.
// See /Users/noise/Documents/obsidian/Massage/Initial.md §5 for design notes.
//
// All Date inputs are UTC-instant moments; timezone-sensitive logic happens only
// inside checkWorkingHours, where we convert to the practice's wall clock to
// compare against minutes-from-midnight working-hours rows.
import { addMinutes, isBefore } from "date-fns";
// ============================================================
// Types
// ============================================================
export type ServiceLite = {
id: string;
durationMin: number;
bufferAfterMin: number;
requiredTherapistTags: string[];
requiredRoomTags: string[];
};
export type WorkingHoursEntry = {
weekday: number; // 0=Sun .. 6=Sat (matches Date.getDay())
startMin: number; // minutes from midnight, practice-local wall clock
endMin: number; // exclusive upper bound
effectiveFrom?: Date | null; // UTC; inclusive
effectiveTo?: Date | null; // UTC; exclusive
};
export type AvailabilityOverrideEntry = {
startsAt: Date; // UTC
endsAt: Date; // UTC
kind: "BLOCK" | "EXTRA_HOURS";
};
export type BookingInterval = {
startsAt: Date; // UTC
endsAt: Date; // UTC; for room bookings include buffer (= roomReleasedAt)
};
export type TherapistState = {
id: string;
active: boolean;
tags: ReadonlySet<string>;
serviceIds: ReadonlySet<string>; // ServiceTherapist allowlist
workingHours: WorkingHoursEntry[];
overrides: AvailabilityOverrideEntry[];
bookings: BookingInterval[]; // only HOLD/CONFIRMED rows
};
export type RoomState = {
id: string;
active: boolean;
tags: ReadonlySet<string>;
blocks: BookingInterval[]; // RoomBlock rows
bookings: BookingInterval[]; // bookings extended to roomReleasedAt
};
export type FindSlotsOptions = {
service: ServiceLite;
from: Date; // UTC; inclusive lower bound
to: Date; // UTC; exclusive upper bound
therapists: TherapistState[];
rooms: RoomState[];
practiceTz: string; // IANA TZ, e.g. "America/New_York"
slotGranularityMin?: number; // default 15
preferredTherapistId?: string; // if set, only this therapist is considered
};
export type Slot = {
startsAt: Date; // UTC
endsAt: Date; // UTC
candidateTherapistIds: string[];
candidateRoomIds: string[];
};
// ============================================================
// Public entry point
// ============================================================
export function findSlots(opts: FindSlotsOptions): Slot[] {
const granularityMin = opts.slotGranularityMin ?? 15;
if (granularityMin <= 0 || !Number.isInteger(granularityMin)) {
throw new Error("slotGranularityMin must be a positive integer");
}
if (!isBefore(opts.from, opts.to)) return [];
// 1. Pre-filter resources by tag/eligibility (cheap, set-based)
const eligibleTherapists = opts.therapists.filter((t) => {
if (!t.active) return false;
if (!t.serviceIds.has(opts.service.id)) return false;
if (!hasAllTags(t.tags, opts.service.requiredTherapistTags)) return false;
if (opts.preferredTherapistId && t.id !== opts.preferredTherapistId) return false;
return true;
});
const eligibleRooms = opts.rooms.filter((r) => {
if (!r.active) return false;
if (!hasAllTags(r.tags, opts.service.requiredRoomTags)) return false;
return true;
});
if (eligibleTherapists.length === 0 || eligibleRooms.length === 0) return [];
// 2. Walk the slot grid in UTC. Step by granularity; emit when at least
// one therapist and one room are available for the candidate slot.
const slots: Slot[] = [];
const start = roundUpToGranularity(opts.from, granularityMin);
for (
let s = start;
isBefore(addMinutes(s, opts.service.durationMin - 1), opts.to);
s = addMinutes(s, granularityMin)
) {
const e = addMinutes(s, opts.service.durationMin);
const roomReleased = addMinutes(e, opts.service.bufferAfterMin);
const candidateTherapistIds: string[] = [];
for (const t of eligibleTherapists) {
if (therapistAvailable(t, s, e, opts.practiceTz)) {
candidateTherapistIds.push(t.id);
}
}
if (candidateTherapistIds.length === 0) continue;
const candidateRoomIds: string[] = [];
for (const r of eligibleRooms) {
if (roomAvailable(r, s, roomReleased)) {
candidateRoomIds.push(r.id);
}
}
if (candidateRoomIds.length === 0) continue;
slots.push({ startsAt: s, endsAt: e, candidateTherapistIds, candidateRoomIds });
}
return slots;
}
// ============================================================
// Per-resource predicates (exported for direct testing)
// ============================================================
export function therapistAvailable(
t: TherapistState,
startsAt: Date,
endsAt: Date,
practiceTz: string,
): boolean {
// Bookings — any overlap kills the slot.
for (const b of t.bookings) {
if (intervalsOverlap(b.startsAt, b.endsAt, startsAt, endsAt)) return false;
}
// BLOCK overrides — any overlap kills the slot.
for (const ov of t.overrides) {
if (
ov.kind === "BLOCK" &&
intervalsOverlap(ov.startsAt, ov.endsAt, startsAt, endsAt)
) {
return false;
}
}
// Slot must be either inside regular working hours OR inside an EXTRA_HOURS window.
const inExtraHours = t.overrides.some(
(ov) =>
ov.kind === "EXTRA_HOURS" &&
intervalContains(ov.startsAt, ov.endsAt, startsAt, endsAt),
);
if (inExtraHours) return true;
return inWorkingHours(t.workingHours, startsAt, endsAt, practiceTz);
}
export function roomAvailable(
r: RoomState,
startsAt: Date,
endsAtIncludingBuffer: Date,
): boolean {
for (const b of r.blocks) {
if (intervalsOverlap(b.startsAt, b.endsAt, startsAt, endsAtIncludingBuffer)) {
return false;
}
}
for (const b of r.bookings) {
if (intervalsOverlap(b.startsAt, b.endsAt, startsAt, endsAtIncludingBuffer)) {
return false;
}
}
return true;
}
// ============================================================
// Working-hours check (timezone-aware)
// ============================================================
export function inWorkingHours(
entries: WorkingHoursEntry[],
startsAt: Date,
endsAt: Date,
practiceTz: string,
): boolean {
const local = zonedParts(startsAt, practiceTz);
const localEnd = zonedParts(endsAt, practiceTz);
// Cross-midnight slots are not allowed — reject.
if (
local.year !== localEnd.year ||
local.month !== localEnd.month ||
local.day !== localEnd.day
) {
return false;
}
const weekday = local.weekday;
const startMin = local.hour * 60 + local.minute;
const endMin = localEnd.hour * 60 + localEnd.minute;
for (const w of entries) {
if (w.weekday !== weekday) continue;
if (w.startMin > startMin) continue;
if (endMin > w.endMin) continue;
if (w.effectiveFrom && startsAt < w.effectiveFrom) continue;
if (w.effectiveTo && startsAt >= w.effectiveTo) continue;
return true;
}
return false;
}
// ============================================================
// Helpers
// ============================================================
function hasAllTags(have: ReadonlySet<string>, need: readonly string[]): boolean {
for (const tag of need) if (!have.has(tag)) return false;
return true;
}
function intervalsOverlap(
aStart: Date,
aEnd: Date,
bStart: Date,
bEnd: Date,
): boolean {
// Half-open [) semantics, matching the booking exclusion constraints.
return aStart < bEnd && bStart < aEnd;
}
function intervalContains(
outerStart: Date,
outerEnd: Date,
innerStart: Date,
innerEnd: Date,
): boolean {
return outerStart <= innerStart && innerEnd <= outerEnd;
}
function roundUpToGranularity(d: Date, granularityMin: number): Date {
const ms = d.getTime();
const stepMs = granularityMin * 60_000;
const remainder = ms % stepMs;
if (remainder === 0) return d;
return new Date(ms + (stepMs - remainder));
}
// ============================================================
// Timezone — system-TZ-independent extraction via Intl
// ============================================================
const WEEKDAY_INDEX: Readonly<Record<string, number>> = {
Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
};
const zonedFormatterCache = new Map<string, Intl.DateTimeFormat>();
function getZonedFormatter(tz: string): Intl.DateTimeFormat {
let f = zonedFormatterCache.get(tz);
if (!f) {
f = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
zonedFormatterCache.set(tz, f);
}
return f;
}
type ZonedParts = {
year: number;
month: number;
day: number;
weekday: number;
hour: number;
minute: number;
};
function zonedParts(date: Date, tz: string): ZonedParts {
const parts = getZonedFormatter(tz).formatToParts(date);
const m: Record<string, string> = {};
for (const p of parts) m[p.type] = p.value;
// hour12:false in en-US returns "24" for midnight; normalize.
let hour = Number(m.hour);
if (hour === 24) hour = 0;
return {
year: Number(m.year),
month: Number(m.month),
day: Number(m.day),
weekday: WEEKDAY_INDEX[m.weekday] ?? 0,
hour,
minute: Number(m.minute),
};
}

19
src/lib/db.ts Normal file
View File

@@ -0,0 +1,19 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
declare global {
var __prisma: PrismaClient | undefined;
}
function makeClient(): PrismaClient {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});
return new PrismaClient({ adapter });
}
export const db = globalThis.__prisma ?? makeClient();
if (process.env.NODE_ENV !== "production") {
globalThis.__prisma = db;
}

270
src/lib/seed.ts Normal file
View File

@@ -0,0 +1,270 @@
import type { PrismaClient } from "@/generated/prisma/client";
// Tag vocabulary (informal — no controlled-vocab table v1).
export const TAGS = {
// Therapist qualifications
swedish: "swedish",
deepTissue: "deep-tissue",
prenatalCert: "prenatal-cert",
hotStoneCert: "hot-stone-cert",
lymphatic: "lymphatic",
// Room capabilities
prenatalTable: "prenatal-table",
hotStoneEquipped: "hot-stone-equipped",
couples: "couples",
wetRoom: "wet-room",
} as const;
type TherapistSeed = {
email: string;
name: string;
bio?: string;
tags: string[];
};
const THERAPISTS: TherapistSeed[] = [
{ email: "mei@touchbase.local", name: "Mei Tanaka", tags: [TAGS.swedish, TAGS.deepTissue, TAGS.lymphatic] },
{ email: "carlos@touchbase.local", name: "Carlos Rivera", tags: [TAGS.swedish, TAGS.deepTissue] },
{ email: "priya@touchbase.local", name: "Priya Patel", tags: [TAGS.swedish, TAGS.prenatalCert] },
{ email: "jordan@touchbase.local", name: "Jordan Lee", tags: [TAGS.swedish, TAGS.hotStoneCert] },
{ email: "aisha@touchbase.local", name: "Aisha Johnson", tags: [TAGS.swedish, TAGS.lymphatic, TAGS.prenatalCert] },
{ email: "rin@touchbase.local", name: "Rin Park", tags: [TAGS.swedish, TAGS.deepTissue, TAGS.hotStoneCert] },
{ email: "daniel@touchbase.local", name: "Daniel Costa", tags: [TAGS.swedish] },
{ email: "sara@touchbase.local", name: "Sara Goldberg", tags: [TAGS.swedish, TAGS.prenatalCert, TAGS.lymphatic] },
{ email: "emeka@touchbase.local", name: "Emeka Obi", tags: [TAGS.swedish, TAGS.deepTissue, TAGS.hotStoneCert] },
{ email: "marisol@touchbase.local", name: "Marisol Cruz", tags: [TAGS.swedish, TAGS.lymphatic] },
];
type RoomSeed = { name: string; tags: string[] };
const ROOMS: RoomSeed[] = [
{ name: "Room 1 — Sunset", tags: [] },
{ name: "Room 2 — Ocean", tags: [] },
{ name: "Room 3 — Cedar", tags: [TAGS.hotStoneEquipped] },
{ name: "Room 4 — Bamboo", tags: [TAGS.hotStoneEquipped] },
{ name: "Room 5 — Lotus", tags: [TAGS.prenatalTable] },
{ name: "Room 6 — Sage", tags: [TAGS.prenatalTable, TAGS.hotStoneEquipped] },
{ name: "Room 7 — Maple", tags: [TAGS.couples] },
{ name: "Room 8 — Willow", tags: [] },
{ name: "Room 9 — Birch", tags: [TAGS.wetRoom] },
{ name: "Room 10 — Quiet", tags: [] },
];
type ServiceSeed = {
name: string;
description: string;
durationMin: number;
bufferAfterMin: number;
priceCents: number;
depositCents: number;
requiredTherapistTags: string[];
requiredRoomTags: string[];
};
const SERVICES: ServiceSeed[] = [
{
name: "60-minute Swedish",
description: "Classic relaxation massage.",
durationMin: 60,
bufferAfterMin: 15,
priceCents: 9000,
depositCents: 2000,
requiredTherapistTags: [TAGS.swedish],
requiredRoomTags: [],
},
{
name: "90-minute Deep Tissue",
description: "Targeted work on chronic tension; firm pressure.",
durationMin: 90,
bufferAfterMin: 15,
priceCents: 13000,
depositCents: 3000,
requiredTherapistTags: [TAGS.deepTissue],
requiredRoomTags: [],
},
{
name: "75-minute Prenatal",
description: "Side-lying massage for expectant mothers; certified therapists only.",
durationMin: 75,
bufferAfterMin: 20,
priceCents: 11500,
depositCents: 2500,
requiredTherapistTags: [TAGS.prenatalCert],
requiredRoomTags: [TAGS.prenatalTable],
},
{
name: "90-minute Hot Stone",
description: "Heated basalt stones; equipment-dependent.",
durationMin: 90,
bufferAfterMin: 30,
priceCents: 13500,
depositCents: 3000,
requiredTherapistTags: [TAGS.hotStoneCert],
requiredRoomTags: [TAGS.hotStoneEquipped],
},
{
name: "60-minute Lymphatic Drainage",
description: "Light, rhythmic strokes to support lymphatic flow.",
durationMin: 60,
bufferAfterMin: 15,
priceCents: 10000,
depositCents: 2000,
requiredTherapistTags: [TAGS.lymphatic],
requiredRoomTags: [],
},
];
const CUSTOMERS = [
{ email: "alex@example.com", name: "Alex Park" },
{ email: "robin@example.com", name: "Robin Halloran" },
{ email: "sam@example.com", name: "Sam Beaumont" },
];
const ADMIN = { email: "admin@touchbase.local", name: "Admin User" };
// 0=Sun .. 6=Sat. Tue=2, Wed=3, ... Sat=6. Default schedule: Tue-Sat 10:00-19:00 local.
const DEFAULT_WORKDAYS = [2, 3, 4, 5, 6];
const DEFAULT_START_MIN = 10 * 60; // 10:00
const DEFAULT_END_MIN = 19 * 60; // 19:00
const TABLES_TO_WIPE = [
"AuditLog",
"Notification",
"Payment",
"Booking",
"AvailabilityOverride",
"WorkingHours",
"ServiceTherapist",
"Service",
"RoomBlock",
"RoomTag",
"Room",
"TherapistTag",
"Therapist",
"Customer",
"User",
];
export type SeedResult = {
admin: { id: string };
therapists: { id: string; name: string; tags: string[] }[];
rooms: { id: string; name: string; tags: string[] }[];
services: {
id: string;
name: string;
durationMin: number;
bufferAfterMin: number;
requiredTherapistTags: string[];
requiredRoomTags: string[];
}[];
customers: { id: string; name: string; email: string }[];
};
export async function wipe(db: PrismaClient): Promise<void> {
// Single statement; CASCADE not needed because we're listing all FK-referenced tables.
// RESTART IDENTITY is a no-op since we use cuid PKs but harmless to include.
const list = TABLES_TO_WIPE.map((t) => `"${t}"`).join(", ");
await db.$executeRawUnsafe(
`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`,
);
}
export async function seed(db: PrismaClient): Promise<SeedResult> {
await wipe(db);
// Admin
const admin = await db.user.create({
data: { email: ADMIN.email, name: ADMIN.name, role: "ADMIN" },
});
// Therapists
const therapistRows: SeedResult["therapists"] = [];
for (const t of THERAPISTS) {
const user = await db.user.create({
data: {
email: t.email,
name: t.name,
role: "THERAPIST",
therapist: {
create: {
bio: t.bio,
tags: { create: t.tags.map((tag) => ({ tag })) },
workingHours: {
create: DEFAULT_WORKDAYS.map((weekday) => ({
weekday,
startMin: DEFAULT_START_MIN,
endMin: DEFAULT_END_MIN,
})),
},
},
},
},
});
therapistRows.push({ id: user.id, name: t.name, tags: t.tags });
}
// Rooms
const roomRows: SeedResult["rooms"] = [];
for (const r of ROOMS) {
const room = await db.room.create({
data: {
name: r.name,
tags: { create: r.tags.map((tag) => ({ tag })) },
},
});
roomRows.push({ id: room.id, name: r.name, tags: r.tags });
}
// Services + ServiceTherapist allowlist (all qualified therapists are allowed)
const serviceRows: SeedResult["services"] = [];
for (const s of SERVICES) {
const eligible = therapistRows.filter((t) =>
s.requiredTherapistTags.every((tag) => t.tags.includes(tag)),
);
const service = await db.service.create({
data: {
name: s.name,
description: s.description,
durationMin: s.durationMin,
bufferAfterMin: s.bufferAfterMin,
priceCents: s.priceCents,
depositCents: s.depositCents,
requiredTherapistTags: s.requiredTherapistTags,
requiredRoomTags: s.requiredRoomTags,
therapists: {
create: eligible.map((t) => ({ therapistId: t.id })),
},
},
});
serviceRows.push({
id: service.id,
name: s.name,
durationMin: s.durationMin,
bufferAfterMin: s.bufferAfterMin,
requiredTherapistTags: s.requiredTherapistTags,
requiredRoomTags: s.requiredRoomTags,
});
}
// Customers
const customerRows: SeedResult["customers"] = [];
for (const c of CUSTOMERS) {
const user = await db.user.create({
data: {
email: c.email,
name: c.name,
role: "CUSTOMER",
customer: { create: {} },
},
});
customerRows.push({ id: user.id, name: c.name, email: c.email });
}
return {
admin: { id: admin.id },
therapists: therapistRows,
rooms: roomRows,
services: serviceRows,
customers: customerRows,
};
}

456
test/availability.test.ts Normal file
View File

@@ -0,0 +1,456 @@
import { describe, expect, test } from "vitest";
import {
findSlots,
inWorkingHours,
therapistAvailable,
type FindSlotsOptions,
type RoomState,
type ServiceLite,
type TherapistState,
type WorkingHoursEntry,
} from "@/lib/availability";
// ============================================================
// Fixture helpers
// ============================================================
const TZ = "America/New_York";
const D = (iso: string) => new Date(iso);
// Tue 2026-05-05 in the New York practice timezone:
// 10:00 EDT == 14:00 UTC (EDT = UTC-4)
// We'll use this date a lot in fixtures.
const TUE_LOCAL_10AM = D("2026-05-05T14:00:00Z"); // 2026-05-05 10:00 EDT
const TUE_LOCAL_11AM = D("2026-05-05T15:00:00Z");
const TUE_LOCAL_12PM = D("2026-05-05T16:00:00Z");
const TUE_LOCAL_5PM = D("2026-05-05T21:00:00Z");
const TUE_LOCAL_7PM = D("2026-05-05T23:00:00Z");
const TUE_LOCAL_9AM = D("2026-05-05T13:00:00Z"); // before opening
// Spring-forward 2026 in US Eastern: Mar 8 2026, local 02:00 EST → 03:00 EDT.
const SPRING_FORWARD_SUN_LOCAL_10AM = D("2026-03-08T14:00:00Z"); // 10:00 EDT after the jump
const SERVICE_60MIN: ServiceLite = {
id: "svc-60",
durationMin: 60,
bufferAfterMin: 15,
requiredTherapistTags: [],
requiredRoomTags: [],
};
const SERVICE_90MIN_PRENATAL: ServiceLite = {
id: "svc-90-prenatal",
durationMin: 90,
bufferAfterMin: 20,
requiredTherapistTags: ["prenatal-cert"],
requiredRoomTags: ["prenatal-table"],
};
const FULL_WEEK_10_TO_19: WorkingHoursEntry[] = [0, 1, 2, 3, 4, 5, 6].map((d) => ({
weekday: d,
startMin: 10 * 60,
endMin: 19 * 60,
}));
const TUE_THRU_SAT_10_TO_19: WorkingHoursEntry[] = [2, 3, 4, 5, 6].map((d) => ({
weekday: d,
startMin: 10 * 60,
endMin: 19 * 60,
}));
function baseTherapist(overrides: Partial<TherapistState> = {}): TherapistState {
return {
id: "t1",
active: true,
tags: new Set(["swedish"]),
serviceIds: new Set([SERVICE_60MIN.id]),
workingHours: TUE_THRU_SAT_10_TO_19,
overrides: [],
bookings: [],
...overrides,
};
}
function baseRoom(overrides: Partial<RoomState> = {}): RoomState {
return {
id: "r1",
active: true,
tags: new Set<string>(),
blocks: [],
bookings: [],
...overrides,
};
}
function baseOpts(overrides: Partial<FindSlotsOptions> = {}): FindSlotsOptions {
return {
service: SERVICE_60MIN,
from: TUE_LOCAL_10AM,
to: TUE_LOCAL_12PM,
therapists: [baseTherapist()],
rooms: [baseRoom()],
practiceTz: TZ,
slotGranularityMin: 15,
...overrides,
};
}
// ============================================================
// inWorkingHours — direct tests
// ============================================================
describe("inWorkingHours", () => {
test("slot inside Tuesday working hours is accepted", () => {
expect(
inWorkingHours(TUE_THRU_SAT_10_TO_19, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ),
).toBe(true);
});
test("slot before opening is rejected", () => {
expect(
inWorkingHours(TUE_THRU_SAT_10_TO_19, TUE_LOCAL_9AM, TUE_LOCAL_10AM, TZ),
).toBe(false);
});
test("slot ending exactly at closing is accepted (endMin is inclusive of equality)", () => {
expect(
inWorkingHours(TUE_THRU_SAT_10_TO_19, TUE_LOCAL_5PM, TUE_LOCAL_7PM, TZ),
).toBe(true);
});
test("Monday slot rejected when Mon is not a working day", () => {
// 2026-05-04 is a Monday
const monLocal10am = D("2026-05-04T14:00:00Z");
const monLocal11am = D("2026-05-04T15:00:00Z");
expect(
inWorkingHours(TUE_THRU_SAT_10_TO_19, monLocal10am, monLocal11am, TZ),
).toBe(false);
});
test("DST spring-forward Sunday: 10:00 local works because we shift to EDT", () => {
// Even though the local clock skipped 02:0003:00 EST, 10:00 EDT is well after.
expect(
inWorkingHours(
FULL_WEEK_10_TO_19,
SPRING_FORWARD_SUN_LOCAL_10AM,
D("2026-03-08T15:00:00Z"), // 11:00 EDT
TZ,
),
).toBe(true);
});
test("effectiveFrom/effectiveTo bracket is enforced", () => {
const wh: WorkingHoursEntry[] = [{
weekday: 2,
startMin: 10 * 60,
endMin: 19 * 60,
effectiveFrom: D("2026-06-01T00:00:00Z"),
}];
// Slot before effectiveFrom should not match.
expect(
inWorkingHours(wh, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ),
).toBe(false);
});
test("cross-midnight slot is rejected", () => {
// Hypothetical 11pm-1am slot would span two local days.
expect(
inWorkingHours(
FULL_WEEK_10_TO_19,
D("2026-05-06T03:00:00Z"), // 23:00 local Tuesday
D("2026-05-06T05:00:00Z"), // 01:00 local Wednesday
TZ,
),
).toBe(false);
});
});
// ============================================================
// therapistAvailable — direct tests
// ============================================================
describe("therapistAvailable", () => {
test("free therapist in working hours is available", () => {
const t = baseTherapist();
expect(therapistAvailable(t, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ)).toBe(true);
});
test("booking overlap blocks the slot", () => {
const t = baseTherapist({
bookings: [{ startsAt: TUE_LOCAL_10AM, endsAt: TUE_LOCAL_11AM }],
});
expect(
therapistAvailable(
t,
D("2026-05-05T14:30:00Z"), // 10:30 EDT — overlaps 10:0011:00
D("2026-05-05T15:30:00Z"),
TZ,
),
).toBe(false);
});
test("BLOCK override during working hours blocks the slot", () => {
const t = baseTherapist({
overrides: [{
kind: "BLOCK",
startsAt: D("2026-05-05T14:30:00Z"),
endsAt: D("2026-05-05T15:30:00Z"),
}],
});
expect(therapistAvailable(t, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ)).toBe(false);
});
test("EXTRA_HOURS opens a slot outside regular working hours", () => {
// Therapist's regular hours don't include Sunday; EXTRA_HOURS does.
const t = baseTherapist({
workingHours: TUE_THRU_SAT_10_TO_19, // no Sunday
overrides: [{
kind: "EXTRA_HOURS",
startsAt: D("2026-05-10T14:00:00Z"), // Sun 10:00 EDT
endsAt: D("2026-05-10T18:00:00Z"), // Sun 14:00 EDT
}],
});
expect(
therapistAvailable(
t,
D("2026-05-10T14:00:00Z"),
D("2026-05-10T15:00:00Z"),
TZ,
),
).toBe(true);
});
});
// ============================================================
// findSlots — integration
// ============================================================
describe("findSlots", () => {
test("baseline: 60-min service in a 2-hour window emits 5 slots at 15-min granularity", () => {
// Window 10:0012:00 local. Service is 60 min. Slots can start at
// 10:00, 10:15, 10:30, 10:45, 11:00 (last finishes at 12:00).
const slots = findSlots(baseOpts());
expect(slots).toHaveLength(5);
expect(slots[0].startsAt).toEqual(TUE_LOCAL_10AM);
expect(slots[4].startsAt).toEqual(TUE_LOCAL_11AM);
expect(slots[4].endsAt).toEqual(TUE_LOCAL_12PM);
});
test("empty therapists list yields no slots", () => {
expect(findSlots(baseOpts({ therapists: [] }))).toEqual([]);
});
test("empty rooms list yields no slots", () => {
expect(findSlots(baseOpts({ rooms: [] }))).toEqual([]);
});
test("therapist tag mismatch excludes therapist", () => {
// Service requires prenatal-cert, but the only room has the right tag.
const slots = findSlots(
baseOpts({
service: SERVICE_90MIN_PRENATAL,
// Window large enough for one 90-min slot
to: D("2026-05-05T15:30:00Z"),
therapists: [
baseTherapist({
tags: new Set(["swedish"]), // missing prenatal-cert
serviceIds: new Set([SERVICE_90MIN_PRENATAL.id]),
}),
],
rooms: [
baseRoom({ tags: new Set(["prenatal-table"]) }),
],
}),
);
expect(slots).toEqual([]);
});
test("room tag mismatch excludes room", () => {
const slots = findSlots(
baseOpts({
service: SERVICE_90MIN_PRENATAL,
to: D("2026-05-05T15:30:00Z"),
therapists: [
baseTherapist({
tags: new Set(["prenatal-cert"]),
serviceIds: new Set([SERVICE_90MIN_PRENATAL.id]),
}),
],
rooms: [
baseRoom({ tags: new Set<string>() }), // missing prenatal-table
],
}),
);
expect(slots).toEqual([]);
});
test("ServiceTherapist allowlist excludes therapists not opted in", () => {
const slots = findSlots(
baseOpts({
therapists: [
baseTherapist({ serviceIds: new Set(["some-other-service"]) }),
],
}),
);
expect(slots).toEqual([]);
});
test("inactive therapist excluded", () => {
expect(
findSlots(baseOpts({ therapists: [baseTherapist({ active: false })] })),
).toEqual([]);
});
test("inactive room excluded", () => {
expect(
findSlots(baseOpts({ rooms: [baseRoom({ active: false })] })),
).toEqual([]);
});
test("therapist on PTO via BLOCK override has no slots in PTO window", () => {
const slots = findSlots(
baseOpts({
therapists: [
baseTherapist({
overrides: [{
kind: "BLOCK",
startsAt: TUE_LOCAL_10AM,
endsAt: TUE_LOCAL_12PM,
}],
}),
],
}),
);
expect(slots).toEqual([]);
});
test("room buffer prevents back-to-back booking inside the buffer window", () => {
// Existing booking 10:0011:00 with 15-min buffer ⇒ room locked till 11:15.
// Window 10:0012:00 ⇒ next legal start is 11:15. With granularity 15 and
// duration 60, candidates 11:15 (end 12:15 out of window!), so 0 slots
// beyond the existing one. But the existing booking's slot itself isn't a
// candidate (the room is occupied). Expand window to 12:30 to test legal
// resumption at 11:15.
const slots = findSlots(
baseOpts({
to: D("2026-05-05T16:30:00Z"), // 12:30 EDT
rooms: [
baseRoom({
bookings: [{
startsAt: TUE_LOCAL_10AM,
endsAt: D("2026-05-05T15:15:00Z"), // = 11:00 + 15 min buffer
}],
}),
],
}),
);
// Legal slots: 11:15 → 12:15 (only one, since 11:30 → 12:30 also fits).
const starts = slots.map((s) => s.startsAt.toISOString());
expect(starts).toContain("2026-05-05T15:15:00.000Z"); // 11:15 EDT
expect(starts).toContain("2026-05-05T15:30:00.000Z"); // 11:30 EDT
// Should NOT include any time before 11:15 (room locked).
expect(
starts.filter((s) => s < "2026-05-05T15:15:00.000Z"),
).toEqual([]);
});
test("room block excludes that room during the block window", () => {
const slots = findSlots(
baseOpts({
rooms: [
baseRoom({
blocks: [{
startsAt: D("2026-05-05T14:30:00Z"), // 10:30 EDT
endsAt: D("2026-05-05T15:30:00Z"), // 11:30 EDT
}],
}),
],
}),
);
// 10:0011:00 slot? Block starts 10:30, so room buffer on slot 10:00 → 11:15
// overlaps the block — excluded.
const starts = slots.map((s) => s.startsAt.toISOString());
expect(starts).not.toContain("2026-05-05T14:00:00.000Z"); // 10:00
expect(starts).not.toContain("2026-05-05T14:15:00.000Z"); // 10:15
});
test("preferredTherapistId restricts results to that therapist", () => {
const t1 = baseTherapist({ id: "t1" });
const t2 = baseTherapist({ id: "t2" });
const slots = findSlots(
baseOpts({
therapists: [t1, t2],
preferredTherapistId: "t2",
}),
);
for (const s of slots) {
expect(s.candidateTherapistIds).toEqual(["t2"]);
}
expect(slots.length).toBeGreaterThan(0);
});
test("from-time not on grid is rounded up to the next granular boundary", () => {
const slots = findSlots(
baseOpts({
from: D("2026-05-05T14:07:00Z"), // 10:07 EDT — should round up to 10:15
to: D("2026-05-05T16:00:00Z"), // 12:00 EDT
}),
);
expect(slots[0].startsAt).toEqual(D("2026-05-05T14:15:00Z"));
});
test("out-of-working-hours window yields no slots", () => {
const slots = findSlots(
baseOpts({
from: D("2026-05-05T11:00:00Z"), // 7:00 EDT
to: D("2026-05-05T13:00:00Z"), // 9:00 EDT — entirely before open
}),
);
expect(slots).toEqual([]);
});
test("DST spring-forward Sunday: slot grid still produces clean slots after the jump", () => {
const slots = findSlots(
baseOpts({
from: D("2026-03-08T14:00:00Z"), // 10:00 EDT (after the jump)
to: D("2026-03-08T16:00:00Z"), // 12:00 EDT
therapists: [
baseTherapist({ workingHours: FULL_WEEK_10_TO_19 }),
],
}),
);
expect(slots.length).toBeGreaterThan(0);
// First slot should be exactly 10:00 EDT.
expect(slots[0].startsAt).toEqual(SPRING_FORWARD_SUN_LOCAL_10AM);
});
test("returns slots sorted by start time, ascending", () => {
const slots = findSlots(
baseOpts({
to: D("2026-05-05T17:00:00Z"), // 13:00 EDT — wider window
}),
);
for (let i = 1; i < slots.length; i++) {
expect(slots[i].startsAt.getTime()).toBeGreaterThan(
slots[i - 1].startsAt.getTime(),
);
}
});
test("two therapists, only one available at a given slot — slot still emitted with that one as candidate", () => {
const t1 = baseTherapist({
id: "t1",
bookings: [{
startsAt: TUE_LOCAL_10AM,
endsAt: TUE_LOCAL_11AM,
}],
});
const t2 = baseTherapist({ id: "t2" });
const slots = findSlots(baseOpts({ therapists: [t1, t2] }));
const tenAm = slots.find(
(s) => s.startsAt.toISOString() === "2026-05-05T14:00:00.000Z",
);
expect(tenAm).toBeDefined();
expect(tenAm!.candidateTherapistIds).toEqual(["t2"]);
});
});

View File

@@ -0,0 +1,249 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
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 therapistA: string;
let therapistB: string;
let roomA: string;
let roomB: string;
let serviceId: string;
let customerId: string;
beforeAll(async () => {
fx = await seed(db);
therapistA = fx.therapists[0].id;
therapistB = fx.therapists[1].id;
roomA = fx.rooms[0].id;
roomB = fx.rooms[1].id;
// 60-minute Swedish — both seed therapists have the "swedish" tag and no
// room tag is required, so any room/therapist pair works for these tests.
serviceId = fx.services.find((s) => s.name.startsWith("60-minute Swedish"))!.id;
customerId = fx.customers[0].id;
});
afterAll(async () => {
await db.$disconnect();
});
beforeEach(async () => {
await db.booking.deleteMany();
});
const PG_EXCLUSION_VIOLATION = "23P01";
const D = (iso: string) => new Date(iso);
const findPgCode = (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>;
if (typeof obj.code === "string" && /^[0-9A-Z]{5}$/.test(obj.code)) {
return obj.code as string;
}
if (obj.meta && typeof obj.meta === "object") {
const meta = obj.meta as Record<string, unknown>;
if (typeof meta.code === "string") return meta.code;
if (
typeof meta.constraint === "string" &&
meta.constraint.includes("_overlap")
) {
return PG_EXCLUSION_VIOLATION;
}
}
cur = (obj.cause as unknown) ?? undefined;
}
return undefined;
};
const expectExclusionViolation = (e: unknown) => {
const code = findPgCode(e);
if (code !== PG_EXCLUSION_VIOLATION) {
throw new Error(
`Expected exclusion_violation (23P01); got code=${code ?? "<none>"}; full error:\n${JSON.stringify(
e,
Object.getOwnPropertyNames(e as object),
2,
)}`,
);
}
};
type BookingArgs = {
therapistId?: string;
roomId?: string;
customerId?: string;
serviceId?: string;
startsAt: Date;
endsAt: Date;
roomReleasedAt?: Date;
status?: "HOLD" | "CONFIRMED" | "COMPLETED" | "NO_SHOW" | "CANCELLED";
};
const insert = (a: BookingArgs) =>
db.booking.create({
data: {
customerId: a.customerId ?? customerId,
therapistId: a.therapistId ?? therapistA,
roomId: a.roomId ?? roomA,
serviceId: a.serviceId ?? serviceId,
startsAt: a.startsAt,
endsAt: a.endsAt,
roomReleasedAt: a.roomReleasedAt ?? a.endsAt,
status: a.status ?? "HOLD",
},
});
describe("Booking exclusion constraints", () => {
test("non-overlapping bookings on the same therapist succeed", async () => {
await insert({
startsAt: D("2026-05-01T10:00:00Z"),
endsAt: D("2026-05-01T11:00:00Z"),
});
await expect(
insert({
roomId: roomB,
startsAt: D("2026-05-01T11:00:00Z"),
endsAt: D("2026-05-01T12:00:00Z"),
}),
).resolves.toBeTruthy();
});
test("overlapping bookings on the same therapist are rejected by the DB", async () => {
await insert({
startsAt: D("2026-05-01T10:00:00Z"),
endsAt: D("2026-05-01T11:00:00Z"),
});
try {
await insert({
roomId: roomB, // different room — only therapist conflict
startsAt: D("2026-05-01T10:30:00Z"),
endsAt: D("2026-05-01T11:30:00Z"),
});
throw new Error("expected exclusion_violation");
} catch (e) {
expectExclusionViolation(e);
}
});
test("two therapists, same time slot, different rooms — both succeed", async () => {
await insert({
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-01T10:00:00Z"),
endsAt: D("2026-05-01T11:00:00Z"),
});
await expect(
insert({
therapistId: therapistB,
roomId: roomB,
startsAt: D("2026-05-01T10:00:00Z"),
endsAt: D("2026-05-01T11:00:00Z"),
}),
).resolves.toBeTruthy();
});
test("room buffer is enforced — second booking inside the buffer window is rejected", async () => {
await insert({
startsAt: D("2026-05-01T10:00:00Z"),
endsAt: D("2026-05-01T11:00:00Z"),
roomReleasedAt: D("2026-05-01T11:15:00Z"),
});
try {
await insert({
therapistId: therapistB, // different therapist; only room conflicts
startsAt: D("2026-05-01T11:10:00Z"),
endsAt: D("2026-05-01T12:10:00Z"),
roomReleasedAt: D("2026-05-01T12:25:00Z"),
});
throw new Error("expected exclusion_violation");
} catch (e) {
expectExclusionViolation(e);
}
});
test("a booking starting exactly when the room is released is allowed", async () => {
await insert({
startsAt: D("2026-05-01T10:00:00Z"),
endsAt: D("2026-05-01T11:00:00Z"),
roomReleasedAt: D("2026-05-01T11:15:00Z"),
});
await expect(
insert({
therapistId: therapistB,
startsAt: D("2026-05-01T11:15:00Z"),
endsAt: D("2026-05-01T12:15:00Z"),
roomReleasedAt: D("2026-05-01T12:30:00Z"),
}),
).resolves.toBeTruthy();
});
test("CANCELLED bookings do not block new bookings on the same slot", async () => {
await insert({
status: "CANCELLED",
startsAt: D("2026-05-01T10:00:00Z"),
endsAt: D("2026-05-01T11:00:00Z"),
});
await expect(
insert({
startsAt: D("2026-05-01T10:00:00Z"),
endsAt: D("2026-05-01T11:00:00Z"),
}),
).resolves.toBeTruthy();
});
test("HOLD blocks CONFIRMED in the same slot — race-condition safety", async () => {
await insert({
status: "HOLD",
startsAt: D("2026-05-01T10:00:00Z"),
endsAt: D("2026-05-01T11:00:00Z"),
});
try {
await insert({
status: "CONFIRMED",
startsAt: D("2026-05-01T10:00:00Z"),
endsAt: D("2026-05-01T11:00:00Z"),
});
throw new Error("expected exclusion_violation");
} catch (e) {
expectExclusionViolation(e);
}
});
});
describe("Booking foreign-key constraints", () => {
test("inserting a booking with a non-existent therapistId fails", async () => {
await expect(
insert({
therapistId: "no-such-therapist",
startsAt: D("2026-05-02T10:00:00Z"),
endsAt: D("2026-05-02T11:00:00Z"),
}),
).rejects.toThrow();
});
test("inserting a booking with a non-existent roomId fails", async () => {
await expect(
insert({
roomId: "no-such-room",
startsAt: D("2026-05-02T10:00:00Z"),
endsAt: D("2026-05-02T11:00:00Z"),
}),
).rejects.toThrow();
});
test("inserting a booking with a non-existent customerId fails", async () => {
await expect(
insert({
customerId: "no-such-customer",
startsAt: D("2026-05-02T10:00:00Z"),
endsAt: D("2026-05-02T11:00:00Z"),
}),
).rejects.toThrow();
});
});

10
test/setup.ts Normal file
View File

@@ -0,0 +1,10 @@
import { config } from "dotenv";
import path from "node:path";
config({ path: path.resolve(process.cwd(), ".env.test"), override: true });
if (!process.env.DATABASE_URL?.includes("touchbase_test")) {
throw new Error(
`Refusing to run tests against non-test database. DATABASE_URL=${process.env.DATABASE_URL}`,
);
}

18
vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
test: {
environment: "node",
setupFiles: ["./test/setup.ts"],
env: { NODE_ENV: "test" },
globals: false,
include: ["test/**/*.test.ts", "src/**/*.test.ts"],
pool: "forks",
fileParallelism: false,
sequence: { concurrent: false },
},
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
});