Files
pwa/backend/prisma/schema.prisma
T
ar.badieiandCursor c6243caa35 Add read-only Noya admin panel with assignments and UX polish.
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>
2026-09-21 08:00:24 +03:30

454 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
}