added availability
This commit is contained in:
62
prisma/migrations/20260430170142_booking_spike/migration.sql
Normal file
62
prisma/migrations/20260430170142_booking_spike/migration.sql
Normal 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'));
|
||||
|
||||
291
prisma/migrations/20260430172029_full_schema/migration.sql
Normal file
291
prisma/migrations/20260430172029_full_schema/migration.sql
Normal 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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
318
prisma/schema.prisma
Normal 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
23
prisma/seed.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user