- 통계 탭 삭제 (프론트 페이지/차트/네비 항목, 백엔드 stats 모듈은 대시보드용으로 유지) - PDF 문제 분리 → 문제집 생성으로 변경 (ProblemSet + Problem 레코드) - ProblemSet에 sourceType, uploadedByUserId 추가 - POST /problem-sets/create-from-pdf 엔드포인트 - GET /problem-sets/my-uploads, DELETE /problem-sets/:id - exams 페이지에 '내가 만든 문제집' 섹션 - 문제 풀이 UI 통일 - DrawingCanvas 공통 컴포넌트 추출 (필기모드) - 복습 페이지에 필기모드(캔버스+펜색상) 추가 - 다시풀기 페이지에 필기모드+메모 추가 - 처음풀기 페이지 난이도 슬라이더→3버튼(H/M/E)+SM-2 연동 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
438 lines
12 KiB
Plaintext
438 lines
12 KiB
Plaintext
// ReLoop v2 schema — persona forgetting curve model
|
|
// Generated fresh; old FSRS fields dropped. DB reset via `prisma migrate reset`.
|
|
//
|
|
// NOTE: After editing tag-relation onDelete rules, run:
|
|
// prisma migrate dev --name relax_tag_cascade
|
|
// on the Dev VM before deploying. This migrates SkillSnapshot.tagId to nullable
|
|
// and sets both SkillSnapshot and StudyLog tag FKs to ON DELETE SET NULL.
|
|
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "mysql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
// ─── Enums ────────────────────────────────────────────────────────
|
|
|
|
enum Persona {
|
|
senior
|
|
mid
|
|
junior
|
|
crammer
|
|
}
|
|
|
|
enum ReviewIntensity {
|
|
strict
|
|
moderate
|
|
relaxed
|
|
}
|
|
|
|
enum StudyResult {
|
|
correct
|
|
incorrect
|
|
partial
|
|
}
|
|
|
|
enum ReviewStatus {
|
|
pending
|
|
done
|
|
skipped
|
|
expired
|
|
}
|
|
|
|
enum MathUnit {
|
|
common
|
|
prob_stat
|
|
calculus
|
|
geometry
|
|
}
|
|
|
|
enum SubscriptionTier {
|
|
free
|
|
pro
|
|
school
|
|
}
|
|
|
|
enum OrgType {
|
|
school
|
|
academy
|
|
}
|
|
|
|
enum OrgRole {
|
|
admin
|
|
teacher
|
|
student
|
|
}
|
|
|
|
enum AssignmentStatus {
|
|
active
|
|
closed
|
|
draft
|
|
}
|
|
|
|
// ─── Models ───────────────────────────────────────────────────────
|
|
|
|
model User {
|
|
id Int @id @default(autoincrement())
|
|
email String @unique
|
|
password String
|
|
nickname String
|
|
avatarUrl String?
|
|
persona Persona @default(mid)
|
|
currentGrade Int?
|
|
targetGrade Int?
|
|
targetExamYear Int?
|
|
focusSubjects Json?
|
|
focusUnits Json?
|
|
bojHandle String? @db.VarChar(32)
|
|
reviewIntensity ReviewIntensity @default(moderate)
|
|
onboardedAt DateTime?
|
|
subscriptionTier SubscriptionTier @default(free)
|
|
subscriptionUntil DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
subjects Subject[]
|
|
studyLogs StudyLog[]
|
|
psBookmarks PsBookmark[]
|
|
psSyncState PsSyncState?
|
|
reviewSchedules ReviewSchedule[]
|
|
skillSnapshots SkillSnapshot[]
|
|
organizationMembers OrganizationMember[]
|
|
classMemberships ClassMember[]
|
|
teacherClasses Class[] @relation("teacherClasses")
|
|
assignmentSubmissions AssignmentSubmission[]
|
|
uploadedProblemSets ProblemSet[] @relation("uploadedProblemSets")
|
|
|
|
@@map("users")
|
|
}
|
|
|
|
model Subject {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
color String @default("#6366f1")
|
|
userId Int
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
createdAt DateTime @default(now())
|
|
|
|
tags Tag[]
|
|
studyLogs StudyLog[]
|
|
|
|
@@unique([userId, name])
|
|
@@index([userId])
|
|
@@map("subjects")
|
|
}
|
|
|
|
model Tag {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
subjectId Int
|
|
subject Subject @relation(fields: [subjectId], references: [id], onDelete: Cascade)
|
|
createdAt DateTime @default(now())
|
|
|
|
studyLogs StudyLog[]
|
|
skillSnapshots SkillSnapshot[]
|
|
|
|
@@unique([subjectId, name])
|
|
@@index([subjectId])
|
|
@@map("tags")
|
|
}
|
|
|
|
model StudyLog {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
subjectId Int
|
|
subject Subject @relation(fields: [subjectId], references: [id])
|
|
tagId Int?
|
|
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
|
|
problemId Int?
|
|
problem Problem? @relation(fields: [problemId], references: [id], onDelete: SetNull)
|
|
psProblemId Int?
|
|
psProblem PsProblem? @relation(fields: [psProblemId], references: [id], onDelete: SetNull)
|
|
|
|
title String
|
|
difficulty Float
|
|
baseCorrectRate Float?
|
|
result StudyResult
|
|
chosenAnswer Int?
|
|
correctAnswer Int? // 정답 번호 (1~5), PDF 해설지에서 추출 또는 수동 입력
|
|
imageUrl String? // 사용자 업로드 PDF에서 크롭된 문제 이미지 경로
|
|
memo String? @db.Text
|
|
studiedAt DateTime @default(now())
|
|
timeSpent Int?
|
|
|
|
reviewSchedules ReviewSchedule[]
|
|
|
|
@@index([userId, studiedAt])
|
|
@@index([subjectId])
|
|
@@index([tagId])
|
|
@@index([problemId])
|
|
@@index([psProblemId])
|
|
@@map("study_logs")
|
|
}
|
|
|
|
model ReviewSchedule {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
studyLogId Int
|
|
studyLog StudyLog @relation(fields: [studyLogId], references: [id], onDelete: Cascade)
|
|
|
|
scheduledAt DateTime
|
|
reviewedAt DateTime?
|
|
result StudyResult?
|
|
iteration Int @default(0)
|
|
predictedP Float?
|
|
status ReviewStatus @default(pending)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([userId, status, scheduledAt])
|
|
@@index([studyLogId])
|
|
@@map("review_schedules")
|
|
}
|
|
|
|
/// 평가원/수능/모의평가 등 공개 기출 문제를 담는 컨테이너.
|
|
/// Subject 와 별개로 존재 — 모든 사용자가 공유하며 읽기 전용.
|
|
model ProblemSet {
|
|
id Int @id @default(autoincrement())
|
|
title String
|
|
/// examType 허용값: "sat" | "mock-june" | "mock-sept" | "academy" | "user-upload"
|
|
examType String
|
|
year Int
|
|
subjectName String
|
|
mathUnit MathUnit?
|
|
sourceUrl String?
|
|
/// 듣기 음원 파일 경로 배열 (data/kice/ 상대경로).
|
|
/// 예: ["2026/listening/audios/01_track.mp3", ...]
|
|
audioUrls Json?
|
|
/// "kice" | "user-upload" — 기출 vs 사용자 업로드 구분
|
|
sourceType String @default("kice")
|
|
/// 사용자 업로드인 경우 업로드한 사용자
|
|
uploadedByUserId Int?
|
|
uploadedBy User? @relation("uploadedProblemSets", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
|
|
createdAt DateTime @default(now())
|
|
|
|
problems Problem[]
|
|
passages Passage[]
|
|
assignments Assignment[]
|
|
|
|
@@unique([year, examType, subjectName, mathUnit])
|
|
@@index([uploadedByUserId])
|
|
@@map("problem_sets")
|
|
}
|
|
|
|
model Organization {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
type OrgType @default(academy)
|
|
inviteCode String @unique @db.VarChar(12)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
members OrganizationMember[]
|
|
classes Class[]
|
|
|
|
@@map("organizations")
|
|
}
|
|
|
|
model OrganizationMember {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
organizationId Int
|
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
role OrgRole @default(student)
|
|
joinedAt DateTime @default(now())
|
|
|
|
@@unique([userId, organizationId])
|
|
@@index([organizationId])
|
|
@@map("organization_members")
|
|
}
|
|
|
|
model Class {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
organizationId Int
|
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
teacherId Int
|
|
teacher User @relation("teacherClasses", fields: [teacherId], references: [id])
|
|
createdAt DateTime @default(now())
|
|
|
|
members ClassMember[]
|
|
assignments Assignment[]
|
|
|
|
@@index([organizationId])
|
|
@@index([teacherId])
|
|
@@map("classes")
|
|
}
|
|
|
|
model ClassMember {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
classId Int
|
|
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
|
joinedAt DateTime @default(now())
|
|
|
|
@@unique([userId, classId])
|
|
@@index([classId])
|
|
@@map("class_members")
|
|
}
|
|
|
|
model Assignment {
|
|
id Int @id @default(autoincrement())
|
|
title String
|
|
description String? @db.Text
|
|
classId Int
|
|
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
|
problemSetId Int
|
|
problemSet ProblemSet @relation(fields: [problemSetId], references: [id])
|
|
status AssignmentStatus @default(active)
|
|
dueDate DateTime?
|
|
createdAt DateTime @default(now())
|
|
|
|
submissions AssignmentSubmission[]
|
|
|
|
@@index([classId])
|
|
@@index([problemSetId])
|
|
@@map("assignments")
|
|
}
|
|
|
|
model AssignmentSubmission {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
assignmentId Int
|
|
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
|
score Float?
|
|
totalProblems Int?
|
|
correctCount Int?
|
|
completedAt DateTime?
|
|
studyLogIds Json?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([userId, assignmentId])
|
|
@@index([assignmentId])
|
|
@@map("assignment_submissions")
|
|
}
|
|
|
|
model Problem {
|
|
id Int @id @default(autoincrement())
|
|
problemSetId Int
|
|
problemSet ProblemSet @relation(fields: [problemSetId], references: [id], onDelete: Cascade)
|
|
number Int
|
|
title String
|
|
difficulty Float
|
|
baseCorrectRate Float?
|
|
topic String?
|
|
/// KICE 파싱 결과 — 본문 텍스트 (선택지 제외)
|
|
bodyText String? @db.Text
|
|
/// KICE 파싱 결과 — 선택지 객체 { "1": "...", "2": "...", "3": "...", "4": "...", "5": "..." }
|
|
choices Json?
|
|
/// 렌더링된 문제 이미지 URL (수학/도표형 문제 대응)
|
|
imageUrl String?
|
|
/// 렌더링된 원본 페이지 이미지 URL
|
|
pageImageUrl String?
|
|
/// KICE 파싱 결과 — 정답 번호 1..5
|
|
answerNumber Int?
|
|
/// 공통 지문 FK (여러 문제가 공유하는 지문)
|
|
passageId Int?
|
|
passage Passage? @relation(fields: [passageId], references: [id], onDelete: SetNull)
|
|
/// 파싱 엣지케이스 플래그 — 수동 보정 대상
|
|
needsReview Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
|
|
studyLogs StudyLog[]
|
|
|
|
@@unique([problemSetId, number])
|
|
@@index([problemSetId])
|
|
@@index([passageId])
|
|
@@map("problems")
|
|
}
|
|
|
|
/// 평가원 기출의 [N~M] 공통 지문을 담는 컨테이너.
|
|
/// 여러 Problem 이 같은 Passage 를 공유할 수 있음.
|
|
model Passage {
|
|
id Int @id @default(autoincrement())
|
|
problemSetId Int
|
|
problemSet ProblemSet @relation(fields: [problemSetId], references: [id], onDelete: Cascade)
|
|
startNumber Int
|
|
endNumber Int
|
|
bodyText String @db.Text
|
|
imageUrl String?
|
|
createdAt DateTime @default(now())
|
|
|
|
problems Problem[]
|
|
|
|
@@index([problemSetId])
|
|
@@map("passages")
|
|
}
|
|
|
|
model SkillSnapshot {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
tagId Int?
|
|
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
|
|
|
|
s0 Float
|
|
lastUpdatedAt DateTime @default(now())
|
|
sampleCount Int @default(0)
|
|
|
|
@@unique([userId, tagId])
|
|
@@index([userId])
|
|
@@map("skill_snapshots")
|
|
}
|
|
|
|
model PsProblem {
|
|
id Int @id @default(autoincrement())
|
|
bojId Int @unique
|
|
title String
|
|
titleKo String?
|
|
level Int
|
|
tags Json?
|
|
acceptedUserCount Int?
|
|
averageTries Float?
|
|
solvedacUpdatedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
bookmarks PsBookmark[]
|
|
studyLogs StudyLog[]
|
|
|
|
@@index([level])
|
|
@@map("ps_problems")
|
|
}
|
|
|
|
model PsBookmark {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
psProblemId Int
|
|
psProblem PsProblem @relation(fields: [psProblemId], references: [id], onDelete: Cascade)
|
|
memo String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([userId, psProblemId])
|
|
@@index([userId])
|
|
@@map("ps_bookmarks")
|
|
}
|
|
|
|
model PsSyncState {
|
|
id Int @id @default(autoincrement())
|
|
userId Int @unique
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
lastSyncedAt DateTime?
|
|
lastSolvedCount Int @default(0)
|
|
lastTier Int?
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@map("ps_sync_states")
|
|
}
|