// 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 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]) } 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]) }