Ship admin auth, user overview, assessments, chats, assignment filters/export, security hardening, and a separate desktop admin shell with clearer assessment answers. Co-authored-by: Cursor <cursoragent@cursor.com>
454 lines
16 KiB
Plaintext
454 lines
16 KiB
Plaintext
// 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")
|
||
}
|