// 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 mobile 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") assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee") labCaseTaskStatusEvents LabCaseTaskStatusEvent[] labCaseComments LabCaseComment[] labCaseActivities LabCaseActivity[] phoneVerificationCodes PhoneVerificationCode[] userNotifications UserNotification[] actedUserNotifications UserNotification[] @relation("UserNotificationActor") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("users") } model PhoneVerificationCode { id String @id @default(uuid()) userId String? mobile String codeHash String purpose String expiresAt DateTime verifiedAt DateTime? createdAt DateTime @default(now()) user User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([mobile, purpose, createdAt]) @@map("phone_verification_codes") } 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[] partnerLabCases LabCase[] @relation("LabCasePartnerClinic") 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) isWalkIn Boolean @default(false) 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]) @@index([createdByOrganizationId, isWalkIn]) @@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 } enum LabCaseOrigin { CLINIC_DISPATCH LAB_INTERNAL } 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]) @@index([organizationId, providerUserId, treatmentAt]) @@map("treatments") } model TreatmentDetail { id String @id @default(uuid()) treatmentId String clientKey String? sortOrder Int treatmentType String teeth Json /** Selection groups: connected spans + singles. Shape: { groupId, kind, teeth[] }[] */ toothSelectionGroups 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? treatmentId 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([treatmentId, detailClientKey]) @@index([detailId]) @@map("treatment_detail_attachments") } model LabCase { id String @id @default(uuid()) treatmentId String? clientKey String? sortOrder Int @default(0) destinationOrganizationId String? sentAt DateTime? startedAt DateTime? dueDate DateTime? isImportant Boolean @default(false) origin LabCaseOrigin @default(CLINIC_DISPATCH) /// Optional code from an external lab app (e.g. exocad) for print/matching. externalCode String? accessToken String? @unique referringClinicName String? referringDentistName String? patientDisplayName String? patientDisplayMobile String? partnerClinicOrganizationId String? treatment Treatment? @relation(fields: [treatmentId], references: [id], onDelete: Cascade) partnerClinic Organization? @relation("LabCasePartnerClinic", fields: [partnerClinicOrganizationId], references: [id], onDelete: SetNull) details LabCaseDetail[] lines LabCaseLine[] sends LabCaseSend[] tasks LabCaseTask[] toothProsthesis LabCaseToothProsthesis[] comments LabCaseComment[] attachments LabCaseAttachment[] activities LabCaseActivity[] userReadStates LabCaseUserReadState[] @@index([treatmentId, sortOrder]) @@index([destinationOrganizationId, origin]) @@index([partnerClinicOrganizationId]) @@map("lab_cases") } /// Lab-origin work lines (analog of TreatmentDetail). Clinic-sent cases do not use this table. model LabCaseLine { id String @id @default(uuid()) labCaseId String clientKey String? sortOrder Int treatmentType String @default("prosthesis") teeth Json toothSelectionGroups Json? comment String? labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) toothProsthesis LabCaseToothProsthesis[] tasks LabCaseTask[] @@index([labCaseId, sortOrder]) @@map("lab_case_lines") } 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 @unique 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? lineId String? /// treatmentDetailId (clinic) or lineId (lab-origin) β€” required unique grouping key. sourceKey String tooth String prosthesisTypeCode String treatmentType String @default("prosthesis") /** Links to TreatmentDetail/LabCaseLine toothSelectionGroups[].groupId for task grouping. */ selectionGroupId String @default("") labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) detail TreatmentDetail? @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) line LabCaseLine? @relation(fields: [lineId], references: [id], onDelete: Cascade) @@unique([labCaseId, sourceKey, tooth]) @@map("lab_case_tooth_prosthesis") } model LabCaseTask { id String @id @default(uuid()) labCaseId String treatmentDetailId String? lineId String? /// treatmentDetailId (clinic) or lineId (lab-origin) β€” required unique grouping key. sourceKey String teeth Json treatmentType String prosthesisTypeCode String /** Empty string for legacy tasks; otherwise matches selection group id. */ selectionGroupId String @default("") workflowStepCode String stepOrder Int stepLabel String status LabTaskStatus @default(IN_PROGRESS) assigneeUserId String? assignedAt DateTime? lastStatusChangedByUserId String? lastStatusChangedAt DateTime? labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) detail TreatmentDetail? @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) line LabCaseLine? @relation(fields: [lineId], references: [id], onDelete: Cascade) assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull) lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull) statusEvents LabCaseTaskStatusEvent[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([labCaseId, sourceKey, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_source_prosthesis_step_key") @@index([labCaseId, status]) @@index([assigneeUserId]) @@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") } enum LabCaseActivityType { CASE_SENT CLINIC_COMMENT LAB_COMMENT CASE_IMPORTANT CASE_AMENDED TASK_COMPLETED TASK_ASSIGNED } enum LabCaseTabReadTarget { CASES TASKS TREATMENT } enum UserNotificationType { CASE_SENT CLINIC_COMMENT LAB_COMMENT LAB_COMMENT_CLINIC CASE_IMPORTANT TASK_COMPLETED TASK_ASSIGNED CONNECTION_REQUEST STAFF_INVITE } /// Fan-out inbox row per recipient. Independent of LabCaseActivity tab badges. model UserNotification { id String @id @default(uuid()) userId String organizationId String type UserNotificationType actorUserId String? payload Json? href String readAt DateTime? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) actorUser User? @relation("UserNotificationActor", fields: [actorUserId], references: [id], onDelete: SetNull) @@index([userId, organizationId, createdAt]) @@index([userId, organizationId, readAt]) @@map("user_notifications") } model LabCaseActivity { id String @id @default(uuid()) labCaseId String type LabCaseActivityType actorUserId String? payload Json? createdAt DateTime @default(now()) labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) actorUser User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull) @@index([labCaseId, createdAt]) @@index([type, createdAt]) @@map("lab_case_activities") } model LabCaseUserReadState { userId String organizationId String labCaseId String lastReadAt DateTime labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) @@id([userId, organizationId, labCaseId]) @@index([userId, organizationId]) @@map("lab_case_user_read_states") } model LabCaseUserTabReadState { userId String organizationId String tab LabCaseTabReadTarget lastReadAt DateTime @@id([userId, organizationId, tab]) @@map("lab_case_user_tab_read_states") } 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 }