Files
nextstep/prisma/schema.prisma
Tony0410 f0f674945c feat: implement all 8 new health management features
This commit implements all features specified in the eight-features design doc:

Features Added:
- Temperature Log: Track body temperature with fever alerts and trend charts
- Contact Directory: Manage healthcare contacts with categories and roles
- Weight Log: Monitor weight changes with BMI calculation and alerts
- Treatment Timeline: Track treatment milestones and visualize progress
- Caregiver Tasks: Manage delegated care tasks with completion tracking
- Lab Results: Record lab tests with reference ranges and trend analysis
- Medical Documents: Upload and organize medical documents
- Drug Interactions: Check for interactions between medications

Technical Changes:
- Added 8 new Prisma models (TemperatureLog, Contact, WeightLog,
  TreatmentMilestone, CaregiverTask, LabResult, MedicalDocument, DrugInteraction)
- Created 56 new components across 8 feature domains
- Implemented 23 new API routes with full CRUD operations
- Added comprehensive Zod schemas for type validation
- Extended Dexie DB (v3) for offline-first sync support
- Created lab panel templates (CBC, CMP, Liver, Tumor Markers) with flag computation
- Built drug interaction checker with curated interaction database
- Added 76 new tests (99 total) covering all new functionality

Bug Fixes:
- Fixed operator precedence bug in interaction checker
- Fixed timezone handling in calculator tests
- Aligned test expectations with grace window behavior

All 99 tests pass and build completes successfully.
2026-03-02 11:17:38 +00:00

