319 lines
9.4 KiB
Plaintext
319 lines
9.4 KiB
Plaintext
|
|
// 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])
|
||
|
|
}
|