Files
touchbase/prisma/schema.prisma

329 lines
9.8 KiB
Plaintext
Raw Normal View History

2026-05-01 18:24:09 -04:00
// 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? // Nullable — Auth.js magic-link signup creates users without a name; captured later at first booking
image String? // Auth.js convention; unused for now but adapter expects it
2026-05-01 18:24:09 -04:00
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])
}
// Auth.js magic-link state. JWT sessions, so no Account or Session tables needed.
model VerificationToken {
identifier String
token String
expires DateTime @db.Timestamptz(3)
@@id([identifier, token])
}
2026-05-01 18:24:09 -04:00
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])
}