598 lines
18 KiB
Plaintext
598 lines
18 KiB
Plaintext
// backend/prisma/schema.prisma
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(uuid())
|
|
email String @unique
|
|
passwordHash String?
|
|
googleId String? @unique
|
|
facebookId String? @unique
|
|
name String
|
|
language String @default("en")
|
|
trialUsedAt DateTime?
|
|
|
|
memberships Membership[]
|
|
ownedOrganizations Organization[] @relation("OrganizationOwner")
|
|
sessions Session[] // 👈 ADD THIS - opposite relation for Session
|
|
sentStaffInvites StaffInvitation[]
|
|
sentOrganizationInvitations OrganizationInvitation[]
|
|
statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy")
|
|
labCaseTaskStatusEvents LabCaseTaskStatusEvent[]
|
|
labCaseComments LabCaseComment[]
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@map("users")
|
|
}
|
|
|
|
model OrganizationType {
|
|
id String @id @default(uuid())
|
|
name String @unique // "CLINIC" or "LAB"
|
|
|
|
organizations Organization[]
|
|
features Feature[] // 👈 ADD THIS - opposite relation for Feature
|
|
|
|
@@map("organization_types")
|
|
}
|
|
|
|
model Organization {
|
|
id String @id @default(uuid())
|
|
name String
|
|
email String @unique
|
|
phone String?
|
|
address String?
|
|
|
|
typeId String
|
|
type OrganizationType @relation(fields: [typeId], references: [id])
|
|
|
|
ownerId String
|
|
owner User @relation("OrganizationOwner", fields: [ownerId], references: [id])
|
|
|
|
memberships Membership[]
|
|
planId String?
|
|
plan Plan? @relation(fields: [planId], references: [id])
|
|
|
|
sharedWithMe OrganizationLink[] @relation("OrganizationB")
|
|
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
|
|
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
|
|
createdPatients Patient[] @relation("PatientCreatedBy")
|
|
appointments Appointment[]
|
|
treatments Treatment[]
|
|
labCaseSends LabCaseSend[]
|
|
labCaseComments LabCaseComment[]
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@map("organizations")
|
|
}
|
|
|
|
model Patient {
|
|
id String @id @default(uuid())
|
|
firstName String
|
|
lastName String
|
|
mobile String @unique
|
|
email String?
|
|
dateOfBirth DateTime?
|
|
notes String?
|
|
isActive Boolean @default(true)
|
|
createdByOrganizationId String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
createdByOrganization Organization? @relation("PatientCreatedBy", fields: [createdByOrganizationId], references: [id], onDelete: SetNull)
|
|
treatments Treatment[]
|
|
appointments Appointment[]
|
|
|
|
@@index([lastName, firstName])
|
|
@@map("patients")
|
|
}
|
|
|
|
model Appointment {
|
|
id String @id @default(uuid())
|
|
organizationId String
|
|
patientId String
|
|
providerUserId String
|
|
startAt DateTime
|
|
endAt DateTime
|
|
purpose String
|
|
|
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
|
treatment Treatment?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([organizationId, providerUserId, startAt])
|
|
@@index([organizationId, startAt])
|
|
@@map("appointments")
|
|
}
|
|
|
|
enum LabTaskStatus {
|
|
IN_PROGRESS
|
|
COMPLETED
|
|
}
|
|
|
|
enum LabCaseCommentSide {
|
|
LAB
|
|
CLINIC
|
|
}
|
|
|
|
model Treatment {
|
|
id String @id @default(uuid())
|
|
organizationId String
|
|
patientId String
|
|
appointmentId String? @unique
|
|
providerUserId String
|
|
title String
|
|
treatmentAt DateTime
|
|
|
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
|
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
|
|
details TreatmentDetail[]
|
|
labCases LabCase[]
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([patientId, treatmentAt])
|
|
@@map("treatments")
|
|
}
|
|
|
|
model TreatmentDetail {
|
|
id String @id @default(uuid())
|
|
treatmentId String
|
|
clientKey String?
|
|
sortOrder Int
|
|
treatmentType String
|
|
teeth Json
|
|
comment String?
|
|
|
|
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
|
attachments TreatmentDetailAttachment[]
|
|
labCaseLink LabCaseDetail?
|
|
labCaseTasks LabCaseTask[]
|
|
toothProsthesis LabCaseToothProsthesis[]
|
|
|
|
@@index([treatmentId, sortOrder])
|
|
@@map("treatment_details")
|
|
}
|
|
|
|
model TreatmentDetailAttachment {
|
|
id String @id @default(uuid())
|
|
detailId String?
|
|
appointmentId String?
|
|
detailClientKey String?
|
|
fileName String
|
|
mimeType String
|
|
sizeBytes Int
|
|
storagePath String
|
|
|
|
detail TreatmentDetail? @relation(fields: [detailId], references: [id], onDelete: Cascade)
|
|
labCaseLinks LabCaseAttachment[]
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([appointmentId, detailClientKey])
|
|
@@index([detailId])
|
|
@@map("treatment_detail_attachments")
|
|
}
|
|
|
|
model LabCase {
|
|
id String @id @default(uuid())
|
|
treatmentId String
|
|
clientKey String?
|
|
sortOrder Int
|
|
destinationOrganizationId String?
|
|
sentAt DateTime?
|
|
|
|
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
|
details LabCaseDetail[]
|
|
sends LabCaseSend[]
|
|
tasks LabCaseTask[]
|
|
toothProsthesis LabCaseToothProsthesis[]
|
|
comments LabCaseComment[]
|
|
attachments LabCaseAttachment[]
|
|
|
|
@@index([treatmentId, sortOrder])
|
|
@@map("lab_cases")
|
|
}
|
|
|
|
model LabCaseAttachment {
|
|
labCaseId String
|
|
attachmentId String
|
|
|
|
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
|
attachment TreatmentDetailAttachment @relation(fields: [attachmentId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([labCaseId, attachmentId])
|
|
@@index([attachmentId])
|
|
@@map("lab_case_attachments")
|
|
}
|
|
|
|
model LabCaseDetail {
|
|
labCaseId String
|
|
treatmentDetailId String @unique
|
|
|
|
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
|
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([labCaseId, treatmentDetailId])
|
|
@@map("lab_case_details")
|
|
}
|
|
|
|
model LabCaseSend {
|
|
id String @id @default(uuid())
|
|
labCaseId String
|
|
organizationId String
|
|
sentAt DateTime @default(now())
|
|
|
|
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([labCaseId, organizationId])
|
|
@@map("lab_case_sends")
|
|
}
|
|
|
|
model TreatmentType {
|
|
id String @id @default(uuid())
|
|
code String @unique
|
|
labDependent Boolean @default(false)
|
|
sortOrder Int @default(0)
|
|
isActive Boolean @default(true)
|
|
availableInAppointments Boolean @default(true)
|
|
availableInTreatment Boolean @default(true)
|
|
|
|
@@map("treatment_types")
|
|
}
|
|
|
|
enum CatalogEntityKind {
|
|
TREATMENT_TYPE
|
|
PROSTHESIS_TYPE
|
|
LAB_WORKFLOW_STEP
|
|
}
|
|
|
|
model CatalogTranslation {
|
|
id String @id @default(uuid())
|
|
entityKind CatalogEntityKind
|
|
entityCode String
|
|
locale String
|
|
label String
|
|
|
|
@@unique([entityKind, entityCode, locale])
|
|
@@map("catalog_translations")
|
|
}
|
|
|
|
model ProsthesisType {
|
|
id String @id @default(uuid())
|
|
code String @unique
|
|
sortOrder Int @default(0)
|
|
isActive Boolean @default(true)
|
|
skipPackingShipping Boolean @default(false)
|
|
|
|
steps ProsthesisTypeStep[]
|
|
|
|
@@map("prosthesis_types")
|
|
}
|
|
|
|
model LabWorkflowStep {
|
|
id String @id @default(uuid())
|
|
code String @unique
|
|
sortOrder Int @default(0)
|
|
|
|
prosthesisSteps ProsthesisTypeStep[]
|
|
|
|
@@map("lab_workflow_steps")
|
|
}
|
|
|
|
model ProsthesisTypeStep {
|
|
id String @id @default(uuid())
|
|
prosthesisTypeId String
|
|
labWorkflowStepId String
|
|
stepOrder Int
|
|
|
|
prosthesisType ProsthesisType @relation(fields: [prosthesisTypeId], references: [id], onDelete: Cascade)
|
|
labWorkflowStep LabWorkflowStep @relation(fields: [labWorkflowStepId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([prosthesisTypeId, stepOrder])
|
|
@@unique([prosthesisTypeId, labWorkflowStepId])
|
|
@@map("prosthesis_type_steps")
|
|
}
|
|
|
|
model LabCaseToothProsthesis {
|
|
id String @id @default(uuid())
|
|
labCaseId String
|
|
treatmentDetailId String
|
|
tooth String
|
|
prosthesisTypeCode String
|
|
|
|
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
|
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([labCaseId, treatmentDetailId, tooth])
|
|
@@map("lab_case_tooth_prosthesis")
|
|
}
|
|
|
|
model LabCaseTask {
|
|
id String @id @default(uuid())
|
|
labCaseId String
|
|
treatmentDetailId String
|
|
teeth Json
|
|
treatmentType String
|
|
prosthesisTypeCode String
|
|
workflowStepCode String
|
|
stepOrder Int
|
|
stepLabel String
|
|
isImportant Boolean @default(false)
|
|
status LabTaskStatus @default(IN_PROGRESS)
|
|
lastStatusChangedByUserId String?
|
|
lastStatusChangedAt DateTime?
|
|
|
|
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
|
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
|
|
lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull)
|
|
statusEvents LabCaseTaskStatusEvent[]
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
|
|
@@index([labCaseId, status])
|
|
@@index([labCaseId, isImportant])
|
|
@@map("lab_case_tasks")
|
|
}
|
|
|
|
model LabCaseTaskStatusEvent {
|
|
id String @id @default(uuid())
|
|
taskId String
|
|
fromStatus LabTaskStatus?
|
|
toStatus LabTaskStatus
|
|
changedByUserId String?
|
|
changedAt DateTime @default(now())
|
|
|
|
task LabCaseTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
|
changedBy User? @relation(fields: [changedByUserId], references: [id], onDelete: SetNull)
|
|
|
|
@@index([taskId, changedAt])
|
|
@@map("lab_case_task_status_events")
|
|
}
|
|
|
|
model LabCaseComment {
|
|
id String @id @default(uuid())
|
|
labCaseId String
|
|
authorUserId String?
|
|
authorOrganizationId String?
|
|
authorSide LabCaseCommentSide
|
|
body String
|
|
visibleToClinic Boolean @default(false)
|
|
|
|
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
|
authorUser User? @relation(fields: [authorUserId], references: [id], onDelete: SetNull)
|
|
authorOrganization Organization? @relation(fields: [authorOrganizationId], references: [id], onDelete: SetNull)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([labCaseId, createdAt])
|
|
@@map("lab_case_comments")
|
|
}
|
|
|
|
model Plan {
|
|
id String @id @default(uuid())
|
|
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
|
|
maxUsers Int // 1, 5, 10, 15, 999999 for unlimited
|
|
price Float
|
|
features Json // Store feature flags as JSON
|
|
|
|
organizations Organization[]
|
|
|
|
@@map("plans")
|
|
}
|
|
|
|
model Membership {
|
|
id String @id @default(uuid())
|
|
|
|
userId String
|
|
organizationId String
|
|
|
|
isOwner Boolean @default(false)
|
|
isActive Boolean @default(true)
|
|
|
|
user User @relation(fields: [userId], references: [id])
|
|
organization Organization @relation(fields: [organizationId], references: [id])
|
|
|
|
permissions MembershipPermission[]
|
|
invitations StaffInvitation[]
|
|
workingHoursSchedule StaffWorkingHoursSchedule?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([userId, organizationId])
|
|
@@map("memberships")
|
|
}
|
|
|
|
model StaffWorkingHoursSchedule {
|
|
id String @id @default(uuid())
|
|
membershipId String @unique
|
|
autoRepeatWeekly Boolean @default(true)
|
|
|
|
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
|
|
blocks StaffWorkingHoursBlock[]
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@map("staff_working_hours_schedules")
|
|
}
|
|
|
|
model StaffWorkingHoursBlock {
|
|
id String @id @default(uuid())
|
|
scheduleId String
|
|
dayOfWeek Int
|
|
startMinute Int
|
|
endMinute Int
|
|
sortOrder Int @default(0)
|
|
|
|
schedule StaffWorkingHoursSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([scheduleId, dayOfWeek, sortOrder])
|
|
@@map("staff_working_hours_blocks")
|
|
}
|
|
|
|
model StaffInvitation {
|
|
id String @id @default(uuid())
|
|
|
|
membershipId String
|
|
invitedById String
|
|
tokenHash String @unique
|
|
expiresAt DateTime
|
|
acceptedAt DateTime?
|
|
revokedAt DateTime?
|
|
|
|
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
|
|
invitedBy User @relation(fields: [invitedById], references: [id], onDelete: Cascade)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([membershipId, createdAt])
|
|
@@map("staff_invitations")
|
|
}
|
|
|
|
model Permission {
|
|
id String @id @default(uuid())
|
|
name String @unique
|
|
description String?
|
|
|
|
memberships MembershipPermission[]
|
|
featureId String?
|
|
feature Feature? @relation(fields: [featureId], references: [id])
|
|
|
|
@@map("permissions")
|
|
}
|
|
|
|
model MembershipPermission {
|
|
membershipId String
|
|
permissionId String
|
|
|
|
membership Membership @relation(fields: [membershipId], references: [id])
|
|
permission Permission @relation(fields: [permissionId], references: [id])
|
|
|
|
@@id([membershipId, permissionId])
|
|
@@map("membership_permissions")
|
|
}
|
|
|
|
model Feature {
|
|
id String @id @default(uuid())
|
|
name String @unique
|
|
description String?
|
|
|
|
permissions Permission[]
|
|
organizationTypeId String?
|
|
organizationType OrganizationType? @relation(fields: [organizationTypeId], references: [id])
|
|
|
|
@@map("features")
|
|
}
|
|
|
|
/// Bidirectional clinic↔lab relationship. One row per unordered pair (A id < B id).
|
|
///
|
|
/// Two product flows share this table:
|
|
/// 1. **Connection request** — inviter found an existing subscribed org in search; row is PENDING
|
|
/// until the counterpart accepts. No OrganizationInvitation row.
|
|
/// 2. **Invitation link** — inviter could not find the org; inviteOrganization() creates a
|
|
/// placeholder org, an OrganizationInvitation (signup token), and a PENDING link here so the
|
|
/// inviter does not need a second request after signup. acceptInvite() sets the link to ACTIVE.
|
|
///
|
|
/// `sharedDataTypes` stores metadata (not shared clinical data yet). While PENDING, entries like
|
|
/// `requested_by:{orgId}` record who initiated the request (see OrganizationService).
|
|
model OrganizationLink {
|
|
id String @id @default(uuid())
|
|
|
|
organizationAId String
|
|
organizationBId String
|
|
status LinkStatus @default(PENDING)
|
|
sharedDataTypes Json
|
|
|
|
organizationA Organization @relation("OrganizationA", fields: [organizationAId], references: [id])
|
|
organizationB Organization @relation("OrganizationB", fields: [organizationBId], references: [id])
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([organizationAId, organizationBId])
|
|
@@map("organization_links")
|
|
}
|
|
|
|
/// Signup invite for a counterpart org that is not on DyoLink yet (or has no active subscription).
|
|
/// Complements OrganizationLink: invite flow always creates both records in one transaction.
|
|
///
|
|
/// Only the token *hash* is stored; the plain token is returned once on create/regenerate and may
|
|
/// be cached in the browser (see frontend useOrganizationInviteLinkCopy). Regenerating rotates
|
|
/// tokenHash and expiresAt on the same invitation row.
|
|
///
|
|
/// `invitedOrganizationId` points at a placeholder Organization (pending-* email) until accept;
|
|
/// list() joins open invitations to links so the UI can offer "copy invitation link" on the
|
|
/// pending connection row (pendingInvitationId on the API response).
|
|
model OrganizationInvitation {
|
|
id String @id @default(uuid())
|
|
|
|
inviterOrganizationId String
|
|
inviterUserId String
|
|
|
|
invitedOrganizationId String?
|
|
invitedOrganizationName String
|
|
invitedOwnerEmail String
|
|
invitedOrganizationType String
|
|
tokenHash String @unique
|
|
expiresAt DateTime
|
|
acceptedAt DateTime?
|
|
revokedAt DateTime?
|
|
|
|
inviterOrganization Organization @relation("OrganizationInvitationInviter", fields: [inviterOrganizationId], references: [id], onDelete: Cascade)
|
|
inviterUser User @relation(fields: [inviterUserId], references: [id], onDelete: Cascade)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([inviterOrganizationId, createdAt])
|
|
@@index([invitedOwnerEmail, createdAt])
|
|
@@map("organization_invitations")
|
|
}
|
|
|
|
model Session {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
token String @unique
|
|
refreshToken String? @unique
|
|
expiresAt DateTime
|
|
userAgent String?
|
|
ipAddress String?
|
|
|
|
user User @relation(fields: [userId], references: [id])
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@map("sessions")
|
|
}
|
|
|
|
/// OrganizationLink lifecycle. Invitation rows use overlapping semantics in API mappers
|
|
/// (e.g. accepted invitation → ACTIVE in listInvitationHistory).
|
|
enum LinkStatus {
|
|
PENDING
|
|
ACTIVE
|
|
REJECTED
|
|
BLOCKED
|
|
}
|