// 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 trialUsedAt DateTime? memberships Membership[] ownedOrganizations Organization[] @relation("OrganizationOwner") sessions Session[] // ๐Ÿ‘ˆ ADD THIS - opposite relation for Session sentStaffInvites StaffInvitation[] sentOrganizationInvitations OrganizationInvitation[] 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") patients Patient[] appointments Appointment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("organizations") } model Patient { id String @id @default(uuid()) organizationId String firstName String lastName String phone String? email String? dateOfBirth DateTime? notes String? isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt organization Organization @relation(fields: [organizationId], references: [id]) treatments PatientTreatmentHistory[] appointments Appointment[] @@index([organizationId, createdAt]) @@index([organizationId, lastName, firstName]) @@map("patients") } model PatientTreatmentHistory { id String @id @default(uuid()) patientId String title String status String treatmentAt DateTime tooth String? notes String? totalCost Float? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) @@index([patientId, treatmentAt]) @@map("patient_treatment_histories") } 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) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([organizationId, providerUserId, startAt]) @@index([organizationId, startAt]) @@map("appointments") } 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[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([userId, organizationId]) @@map("memberships") } 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 }