// Novya backend schema. // Identity/auth from Phase 3 is preserved. Domain tables match BACKEND_DATABASE_ARCHITECTURE_AUDIT.md. generator client { provider = "prisma-client-js" } datasource db { provider = "mysql" url = env("DATABASE_URL") } enum AuthProvider { password guest } /// Application access role. Runtime admin checks use DB role, not ENV alone. enum UserRole { user admin } enum AttemptPhase { pre post } enum AttemptStatus { /// Reserved for future draft/in-progress attempts; MVP always writes `scored`. submitted scored } enum SessionProgressStatus { not_started in_progress completed } enum ChatRole { /// Used in coach prompts at runtime; not persisted to chat_messages. system user assistant } enum AiRunStatus { queued running succeeded failed } enum AssignmentType { ONE_TIME DAILY ONGOING } enum UserAssignmentStatus { NOT_STARTED ACTIVE IN_PROGRESS REPORTED COMPLETED } enum AssignmentReportStatus { completed in_progress difficult not_started } /// Per-day outcome for DAILY / ONGOING assignments (stored on the entry row). enum AssignmentDayStatus { done skipped difficult } model User { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt externalId String @unique @default(uuid()) @db.Char(36) isGuest Boolean @default(false) role UserRole @default(user) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt profile UserProfile? identities AuthIdentity[] sessions AuthSession[] journey LearningJourney? assessmentAttempts AssessmentAttempt[] sessionProgress SessionProgress[] homeworkReports HomeworkReport[] userAssignments UserAssignment[] assignmentReports AssignmentReport[] assignmentEntries AssignmentDailyEntry[] chatSessions ChatSession[] aiRuns AiAnalysisRun[] auditLogsAsActor AuditLog[] @relation("AuditActor") auditLogsAsTarget AuditLog[] @relation("AuditTarget") @@index([createdAt], map: "idx_users_created_at") @@index([isGuest], map: "idx_users_is_guest") @@index([role], map: "idx_users_role") @@map("users") } /// Sensitive admin/access events. Never store passwords, hashes, tokens, or full chat/assessment payloads. model AuditLog { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt actorUserId BigInt? @db.UnsignedBigInt action String @db.VarChar(64) targetUserId BigInt? @db.UnsignedBigInt targetType String? @db.VarChar(64) targetId String? @db.VarChar(191) metadata Json? ipAddress String? @db.VarChar(64) userAgent String? @db.VarChar(512) createdAt DateTime @default(now()) actor User? @relation("AuditActor", fields: [actorUserId], references: [id], onDelete: SetNull) target User? @relation("AuditTarget", fields: [targetUserId], references: [id], onDelete: SetNull) @@index([actorUserId, createdAt], map: "idx_audit_logs_actor_created") @@index([action, createdAt], map: "idx_audit_logs_action_created") @@index([targetUserId, createdAt], map: "idx_audit_logs_target_created") @@map("audit_logs") } model UserProfile { userId BigInt @id @db.UnsignedBigInt name String @db.VarChar(100) age Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([name], map: "idx_user_profiles_name") @@map("user_profiles") } model AuthIdentity { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt userId BigInt @db.UnsignedBigInt provider AuthProvider providerSubject String @db.VarChar(191) email String? @db.VarChar(191) passwordHash String? @db.VarChar(255) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerSubject], map: "uq_auth_identities_provider_subject") @@index([userId], map: "idx_auth_identities_user_id") @@index([email], map: "idx_auth_identities_email") @@map("auth_identities") } model AuthSession { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt userId BigInt @db.UnsignedBigInt tokenHash String @unique @db.Char(64) expiresAt DateTime revokedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, expiresAt], map: "idx_auth_sessions_user_id_expires_at") @@index([expiresAt], map: "idx_auth_sessions_expires_at") @@map("auth_sessions") } /// Timestamps are authoritative. currentStep is a denormalized cache. model LearningJourney { userId BigInt @id @db.UnsignedBigInt currentStep String @default("registered") @db.VarChar(32) preTestCompletedAt DateTime? trainingStartedAt DateTime? trainingCompletedAt DateTime? postTestCompletedAt DateTime? lastActivityAt DateTime @default(now()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("learning_journeys") } /// Catalog of the 11 educational sessions. Content blocks are JSON (same shape as PWA). model CourseSession { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt sessionKey String @unique @db.VarChar(64) order Int @unique title String @db.VarChar(191) description String @db.Text estimatedDurationMinutes Int blocks Json createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sessionProgress SessionProgress[] homeworkReports HomeworkReport[] assignmentDefinitions AssignmentDefinition[] chatSessions ChatSession[] @@index([order], map: "idx_course_sessions_order") @@map("course_sessions") } /// Fixed catalog of homework/assignment definitions (seeded from code). model AssignmentDefinition { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt key String @unique @db.VarChar(64) sessionOrder Int title String @db.VarChar(191) description String @db.Text instructions String @db.Text assignmentType AssignmentType /// Structured form fields for drafts / daily entries (JSON). Null = free text. formSchema Json? /// Target day count for progress UI (not necessarily the completion threshold). expectedDurationDays Int? suggestedEndDay Int? /// Min valid «done» days to auto-complete DAILY/ONGOING. Null = never auto-complete by count. minValidDays Int? /// When false, structured response is the primary record; AssignmentReport is optional/soft. requiresReport Boolean @default(true) /// Soft check-in only; must stay false unless a clinical gate is explicitly required. blocksNextSession Boolean @default(false) isFinalAssignment Boolean @default(false) displayOrder Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt courseSession CourseSession @relation(fields: [sessionOrder], references: [order], onDelete: Restrict, onUpdate: Cascade) userAssignments UserAssignment[] @@index([sessionOrder], map: "idx_assignment_definitions_session_order") @@index([displayOrder], map: "idx_assignment_definitions_display_order") @@map("assignment_definitions") } /// Per-user instance of an assignment, activated when the related session is completed. model UserAssignment { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt userId BigInt @db.UnsignedBigInt definitionId BigInt @db.UnsignedBigInt status UserAssignmentStatus @default(ACTIVE) activatedAt DateTime @default(now()) startedAt DateTime? lastActivityAt DateTime? completedAt DateTime? /// Draft answers for ONE_TIME writing assignments (JSON object keyed by field). draftData Json? completionNote String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) definition AssignmentDefinition @relation(fields: [definitionId], references: [id], onDelete: Restrict) reports AssignmentReport[] dailyEntries AssignmentDailyEntry[] @@unique([userId, definitionId], map: "uq_user_assignments_user_definition") @@index([userId, status], map: "idx_user_assignments_user_status") @@index([userId, activatedAt], map: "idx_user_assignments_user_activated") @@map("user_assignments") } /// Check-in / progress reports — multiple allowed per user assignment. model AssignmentReport { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt userAssignmentId BigInt @db.UnsignedBigInt userId BigInt @db.UnsignedBigInt reportText String @db.Text reportStatus AssignmentReportStatus createdAt DateTime @default(now()) userAssignment UserAssignment @relation(fields: [userAssignmentId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userAssignmentId, createdAt], map: "idx_assignment_reports_ua_created") @@index([userId, createdAt], map: "idx_assignment_reports_user_created") @@map("assignment_reports") } /// Daily/ongoing entry — one row per calendar day per user assignment. model AssignmentDailyEntry { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt userAssignmentId BigInt @db.UnsignedBigInt userId BigInt @db.UnsignedBigInt entryDate DateTime @db.Date /// done = practiced today; skipped / difficult = logged without full activity payload. dayStatus AssignmentDayStatus @default(done) responseData Json note String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userAssignment UserAssignment @relation(fields: [userAssignmentId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userAssignmentId, entryDate], map: "uq_assignment_daily_entries_ua_date") @@index([userId, entryDate], map: "idx_assignment_daily_entries_user_date") @@map("assignment_daily_entries") } model SessionProgress { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt userId BigInt @db.UnsignedBigInt sessionOrder Int status SessionProgressStatus @default(not_started) startedAt DateTime? completedAt DateTime? completionMetadata Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) courseSession CourseSession @relation(fields: [sessionOrder], references: [order], onDelete: Restrict, onUpdate: Cascade) @@unique([userId, sessionOrder], map: "uq_session_progress_user_order") @@index([userId, status], map: "idx_session_progress_user_status") @@map("session_progress") } /// Learner report of homework completed for a finished session (sessions 2–10). model HomeworkReport { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt userId BigInt @db.UnsignedBigInt /// Session whose homework is being reported (the completed session, not the next one). sessionOrder Int reportText String @db.Text submittedAt DateTime @default(now()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) courseSession CourseSession @relation(fields: [sessionOrder], references: [order], onDelete: Restrict, onUpdate: Cascade) @@unique([userId, sessionOrder], map: "uq_homework_reports_user_order") @@index([userId], map: "idx_homework_reports_user") @@map("homework_reports") } model AssessmentAttempt { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt userId BigInt @db.UnsignedBigInt phase AttemptPhase status AttemptStatus @default(scored) submittedAt DateTime scoredAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) answers AssessmentAnswer[] scores AssessmentScore[] @@unique([userId, phase], map: "uq_assessment_attempts_user_phase") @@index([userId, phase, submittedAt], map: "idx_assessment_attempts_user_phase_submitted") @@map("assessment_attempts") } model AssessmentAnswer { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt attemptId BigInt @db.UnsignedBigInt questionId String @db.VarChar(32) rawValue Int points Int dimension String @db.VarChar(32) isReverseScored Boolean createdAt DateTime @default(now()) attempt AssessmentAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade) @@unique([attemptId, questionId], map: "uq_assessment_answers_attempt_question") @@map("assessment_answers") } model AssessmentScore { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt attemptId BigInt @db.UnsignedBigInt metricKey String @db.VarChar(32) rawScore Int createdAt DateTime @default(now()) attempt AssessmentAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade) @@unique([attemptId, metricKey], map: "uq_assessment_scores_attempt_metric") @@map("assessment_scores") } model ChatSession { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt userId BigInt @db.UnsignedBigInt sessionOrder Int startedAt DateTime @default(now()) endedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) courseSession CourseSession @relation(fields: [sessionOrder], references: [order], onDelete: Restrict, onUpdate: Cascade) messages ChatMessage[] aiRuns AiAnalysisRun[] @@index([userId, sessionOrder, startedAt], map: "idx_chat_sessions_user_order_started") @@map("chat_sessions") } model ChatMessage { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt chatSessionId BigInt @db.UnsignedBigInt role ChatRole content String @db.Text metadata Json? createdAt DateTime @default(now()) chatSession ChatSession @relation(fields: [chatSessionId], references: [id], onDelete: Cascade) @@index([chatSessionId, createdAt], map: "idx_chat_messages_session_created") @@map("chat_messages") } model AiAnalysisRun { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt userId BigInt @db.UnsignedBigInt chatSessionId BigInt? provider String @db.VarChar(64) model String @db.VarChar(128) status AiRunStatus @default(queued) latencyMs Int? totalTokens Int? errorMessage String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) chatSession ChatSession? @relation(fields: [chatSessionId], references: [id], onDelete: SetNull) @@index([userId, createdAt], map: "idx_ai_analysis_runs_user_created") @@map("ai_analysis_runs") }