Files
pwa/backend/prisma/schema.prisma
T

454 lines
16 KiB
Plaintext
Raw Normal View History

// 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")
}