Files
dyolink/backend/prisma/schema.prisma

540 lines
16 KiB
Plaintext
Raw Normal View History

// 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[]
assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee")
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[]
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 {
PENDING
IN_PROGRESS
COMPLETED
}
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)
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?
labComment String?
sentAt DateTime?
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
details LabCaseDetail[]
sends LabCaseSend[]
tasks LabCaseTask[]
toothProsthesis LabCaseToothProsthesis[]
@@index([treatmentId, sortOrder])
@@map("lab_cases")
}
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)
@@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
tooth String
treatmentType String
prosthesisTypeCode String
workflowStepCode String
stepOrder Int
stepLabel String
assigneeUserId String?
assignedAt DateTime?
priority Int @default(3)
status LabTaskStatus @default(PENDING)
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([labCaseId, treatmentDetailId, tooth, stepOrder])
@@index([labCaseId, status])
@@index([assigneeUserId, priority, createdAt])
@@index([assignedAt, labCaseId, priority])
@@map("lab_case_tasks")
}
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
}