718 lines
22 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// USER & AUTHENTICATION
// ============================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
forcePasswordReset Boolean @default(false)
// Relations
sessions Session[]
workspaceMembers WorkspaceMember[]
createdAppointments Appointment[] @relation("AppointmentCreatedBy")
updatedAppointments Appointment[] @relation("AppointmentUpdatedBy")
createdMedications Medication[] @relation("MedicationCreatedBy")
updatedMedications Medication[] @relation("MedicationUpdatedBy")
createdNotes Note[] @relation("NoteCreatedBy")
updatedNotes Note[] @relation("NoteUpdatedBy")
loggedDoses DoseLog[] @relation("DoseLoggedBy")
undoneDoses DoseLog[] @relation("DoseUndoneBy")
auditLogs AuditLog[]
symptoms Symptom[]
pushSubscriptions PushSubscription[]
// New feature relations
temperatureLogs TemperatureLog[] @relation("TempLogCreatedBy")
createdContacts Contact[] @relation("ContactCreatedBy")
updatedContacts Contact[] @relation("ContactUpdatedBy")
weightLogs WeightLog[] @relation("WeightLogCreatedBy")
createdMilestones TreatmentMilestone[] @relation("MilestoneCreatedBy")
updatedMilestones TreatmentMilestone[] @relation("MilestoneUpdatedBy")
createdTasks CaregiverTask[] @relation("TaskCreatedBy")
updatedTasks CaregiverTask[] @relation("TaskUpdatedBy")
assignedTasks CaregiverTask[] @relation("TaskAssignedTo")
completedTasks CaregiverTask[] @relation("TaskCompletedBy")
createdLabResults LabResult[] @relation("LabResultCreatedBy")
updatedLabResults LabResult[] @relation("LabResultUpdatedBy")
createdDocuments MedicalDocument[] @relation("DocCreatedBy")
@@index([email])
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
userAgent String?
ipAddress String?
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
@@index([expiresAt])
}
model LoginAttempt {
id String @id @default(cuid())
email String
ipAddress String?
success Boolean
createdAt DateTime @default(now())
@@index([email, createdAt])
@@index([ipAddress, createdAt])
}
// ============================================
// WORKSPACE & SHARING
// ============================================
model Workspace {
id String @id @default(cuid())
name String // e.g., "Grace's Plan"
clinicPhone String?
emergencyPhone String?
quietHoursStart String? // HH:mm format
quietHoursEnd String? // HH:mm format
largeTextMode Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Emergency info fields
patientName String?
patientDOB DateTime?
bloodType String?
allergies String? // Comma-separated or free text
medicalConditions String? // Comma-separated or free text
primaryPhysician String?
physicianPhone String?
// Relations
members WorkspaceMember[]
inviteTokens InviteToken[]
appointments Appointment[]
medications Medication[]
notes Note[]
doseLogs DoseLog[]
auditLogs AuditLog[]
syncCursors SyncCursor[]
symptoms Symptom[]
appointmentChecklists AppointmentChecklist[]
pushSubscriptions PushSubscription[]
// New feature relations
temperatureLogs TemperatureLog[]
contacts Contact[]
weightLogs WeightLog[]
milestones TreatmentMilestone[]
caregiverTasks CaregiverTask[]
labResults LabResult[]
medicalDocuments MedicalDocument[]
drugInteractions DrugInteraction[]
@@index([name])
}
enum WorkspaceRole {
OWNER
EDITOR
VIEWER
}
model WorkspaceMember {
id String @id @default(cuid())
workspaceId String
userId String
role WorkspaceRole @default(VIEWER)
createdAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([workspaceId, userId])
@@index([userId])
}
model InviteToken {
id String @id @default(cuid())
workspaceId String
token String @unique
role WorkspaceRole @default(VIEWER)
expiresAt DateTime
usedAt DateTime?
usedById String?
createdAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([token])
@@index([workspaceId])
}
// ============================================
// APPOINTMENTS
// ============================================
model Appointment {
id String @id @default(cuid())
workspaceId String
title String
datetime DateTime
location String?
mapUrl String?
notes String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync tracking
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("AppointmentCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("AppointmentUpdatedBy", fields: [updatedById], references: [id])
checklists AppointmentChecklist[]
@@index([workspaceId, datetime])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// MEDICATIONS
// ============================================
enum ScheduleType {
FIXED_TIMES // e.g., 08:00, 20:00 daily
INTERVAL // every X hours
WEEKDAYS // specific weekdays at a time
PRN // as needed with min hours between
}
model Medication {
id String @id @default(cuid())
workspaceId String
name String
instructions String?
scheduleType ScheduleType
scheduleData Json // Flexible storage for schedule details
startDate DateTime?
endDate DateTime?
active Boolean @default(true)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Refill tracking fields
pillCount Int?
pillsPerDose Int? @default(1)
refillThreshold Int? @default(7)
lastRefillDate DateTime?
// Sync tracking
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("MedicationCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("MedicationUpdatedBy", fields: [updatedById], references: [id])
doseLogs DoseLog[]
// Drug interaction relations
interactions1 DrugInteraction[] @relation("Interaction1")
interactions2 DrugInteraction[] @relation("Interaction2")
@@index([workspaceId, active])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// Schedule data shapes (stored as JSON):
// FIXED_TIMES: { times: ["08:00", "20:00"] }
// INTERVAL: { hours: 8, startTime: "08:00" }
// WEEKDAYS: { days: [1, 3, 5], time: "09:00" } // Monday, Wednesday, Friday
// PRN: { minHoursBetween: 4 }
model DoseLog {
id String @id @default(cuid())
medicationId String
workspaceId String
takenAt DateTime
loggedById String
undoneAt DateTime?
undoneById String?
createdAt DateTime @default(now())
// Sync tracking (append-only, never overwritten)
syncedAt DateTime @default(now())
// Relations
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
loggedBy User @relation("DoseLoggedBy", fields: [loggedById], references: [id])
undoneBy User? @relation("DoseUndoneBy", fields: [undoneById], references: [id])
@@index([medicationId, takenAt])
@@index([workspaceId, takenAt])
@@index([syncedAt])
}
// ============================================
// NOTES
// ============================================
enum NoteType {
QUESTION // Questions for doctor
GENERAL // General notes
}
model Note {
id String @id @default(cuid())
workspaceId String
type NoteType
content String
askedAt DateTime? // When a question was asked (marked)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync tracking
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("NoteCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("NoteUpdatedBy", fields: [updatedById], references: [id])
@@index([workspaceId, type])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// AUDIT LOG
// ============================================
model AuditLog {
id String @id @default(cuid())
workspaceId String
userId String
action String // CREATE, UPDATE, DELETE, TAKE_DOSE, UNDO_DOSE, etc.
entityType String // APPOINTMENT, MEDICATION, NOTE, DOSE_LOG
entityId String
details Json? // Additional context
createdAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@index([workspaceId, createdAt])
@@index([entityType, entityId])
}
// ============================================
// SYMPTOMS
// ============================================
enum SymptomType {
FATIGUE
NAUSEA
PAIN
APPETITE
SLEEP
MOOD
CUSTOM
}
model Symptom {
id String @id @default(cuid())
workspaceId String
type SymptomType
customName String? // Only used when type is CUSTOM
severity Int // 1-5 scale
notes String?
recordedAt DateTime @default(now())
deletedAt DateTime?
createdById String
// Sync tracking
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation(fields: [createdById], references: [id])
@@index([workspaceId, recordedAt])
@@index([workspaceId, type])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// APPOINTMENT CHECKLIST
// ============================================
model AppointmentChecklist {
id String @id @default(cuid())
workspaceId String
appointmentId String
item String
isReady Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
@@unique([workspaceId, appointmentId, item])
@@index([appointmentId])
@@index([workspaceId])
}
// ============================================
// PUSH NOTIFICATIONS
// ============================================
model PushSubscription {
id String @id @default(cuid())
userId String
workspaceId String
endpoint String
p256dh String
auth String
createdAt DateTime @default(now())
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([userId, endpoint])
@@index([workspaceId])
}
// ============================================
// SYNC
// ============================================
model SyncCursor {
id String @id @default(cuid())
workspaceId String
cursor BigInt @default(0) // Timestamp-based cursor
updatedAt DateTime @updatedAt
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId])
}
// ============================================
// TEMPERATURE LOG
// ============================================
model TemperatureLog {
id String @id @default(cuid())
workspaceId String
recordedAt DateTime @default(now())
tempCelsius Float
method String? // "oral", "forehead", "ear", "armpit"
notes String?
createdById String
deletedAt DateTime?
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("TempLogCreatedBy", fields: [createdById], references: [id])
@@index([workspaceId, recordedAt])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// CONTACT DIRECTORY
// ============================================
model Contact {
id String @id @default(cuid())
workspaceId String
name String
role String // "Oncologist", "Pharmacist", etc.
category String // "ONCOLOGY", "HOSPITAL", "PHARMACY", "INSURANCE", "FAMILY", "OTHER"
phone String
phone2 String?
email String?
address String?
hours String? // "Mon-Fri 8am-5pm"
notes String?
isEmergency Boolean @default(false)
sortOrder Int @default(0)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("ContactCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("ContactUpdatedBy", fields: [updatedById], references: [id])
@@index([workspaceId, category])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// WEIGHT LOG
// ============================================
model WeightLog {
id String @id @default(cuid())
workspaceId String
recordedAt DateTime @default(now())
weightKg Float
notes String?
createdById String
deletedAt DateTime?
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("WeightLogCreatedBy", fields: [createdById], references: [id])
@@index([workspaceId, recordedAt])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// TREATMENT TIMELINE
// ============================================
enum MilestoneStatus {
SCHEDULED
COMPLETED
DELAYED
CANCELLED
}
model TreatmentMilestone {
id String @id @default(cuid())
workspaceId String
type String // "CHEMO_CYCLE", "SURGERY", "RADIATION", "SCAN", "CONSULTATION", "OTHER"
title String
description String?
plannedDate DateTime
actualDate DateTime?
status MilestoneStatus @default(SCHEDULED)
sortOrder Int @default(0)
notes String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("MilestoneCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("MilestoneUpdatedBy", fields: [updatedById], references: [id])
@@index([workspaceId, plannedDate])
@@index([workspaceId, status])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// CAREGIVER TASKS
// ============================================
enum TaskStatus {
TODO
IN_PROGRESS
DONE
CANCELLED
}
enum TaskPriority {
URGENT
HIGH
NORMAL
LOW
}
model CaregiverTask {
id String @id @default(cuid())
workspaceId String
title String
description String?
category String // "MEDICAL", "ERRANDS", "MEALS", "EMOTIONAL", "OTHER"
priority TaskPriority @default(NORMAL)
status TaskStatus @default(TODO)
assignedToId String?
dueDate DateTime?
completedAt DateTime?
completedById String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("TaskCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("TaskUpdatedBy", fields: [updatedById], references: [id])
assignedTo User? @relation("TaskAssignedTo", fields: [assignedToId], references: [id])
completedBy User? @relation("TaskCompletedBy", fields: [completedById], references: [id])
@@index([workspaceId, status])
@@index([workspaceId, assignedToId])
@@index([workspaceId, dueDate])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// LAB RESULTS
// ============================================
model LabResult {
id String @id @default(cuid())
workspaceId String
testDate DateTime
panelName String // "Complete Blood Count", "Comprehensive Metabolic", etc.
labName String? // "Quest", "Hospital Lab"
results Json // Array of { marker, value, unit, refMin, refMax, flag }
notes String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("LabResultCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("LabResultUpdatedBy", fields: [updatedById], references: [id])
@@index([workspaceId, testDate])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// MEDICAL DOCUMENTS
// ============================================
model MedicalDocument {
id String @id @default(cuid())
workspaceId String
title String
category String // "LAB_REPORT", "SCAN", "INSURANCE", "ID_CARD", "PRESCRIPTION", "OTHER"
fileName String
fileSize Int // bytes
mimeType String // "application/pdf", "image/jpeg"
fileData Bytes // Store in DB as bytes (self-hosted, no S3)
dateTaken DateTime?
expiryDate DateTime?
notes String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
// Sync (no offline sync for file blobs — too large)
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("DocCreatedBy", fields: [createdById], references: [id])
@@index([workspaceId, category])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// DRUG INTERACTIONS (cached lookups)
// ============================================
model DrugInteraction {
id String @id @default(cuid())
workspaceId String
medication1Id String
medication2Id String
severity String // "MINOR", "MODERATE", "MAJOR", "CONTRAINDICATED"
description String
checkedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
medication1 Medication @relation("Interaction1", fields: [medication1Id], references: [id], onDelete: Cascade)
medication2 Medication @relation("Interaction2", fields: [medication2Id], references: [id], onDelete: Cascade)
@@unique([workspaceId, medication1Id, medication2Id])
@@index([workspaceId])
}