diff --git a/docs/db/schema-changelog.md b/docs/db/schema-changelog.md index a696e1c..2261b3b 100644 --- a/docs/db/schema-changelog.md +++ b/docs/db/schema-changelog.md @@ -1,12 +1,12 @@ # Database Schema Changelog -## v1.1.0 - Exam Structure & Performance Optimization +## v1.1.0 - Exam Structure Support **Date:** 2025-12-29 **Migration ID:** `0001_flawless_texas_twister` **Author:** Principal Database Architect ### 1. Summary -This release introduces support for hierarchical exam structures (Sectioning/Grouping) and optimizes database constraint naming for better compatibility with MySQL environments. +This release introduces support for hierarchical exam structures (Sectioning/Grouping). ### 2. Changes @@ -23,17 +23,55 @@ This release introduces support for hierarchical exam structures (Sectioning/Gro > ``` -#### 2.2 Table: `questions_to_knowledge_points` -* **Action**: `RENAME FOREIGN KEY` +### 3. Migration Strategy +* **Up**: Run standard Drizzle migration. +* **Down**: Revert `structure` column. Note that FK names can be kept short as they are implementation details. + +### 4. Impact Analysis +* **Performance**: Negligible. JSON parsing is done client-side or at application layer. +* **Data Integrity**: High. Existing data is unaffected. New `structure` field defaults to `NULL`. + +## v1.2.0 - Homework Module Tables & FK Name Hardening +**Date:** 2025-12-31 +**Migration ID:** `0002_equal_wolfpack` +**Author:** Principal Database Architect + +### 1. Summary +This release introduces homework-related tables and hardens foreign key names to avoid exceeding MySQL identifier length limits (MySQL 64-char constraint names). + +### 2. Changes + +#### 2.1 Tables: Homework Domain +* **Action**: `CREATE TABLE` +* **Tables**: + * `homework_assignments` + * `homework_assignment_questions` + * `homework_assignment_targets` + * `homework_submissions` + * `homework_answers` +* **Reason**: Support assignment lifecycle, targeting, submissions, and per-question grading. + +#### 2.2 Foreign Keys: Homework Domain (Name Hardening) +* **Action**: `ADD FOREIGN KEY` (with short constraint names) +* **Details**: + * `homework_assignments`: `hw_asg_exam_fk`, `hw_asg_creator_fk` + * `homework_assignment_questions`: `hw_aq_a_fk`, `hw_aq_q_fk` + * `homework_assignment_targets`: `hw_at_a_fk`, `hw_at_s_fk` + * `homework_submissions`: `hw_sub_a_fk`, `hw_sub_student_fk` + * `homework_answers`: `hw_ans_sub_fk`, `hw_ans_q_fk` +* **Reason**: Default generated FK names can exceed 64 characters in MySQL and fail during migration. + +#### 2.3 Table: `questions_to_knowledge_points` +* **Action**: `RENAME FOREIGN KEY` (implemented as drop + add) * **Details**: * Old: `questions_to_knowledge_points_question_id_questions_id_fk` -> New: `q_kp_qid_fk` * Old: `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` -> New: `q_kp_kpid_fk` * **Reason**: Previous names exceeded MySQL's 64-character identifier limit, causing potential migration failures in production environments. ### 3. Migration Strategy -* **Up**: Run standard Drizzle migration. The script includes `ALTER TABLE ... DROP FOREIGN KEY` followed by `ADD CONSTRAINT`. -* **Down**: Revert `structure` column. Note that FK names can be kept short as they are implementation details. +* **Up**: Run standard Drizzle migration. The migration is resilient whether the legacy FK names exist or have already been renamed. +* **Down**: Not provided. Removing homework tables and FKs is destructive and should be handled explicitly per environment. ### 4. Impact Analysis -* **Performance**: Negligible. JSON parsing is done client-side or at application layer. -* **Data Integrity**: High. Existing data is unaffected. New `structure` field defaults to `NULL`. +* **Performance**: Minimal. New indexes are scoped to common homework access patterns. +* **Data Integrity**: High. Foreign keys enforce referential integrity for homework workflow. diff --git a/docs/design/005_exam_module_implementation.md b/docs/design/005_exam_module_implementation.md index bbde1b0..186e5d5 100644 --- a/docs/design/005_exam_module_implementation.md +++ b/docs/design/005_exam_module_implementation.md @@ -1,15 +1,17 @@ # 考试模块实现设计文档 ## 1. 概述 -考试模块提供了一个完整的评估管理生命周期,使教师能够创建考试、组卷(支持嵌套分组)、发布评估以及对学生的提交进行评分。 +考试模块用于教师侧的“试卷制作与管理”,覆盖创建考试、组卷(支持嵌套分组)、发布/归档等流程。 + +**说明(合并调整)**:与“作业(Homework)”模块合并后,考试模块不再提供“阅卷/评分(grading)”与提交流转;教师批改统一在 Homework 的 submissions 中完成。 ## 2. 数据架构 ### 2.1 核心实体 - **Exams**: 根实体,包含元数据(标题、时间安排)和结构信息。 - **ExamQuestions**: 关系链接,用于查询题目的使用情况(扁平化表示)。 -- **ExamSubmissions**: 学生的考试尝试记录。 -- **SubmissionAnswers**: 链接到特定题目的单个答案。 +- **ExamSubmissions**: (历史/保留)学生的考试尝试记录;当前 UI/路由不再使用。 +- **SubmissionAnswers**: (历史/保留)链接到特定题目的单个答案;当前 UI/路由不再使用。 ### 2.2 `structure` 字段 为了支持层级布局(如章节/分组),我们在 `exams` 表中引入了一个 JSON 列 `structure`。它作为“布局/呈现层”的单一事实来源(Source of Truth),用于渲染分组与排序;而 `exam_questions` 仍然承担题目关联、外键完整性与索引查询职责。 @@ -46,14 +48,9 @@ type ExamNode = { - 可搜索/筛选的可用题目列表。 - “添加”操作将节点追加到结构树中。 -### 3.2 阅卷界面 -位于 `/teacher/exams/grading/[submissionId]`。 - -- **`GradingView` (客户端组件)** - - **左侧面板**: 只读视图,显示学生的答案与题目内容。 - - **右侧面板**: 评分和反馈的输入字段。 - - **状态**: 在提交前管理本地更改。 -- **Actions**: `gradeSubmissionAction` 更新 `submissionAnswers` 并将总分聚合到 `examSubmissions`。 +### 3.2 阅卷界面(已下线) +原阅卷路由 `/teacher/exams/grading` 与 `/teacher/exams/grading/[submissionId]` 已移除业务能力并重定向到 Homework: +- `/teacher/exams/grading*` → `/teacher/homework/submissions` ### 3.3 列表页(All Exams) 位于 `/teacher/exams/all`。 @@ -74,13 +71,10 @@ type ExamNode = { - **保存**: 同时提交 `questionsJson`(扁平化,用于索引)和 `structureJson`(树状,用于布局)到 `updateExamAction`。 3. **发布**: 状态变更为 `published`。 -### 4.2 阅卷流程 -1. **列表**: 教师查看 `submission-data-table`。 -2. **评分**: 打开特定提交。 -3. **审查**: 遍历题目。 - - 系统显示学生答案。 - - 教师输入分数(上限为满分)和反馈。 -4. **提交**: 服务器更新单个答案记录并重新计算提交总分。 +### 4.2 阅卷/批改流程(迁移到 Homework) +教师批改统一在 Homework 模块完成: +- 提交列表:`/teacher/homework/submissions` +- 批改页:`/teacher/homework/submissions/[submissionId]` ### 4.3 考试管理(All Exams Actions) 位于 `/teacher/exams/all` 的表格行级菜单。 @@ -118,14 +112,13 @@ type ExamNode = { - 面向未来(现代 React Hooks 模式)。 ### 5.3 Server Actions -所有变更操作(保存草稿、发布、复制、删除、评分)均使用 Next.js Server Actions,以确保类型安全并自动重新验证缓存。 +所有变更操作(保存草稿、发布、复制、删除)均使用 Next.js Server Actions,以确保类型安全并自动重新验证缓存。 已落地的 Server Actions: - `createExamAction` - `updateExamAction` - `duplicateExamAction` - `deleteExamAction` -- `gradeSubmissionAction` ## 6. 接口与数据影响 @@ -153,11 +146,16 @@ type ExamNode = { - 依赖外键级联清理关联数据:`exam_questions`、`exam_submissions`、`submission_answers` - **缓存**: - `revalidatePath("/teacher/exams/all")` - - `revalidatePath("/teacher/exams/grading")` ### 6.4 数据访问层(Data Access) 位于 `src/modules/exams/data-access.ts`,对外提供与页面/组件解耦的查询函数。 - `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤 - `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate -- `getExamSubmissions()`: 为阅卷列表提供 submissions 数据 + +## 7. 变更记录(合并 Homework) + +**日期**:2025-12-31 + +- 移除 Exams grading 入口与实现:删除阅卷 UI、server action、data-access 查询。 +- Exams grading 路由改为重定向到 Homework submissions。 diff --git a/docs/design/006_homework_module_implementation.md b/docs/design/006_homework_module_implementation.md new file mode 100644 index 0000000..1124ef2 --- /dev/null +++ b/docs/design/006_homework_module_implementation.md @@ -0,0 +1,153 @@ +# 作业模块实现设计文档(Homework Module) + +**日期**: 2025-12-31 +**模块**: Homework (`src/modules/homework`) + +--- + +## 1. 概述 + +作业模块提供“由试卷派发作业”的完整生命周期: + +- 教师从已存在的 Exam 派发 Homework Assignment(冻结当时的结构与题目引用) +- 指定作业目标学生(Targets) +- 学生开始一次作答(Submission),保存答案(Answers),并最终提交 +- 教师在提交列表中查看并批改(按题给分/反馈,汇总总分) + +核心目标是:在不破坏 Exam 本体数据的前提下,为作业提供可追溯、可批改、可统计的独立域模型。 + +**说明(合并调整)**:教师端“阅卷/批改”统一通过 Homework submissions 完成,`/teacher/exams/grading*` 相关路由已重定向到 `/teacher/homework/submissions`。 + +--- + +## 2. 数据架构 + +### 2.1 核心实体 + +- `homework_assignments`: 作业实例(从 exam 派生) +- `homework_assignment_questions`: 作业与题目关系(score/order) +- `homework_assignment_targets`: 作业目标学生列表 +- `homework_submissions`: 学生作业尝试(attempt_no/status/时间/是否迟交) +- `homework_answers`: 每题答案(answer_content/score/feedback) + +数据库变更记录见:[schema-changelog.md](file:///c:/Users/xiner/Desktop/CICD/docs/db/schema-changelog.md#L34-L77) + +### 2.2 设计要点:冻结 Exam → Homework Assignment + +- `homework_assignments.source_exam_id` 保存来源 Exam +- `homework_assignments.structure` 在 publish 时复制 `exams.structure`(冻结当时的呈现结构) +- 题目关联使用 `homework_assignment_questions`(仍引用 `questions` 表,作业侧记录分值与顺序) + +--- + +## 3. 路由与页面 + +### 3.1 教师端 + +- `/teacher/homework/assignments`: 作业列表 + 实现:[assignments/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx) +- `/teacher/homework/assignments/create`: 从 Exam 派发作业 + 实现:[create/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx) +- `/teacher/homework/assignments/[id]`: 作业详情 + 实现:[[id]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx) +- `/teacher/homework/assignments/[id]/submissions`: 作业提交列表(按作业筛选) + 实现:[[id]/submissions/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx) +- `/teacher/homework/submissions`: 全部提交列表 + 实现:[submissions/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx) +- `/teacher/homework/submissions/[submissionId]`: 批改页 + 实现:[[submissionId]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx) + +关联重定向: + +- `/teacher/exams/grading` → `/teacher/homework/submissions` + 实现:[grading/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx) +- `/teacher/exams/grading/[submissionId]` → `/teacher/homework/submissions` + 实现:[grading/[submissionId]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx) + +### 3.2 学生端 + +- `/student/learning/assignments`: 作业列表 + 实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/student/learning/assignments/page.tsx) +- `/student/learning/assignments/[assignmentId]`: 作答页(开始/保存/提交) + 实现:[[assignmentId]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/student/learning/assignments/%5BassignmentId%5D/page.tsx) + +--- + +## 4. 数据访问层(Data Access) + +数据访问位于:[data-access.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts) + +### 4.1 教师侧查询 + +- `getHomeworkAssignments`:作业列表(可按 creatorId/ids) +- `getHomeworkAssignmentById`:作业详情(含目标人数、提交数统计) +- `getHomeworkSubmissions`:提交列表(可按 assignmentId) +- `getHomeworkSubmissionDetails`:提交详情(题目内容 + 学生答案 + 分值/顺序) + +### 4.2 学生侧查询 + +- `getStudentHomeworkAssignments(studentId)`:只返回“已派发给该学生、已发布、且到达 availableAt”的作业 +- `getStudentHomeworkTakeData(assignmentId, studentId)`:进入作答页所需数据(assignment + 当前/最近 submission + 题目列表 + 已保存答案) + +### 4.3 开发模式用户选择(Demo) + +为了在未接入真实 Auth 的情况下可演示学生端页面,提供: + +- `getDemoStudentUser()`:优先选取最早创建的 student;若无 student,则退化到任意用户 + +--- + +## 5. Server Actions + +实现位于:[actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/actions.ts) + +### 5.1 教师侧 + +- `createHomeworkAssignmentAction`:从 exam 创建 assignment;可写入 targets;可选择 publish(默认 true) +- `gradeHomeworkSubmissionAction`:按题写入 score/feedback,并汇总写入 submission.score 与 status=graded + +### 5.2 学生侧 + +- `startHomeworkSubmissionAction`:创建一次 submission(attemptNo + startedAt),并校验: + - assignment 已发布 + - student 在 targets 中 + - availableAt 已到 + - 未超过 maxAttempts +- `saveHomeworkAnswerAction`:保存/更新某题答案(upsert 到 homework_answers) +- `submitHomeworkAction`:提交作业(校验 dueAt/lateDueAt/allowLate,写入 submittedAt/isLate/status=submitted) + +--- + +## 6. UI 组件 + +### 6.1 教师批改视图 + +- [HomeworkGradingView](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-grading-view.tsx) + - 左侧:学生答案只读展示 + - 右侧:按题录入分数与反馈,并提交批改 + +### 6.2 学生作答视图 + +- [HomeworkTakeView](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-take-view.tsx) + - Start:开始一次作答 + - Save:按题保存 + - Submit:提交(提交前会先保存当前题目答案) + - 题型支持:`text` / `judgment` / `single_choice` / `multiple_choice` + +题目 content 约定与题库一致:`{ text, options?: [{ id, text, isCorrect? }] }`(作答页仅消费 `id/text`)。 + +--- + +## 7. 类型定义 + +类型位于:[types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/types.ts) + +- 教师侧:`HomeworkAssignmentListItem` / `HomeworkSubmissionDetails` 等 +- 学生侧:`StudentHomeworkAssignmentListItem` / `StudentHomeworkTakeData` 等 + +--- + +## 8. 校验 + +- `npm run typecheck`: 通过 +- `npm run lint`: 0 errors(仓库其他位置存在 warnings,与本模块新增功能无直接关联) diff --git a/docs/scripts/baseline-migrations.js b/docs/scripts/baseline-migrations.js new file mode 100644 index 0000000..76e01f0 --- /dev/null +++ b/docs/scripts/baseline-migrations.js @@ -0,0 +1,90 @@ +require("dotenv/config"); + +const fs = require("node:fs"); +const crypto = require("node:crypto"); +const path = require("node:path"); +const mysql = require("mysql2/promise"); + +const JOURNAL = { + "0000_aberrant_cobalt_man": 1766460456274, + "0001_flawless_texas_twister": 1767004087964, + "0002_equal_wolfpack": 1767145757594, +}; + +function sha256Hex(input) { + return crypto.createHash("sha256").update(input).digest("hex"); +} + +async function main() { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("DATABASE_URL is not set"); + } + + const conn = await mysql.createConnection(url); + + await conn.query( + "CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id serial primary key, hash text not null, created_at bigint)" + ); + + const [existing] = await conn.query( + "SELECT id, hash, created_at FROM `__drizzle_migrations` ORDER BY created_at DESC LIMIT 1" + ); + if (Array.isArray(existing) && existing.length > 0) { + console.log("✅ __drizzle_migrations already has entries. Skip baselining."); + await conn.end(); + return; + } + + const [[accountsRow]] = await conn.query( + "SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='accounts'" + ); + const accountsExists = Number(accountsRow?.cnt ?? 0) > 0; + if (!accountsExists) { + console.log("ℹ️ No existing tables detected (accounts missing). Skip baselining."); + await conn.end(); + return; + } + + const [[structureRow]] = await conn.query( + "SELECT COUNT(*) AS cnt FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='exams' AND column_name='structure'" + ); + const examsStructureExists = Number(structureRow?.cnt ?? 0) > 0; + + const [[homeworkRow]] = await conn.query( + "SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='homework_assignments'" + ); + const homeworkExists = Number(homeworkRow?.cnt ?? 0) > 0; + + const baselineTags = []; + baselineTags.push("0000_aberrant_cobalt_man"); + if (examsStructureExists) baselineTags.push("0001_flawless_texas_twister"); + if (homeworkExists) baselineTags.push("0002_equal_wolfpack"); + + const drizzleDir = path.resolve(__dirname, "..", "..", "drizzle"); + for (const tag of baselineTags) { + const sqlPath = path.join(drizzleDir, `${tag}.sql`); + if (!fs.existsSync(sqlPath)) { + throw new Error(`Missing migration file: ${sqlPath}`); + } + const sqlText = fs.readFileSync(sqlPath).toString(); + const hash = sha256Hex(sqlText); + const createdAt = JOURNAL[tag]; + if (typeof createdAt !== "number") { + throw new Error(`Missing journal timestamp for: ${tag}`); + } + await conn.query( + "INSERT INTO `__drizzle_migrations` (`hash`, `created_at`) VALUES (?, ?)", + [hash, createdAt] + ); + } + + console.log(`✅ Baselined __drizzle_migrations: ${baselineTags.join(", ")}`); + await conn.end(); +} + +main().catch((err) => { + console.error("❌ Baseline failed:", err); + process.exit(1); +}); + diff --git a/drizzle/0001_flawless_texas_twister.sql b/drizzle/0001_flawless_texas_twister.sql index 1ed65ef..530ef40 100644 --- a/drizzle/0001_flawless_texas_twister.sql +++ b/drizzle/0001_flawless_texas_twister.sql @@ -1,5 +1 @@ -ALTER TABLE `exams` ADD `structure` json;--> statement-breakpoint -ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_question_id_questions_id_fk`;--> statement-breakpoint -ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`;--> statement-breakpoint -ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_qid_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_kpid_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action; \ No newline at end of file +ALTER TABLE `exams` ADD `structure` json; diff --git a/drizzle/0002_equal_wolfpack.sql b/drizzle/0002_equal_wolfpack.sql new file mode 100644 index 0000000..e9a8017 --- /dev/null +++ b/drizzle/0002_equal_wolfpack.sql @@ -0,0 +1,274 @@ +CREATE TABLE IF NOT EXISTS `homework_answers` ( + `id` varchar(128) NOT NULL, + `submission_id` varchar(128) NOT NULL, + `question_id` varchar(128) NOT NULL, + `answer_content` json, + `score` int, + `feedback` text, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `homework_answers_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `homework_assignment_questions` ( + `assignment_id` varchar(128) NOT NULL, + `question_id` varchar(128) NOT NULL, + `score` int DEFAULT 0, + `order` int DEFAULT 0, + CONSTRAINT `homework_assignment_questions_assignment_id_question_id_pk` PRIMARY KEY(`assignment_id`,`question_id`) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `homework_assignment_targets` ( + `assignment_id` varchar(128) NOT NULL, + `student_id` varchar(128) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `homework_assignment_targets_assignment_id_student_id_pk` PRIMARY KEY(`assignment_id`,`student_id`) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `homework_assignments` ( + `id` varchar(128) NOT NULL, + `source_exam_id` varchar(128) NOT NULL, + `title` varchar(255) NOT NULL, + `description` text, + `structure` json, + `status` varchar(50) DEFAULT 'draft', + `creator_id` varchar(128) NOT NULL, + `available_at` timestamp, + `due_at` timestamp, + `allow_late` boolean NOT NULL DEFAULT false, + `late_due_at` timestamp, + `max_attempts` int NOT NULL DEFAULT 1, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `homework_assignments_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `homework_submissions` ( + `id` varchar(128) NOT NULL, + `assignment_id` varchar(128) NOT NULL, + `student_id` varchar(128) NOT NULL, + `attempt_no` int NOT NULL DEFAULT 1, + `score` int, + `status` varchar(50) DEFAULT 'started', + `started_at` timestamp NOT NULL DEFAULT (now()), + `submitted_at` timestamp, + `is_late` boolean NOT NULL DEFAULT false, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `homework_submissions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +SET @__qkp_drop_qid := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.referential_constraints + WHERE constraint_schema = DATABASE() + AND constraint_name = 'questions_to_knowledge_points_question_id_questions_id_fk' + ), + 'ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_question_id_questions_id_fk`;', + 'SELECT 1;' + ) +);--> statement-breakpoint +PREPARE __stmt FROM @__qkp_drop_qid;--> statement-breakpoint +EXECUTE __stmt;--> statement-breakpoint +DEALLOCATE PREPARE __stmt;--> statement-breakpoint +SET @__qkp_drop_kpid := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.referential_constraints + WHERE constraint_schema = DATABASE() + AND constraint_name = 'questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk' + ), + 'ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`;', + 'SELECT 1;' + ) +);--> statement-breakpoint +PREPARE __stmt2 FROM @__qkp_drop_kpid;--> statement-breakpoint +EXECUTE __stmt2;--> statement-breakpoint +DEALLOCATE PREPARE __stmt2;--> statement-breakpoint +ALTER TABLE `homework_answers` ADD CONSTRAINT `hw_ans_sub_fk` FOREIGN KEY (`submission_id`) REFERENCES `homework_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homework_answers` ADD CONSTRAINT `hw_ans_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homework_assignment_questions` ADD CONSTRAINT `hw_aq_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homework_assignment_questions` ADD CONSTRAINT `hw_aq_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homework_assignment_targets` ADD CONSTRAINT `hw_at_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homework_assignment_targets` ADD CONSTRAINT `hw_at_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homework_assignments` ADD CONSTRAINT `hw_asg_exam_fk` FOREIGN KEY (`source_exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homework_assignments` ADD CONSTRAINT `hw_asg_creator_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homework_submissions` ADD CONSTRAINT `hw_sub_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homework_submissions` ADD CONSTRAINT `hw_sub_student_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +SET @__idx_hw_answer_submission := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'homework_answers' + AND index_name = 'hw_answer_submission_idx' + ), + 'SELECT 1;', + 'CREATE INDEX `hw_answer_submission_idx` ON `homework_answers` (`submission_id`);' + ) +);--> statement-breakpoint +PREPARE __stmt3 FROM @__idx_hw_answer_submission;--> statement-breakpoint +EXECUTE __stmt3;--> statement-breakpoint +DEALLOCATE PREPARE __stmt3;--> statement-breakpoint +SET @__idx_hw_answer_submission_question := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'homework_answers' + AND index_name = 'hw_answer_submission_question_idx' + ), + 'SELECT 1;', + 'CREATE INDEX `hw_answer_submission_question_idx` ON `homework_answers` (`submission_id`,`question_id`);' + ) +);--> statement-breakpoint +PREPARE __stmt4 FROM @__idx_hw_answer_submission_question;--> statement-breakpoint +EXECUTE __stmt4;--> statement-breakpoint +DEALLOCATE PREPARE __stmt4;--> statement-breakpoint +SET @__idx_hw_assignment_questions_assignment := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'homework_assignment_questions' + AND index_name = 'hw_assignment_questions_assignment_idx' + ), + 'SELECT 1;', + 'CREATE INDEX `hw_assignment_questions_assignment_idx` ON `homework_assignment_questions` (`assignment_id`);' + ) +);--> statement-breakpoint +PREPARE __stmt5 FROM @__idx_hw_assignment_questions_assignment;--> statement-breakpoint +EXECUTE __stmt5;--> statement-breakpoint +DEALLOCATE PREPARE __stmt5;--> statement-breakpoint +SET @__idx_hw_assignment_targets_assignment := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'homework_assignment_targets' + AND index_name = 'hw_assignment_targets_assignment_idx' + ), + 'SELECT 1;', + 'CREATE INDEX `hw_assignment_targets_assignment_idx` ON `homework_assignment_targets` (`assignment_id`);' + ) +);--> statement-breakpoint +PREPARE __stmt6 FROM @__idx_hw_assignment_targets_assignment;--> statement-breakpoint +EXECUTE __stmt6;--> statement-breakpoint +DEALLOCATE PREPARE __stmt6;--> statement-breakpoint +SET @__idx_hw_assignment_targets_student := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'homework_assignment_targets' + AND index_name = 'hw_assignment_targets_student_idx' + ), + 'SELECT 1;', + 'CREATE INDEX `hw_assignment_targets_student_idx` ON `homework_assignment_targets` (`student_id`);' + ) +);--> statement-breakpoint +PREPARE __stmt7 FROM @__idx_hw_assignment_targets_student;--> statement-breakpoint +EXECUTE __stmt7;--> statement-breakpoint +DEALLOCATE PREPARE __stmt7;--> statement-breakpoint +SET @__idx_hw_assignment_creator := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'homework_assignments' + AND index_name = 'hw_assignment_creator_idx' + ), + 'SELECT 1;', + 'CREATE INDEX `hw_assignment_creator_idx` ON `homework_assignments` (`creator_id`);' + ) +);--> statement-breakpoint +PREPARE __stmt8 FROM @__idx_hw_assignment_creator;--> statement-breakpoint +EXECUTE __stmt8;--> statement-breakpoint +DEALLOCATE PREPARE __stmt8;--> statement-breakpoint +SET @__idx_hw_assignment_source_exam := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'homework_assignments' + AND index_name = 'hw_assignment_source_exam_idx' + ), + 'SELECT 1;', + 'CREATE INDEX `hw_assignment_source_exam_idx` ON `homework_assignments` (`source_exam_id`);' + ) +);--> statement-breakpoint +PREPARE __stmt9 FROM @__idx_hw_assignment_source_exam;--> statement-breakpoint +EXECUTE __stmt9;--> statement-breakpoint +DEALLOCATE PREPARE __stmt9;--> statement-breakpoint +SET @__idx_hw_assignment_status := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'homework_assignments' + AND index_name = 'hw_assignment_status_idx' + ), + 'SELECT 1;', + 'CREATE INDEX `hw_assignment_status_idx` ON `homework_assignments` (`status`);' + ) +);--> statement-breakpoint +PREPARE __stmt10 FROM @__idx_hw_assignment_status;--> statement-breakpoint +EXECUTE __stmt10;--> statement-breakpoint +DEALLOCATE PREPARE __stmt10;--> statement-breakpoint +SET @__idx_hw_assignment_student := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'homework_submissions' + AND index_name = 'hw_assignment_student_idx' + ), + 'SELECT 1;', + 'CREATE INDEX `hw_assignment_student_idx` ON `homework_submissions` (`assignment_id`,`student_id`);' + ) +);--> statement-breakpoint +PREPARE __stmt11 FROM @__idx_hw_assignment_student;--> statement-breakpoint +EXECUTE __stmt11;--> statement-breakpoint +DEALLOCATE PREPARE __stmt11;--> statement-breakpoint +SET @__qkp_add_qid := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.referential_constraints + WHERE constraint_schema = DATABASE() + AND constraint_name = 'q_kp_qid_fk' + ), + 'SELECT 1;', + 'ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_qid_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;' + ) +);--> statement-breakpoint +PREPARE __stmt12 FROM @__qkp_add_qid;--> statement-breakpoint +EXECUTE __stmt12;--> statement-breakpoint +DEALLOCATE PREPARE __stmt12;--> statement-breakpoint +SET @__qkp_add_kpid := ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.referential_constraints + WHERE constraint_schema = DATABASE() + AND constraint_name = 'q_kp_kpid_fk' + ), + 'SELECT 1;', + 'ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_kpid_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;' + ) +);--> statement-breakpoint +PREPARE __stmt13 FROM @__qkp_add_kpid;--> statement-breakpoint +EXECUTE __stmt13;--> statement-breakpoint +DEALLOCATE PREPARE __stmt13; diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..d5869f3 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1884 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "e6118000-4093-4c16-a01c-e33a2a5f0875", + "prevId": "c1afed29-ad52-484d-a6a1-272b6dec6a24", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accounts_provider_providerAccountId_pk": { + "name": "accounts_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "chapters": { + "name": "chapters", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "textbook_id": { + "name": "textbook_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "textbook_idx": { + "name": "textbook_idx", + "columns": [ + "textbook_id" + ], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chapters_textbook_id_textbooks_id_fk": { + "name": "chapters_textbook_id_textbooks_id_fk", + "tableFrom": "chapters", + "tableTo": "textbooks", + "columnsFrom": [ + "textbook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chapters_id": { + "name": "chapters_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exam_questions": { + "name": "exam_questions", + "columns": { + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "exam_questions_exam_id_exams_id_fk": { + "name": "exam_questions_exam_id_exams_id_fk", + "tableFrom": "exam_questions", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_questions_question_id_questions_id_fk": { + "name": "exam_questions_question_id_questions_id_fk", + "tableFrom": "exam_questions", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_questions_exam_id_question_id_pk": { + "name": "exam_questions_exam_id_question_id_pk", + "columns": [ + "exam_id", + "question_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exam_submissions": { + "name": "exam_submissions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'started'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "exam_student_idx": { + "name": "exam_student_idx", + "columns": [ + "exam_id", + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "exam_submissions_exam_id_exams_id_fk": { + "name": "exam_submissions_exam_id_exams_id_fk", + "tableFrom": "exam_submissions", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_submissions_student_id_users_id_fk": { + "name": "exam_submissions_student_id_users_id_fk", + "tableFrom": "exam_submissions", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_submissions_id": { + "name": "exam_submissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exams": { + "name": "exams", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "exams_creator_id_users_id_fk": { + "name": "exams_creator_id_users_id_fk", + "tableFrom": "exams", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exams_id": { + "name": "exams_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_answers": { + "name": "homework_answers", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_id": { + "name": "submission_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer_content": { + "name": "answer_content", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "hw_answer_submission_idx": { + "name": "hw_answer_submission_idx", + "columns": [ + "submission_id" + ], + "isUnique": false + }, + "hw_answer_submission_question_idx": { + "name": "hw_answer_submission_question_idx", + "columns": [ + "submission_id", + "question_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_ans_sub_fk": { + "name": "hw_ans_sub_fk", + "tableFrom": "homework_answers", + "tableTo": "homework_submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_ans_q_fk": { + "name": "hw_ans_q_fk", + "tableFrom": "homework_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_answers_id": { + "name": "homework_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_assignment_questions": { + "name": "homework_assignment_questions", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "hw_assignment_questions_assignment_idx": { + "name": "hw_assignment_questions_assignment_idx", + "columns": [ + "assignment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_aq_a_fk": { + "name": "hw_aq_a_fk", + "tableFrom": "homework_assignment_questions", + "tableTo": "homework_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_aq_q_fk": { + "name": "hw_aq_q_fk", + "tableFrom": "homework_assignment_questions", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_assignment_questions_assignment_id_question_id_pk": { + "name": "homework_assignment_questions_assignment_id_question_id_pk", + "columns": [ + "assignment_id", + "question_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_assignment_targets": { + "name": "homework_assignment_targets", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "hw_assignment_targets_assignment_idx": { + "name": "hw_assignment_targets_assignment_idx", + "columns": [ + "assignment_id" + ], + "isUnique": false + }, + "hw_assignment_targets_student_idx": { + "name": "hw_assignment_targets_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_at_a_fk": { + "name": "hw_at_a_fk", + "tableFrom": "homework_assignment_targets", + "tableTo": "homework_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_at_s_fk": { + "name": "hw_at_s_fk", + "tableFrom": "homework_assignment_targets", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_assignment_targets_assignment_id_student_id_pk": { + "name": "homework_assignment_targets_assignment_id_student_id_pk", + "columns": [ + "assignment_id", + "student_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_assignments": { + "name": "homework_assignments", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_exam_id": { + "name": "source_exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_late": { + "name": "allow_late", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "late_due_at": { + "name": "late_due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_attempts": { + "name": "max_attempts", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "hw_assignment_creator_idx": { + "name": "hw_assignment_creator_idx", + "columns": [ + "creator_id" + ], + "isUnique": false + }, + "hw_assignment_source_exam_idx": { + "name": "hw_assignment_source_exam_idx", + "columns": [ + "source_exam_id" + ], + "isUnique": false + }, + "hw_assignment_status_idx": { + "name": "hw_assignment_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_asg_exam_fk": { + "name": "hw_asg_exam_fk", + "tableFrom": "homework_assignments", + "tableTo": "exams", + "columnsFrom": [ + "source_exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_asg_creator_fk": { + "name": "hw_asg_creator_fk", + "tableFrom": "homework_assignments", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_assignments_id": { + "name": "homework_assignments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_submissions": { + "name": "homework_submissions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assignment_id": { + "name": "assignment_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_no": { + "name": "attempt_no", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'started'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_late": { + "name": "is_late", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "hw_assignment_student_idx": { + "name": "hw_assignment_student_idx", + "columns": [ + "assignment_id", + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_sub_a_fk": { + "name": "hw_sub_a_fk", + "tableFrom": "homework_submissions", + "tableTo": "homework_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_sub_student_fk": { + "name": "hw_sub_student_fk", + "tableFrom": "homework_submissions", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_submissions_id": { + "name": "homework_submissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_points": { + "name": "knowledge_points", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_points_id": { + "name": "knowledge_points_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions": { + "name": "questions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('single_choice','multiple_choice','text','judgment','composite')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": [ + "author_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "questions_author_id_users_id_fk": { + "name": "questions_author_id_users_id_fk", + "tableFrom": "questions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_id": { + "name": "questions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions_to_knowledge_points": { + "name": "questions_to_knowledge_points", + "columns": { + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "knowledge_point_id": { + "name": "knowledge_point_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "kp_idx": { + "name": "kp_idx", + "columns": [ + "knowledge_point_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "q_kp_qid_fk": { + "name": "q_kp_qid_fk", + "tableFrom": "questions_to_knowledge_points", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "q_kp_kpid_fk": { + "name": "q_kp_kpid_fk", + "tableFrom": "questions_to_knowledge_points", + "tableTo": "knowledge_points", + "columnsFrom": [ + "knowledge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_to_knowledge_points_question_id_knowledge_point_id_pk": { + "name": "questions_to_knowledge_points_question_id_knowledge_point_id_pk", + "columns": [ + "question_id", + "knowledge_point_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_userId_users_id_fk": { + "name": "sessions_userId_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_sessionToken": { + "name": "sessions_sessionToken", + "columns": [ + "sessionToken" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "submission_answers": { + "name": "submission_answers", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_id": { + "name": "submission_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer_content": { + "name": "answer_content", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "submission_idx": { + "name": "submission_idx", + "columns": [ + "submission_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "submission_answers_submission_id_exam_submissions_id_fk": { + "name": "submission_answers_submission_id_exam_submissions_id_fk", + "tableFrom": "submission_answers", + "tableTo": "exam_submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submission_answers_question_id_questions_id_fk": { + "name": "submission_answers_question_id_questions_id_fk", + "tableFrom": "submission_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "submission_answers_id": { + "name": "submission_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "textbooks": { + "name": "textbooks", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade": { + "name": "grade", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "textbooks_id": { + "name": "textbooks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'student'" + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "users_to_roles": { + "name": "users_to_roles", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_roles_user_id_users_id_fk": { + "name": "users_to_roles_user_id_users_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_to_roles_role_id_roles_id_fk": { + "name": "users_to_roles_role_id_roles_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_roles_user_id_role_id_pk": { + "name": "users_to_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationTokens": { + "name": "verificationTokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationTokens_identifier_token_pk": { + "name": "verificationTokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d520528..b5eb1b4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1767004087964, "tag": "0001_flawless_texas_twister", "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1767145757594, + "tag": "0002_equal_wolfpack", + "breakpoints": true } ] } \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 585f758..9ba0063 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,7 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + "docs/scripts/**", ]), ]); diff --git a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx new file mode 100644 index 0000000..b4c3594 --- /dev/null +++ b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx @@ -0,0 +1,34 @@ +import { notFound } from "next/navigation" + +import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access" +import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view" +import { formatDate } from "@/shared/lib/utils" + +export default async function StudentAssignmentTakePage({ + params, +}: { + params: Promise<{ assignmentId: string }> +}) { + const { assignmentId } = await params + const student = await getDemoStudentUser() + if (!student) return notFound() + + const data = await getStudentHomeworkTakeData(assignmentId, student.id) + if (!data) return notFound() + + return ( +
+
+

{data.assignment.title}

+
+ Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"} + + Max Attempts: {data.assignment.maxAttempts} +
+
+ + +
+ ) +} + diff --git a/src/app/(dashboard)/student/learning/assignments/page.tsx b/src/app/(dashboard)/student/learning/assignments/page.tsx new file mode 100644 index 0000000..73540a4 --- /dev/null +++ b/src/app/(dashboard)/student/learning/assignments/page.tsx @@ -0,0 +1,105 @@ +import Link from "next/link" + +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" +import { getDemoStudentUser, getStudentHomeworkAssignments } from "@/modules/homework/data-access" +import { Inbox } from "lucide-react" + +const getStatusVariant = (status: string): "default" | "secondary" | "outline" => { + if (status === "graded") return "default" + if (status === "submitted") return "secondary" + if (status === "in_progress") return "secondary" + return "outline" +} + +const getStatusLabel = (status: string) => { + if (status === "graded") return "Graded" + if (status === "submitted") return "Submitted" + if (status === "in_progress") return "In progress" + return "Not started" +} + +export default async function StudentAssignmentsPage() { + const student = await getDemoStudentUser() + + if (!student) { + return ( +
+
+
+

Assignments

+

Your homework assignments.

+
+
+ +
+ ) + } + + const assignments = await getStudentHomeworkAssignments(student.id) + const hasAssignments = assignments.length > 0 + + return ( +
+
+
+

Assignments

+

Your homework assignments.

+
+ +
+ + {!hasAssignments ? ( + + ) : ( +
+ + + + Title + Status + Due + Attempts + Score + + + + {assignments.map((a) => ( + + + + {a.title} + + + + + {getStatusLabel(a.progressStatus)} + + + {a.dueAt ? formatDate(a.dueAt) : "-"} + + {a.attemptsUsed}/{a.maxAttempts} + + {a.latestScore ?? "-"} + + ))} + +
+
+ )} +
+ ) +} + diff --git a/src/app/(dashboard)/teacher/exams/all/page.tsx b/src/app/(dashboard)/teacher/exams/all/page.tsx index 2001c7c..05d3473 100644 --- a/src/app/(dashboard)/teacher/exams/all/page.tsx +++ b/src/app/(dashboard)/teacher/exams/all/page.tsx @@ -57,9 +57,6 @@ async function ExamsResults({ searchParams }: { searchParams: PromiseArchived {counts.archived}
- + +
+ + +
+ + + Targets + + +
{assignment.targetCount}
+
+
+ + + + Submissions + + +
{assignment.submissionCount}
+
+
+ + + + Due + + +
+
{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}
+
+ Late: {assignment.allowLate ? (assignment.lateDueAt ? formatDate(assignment.lateDueAt) : "Allowed") : "Not allowed"} +
+
+
+
+
+ + ) +} + diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx new file mode 100644 index 0000000..23c09de --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx @@ -0,0 +1,73 @@ +import Link from "next/link" +import { notFound } from "next/navigation" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" +import { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/homework/data-access" + +export default async function HomeworkAssignmentSubmissionsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const assignment = await getHomeworkAssignmentById(id) + if (!assignment) return notFound() + + const submissions = await getHomeworkSubmissions({ assignmentId: id }) + + return ( +
+
+
+

Submissions

+

{assignment.title}

+
+
+ +
+
+ +
+ + + + Student + Status + Submitted + Score + Action + + + + {submissions.map((s) => ( + + {s.studentName} + + + {s.status} + + {s.isLate ? Late : null} + + {s.submittedAt ? formatDate(s.submittedAt) : "-"} + {typeof s.score === "number" ? s.score : "-"} + + + Grade + + + + ))} + +
+
+
+ ) +} + diff --git a/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx new file mode 100644 index 0000000..0e03fab --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx @@ -0,0 +1,31 @@ +import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-assignment-form" +import { getExams } from "@/modules/exams/data-access" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { FileQuestion } from "lucide-react" + +export default async function CreateHomeworkAssignmentPage() { + const exams = await getExams({}) + const options = exams.map((e) => ({ id: e.id, title: e.title })) + + return ( +
+
+
+

Create Assignment

+

Dispatch homework from an existing exam.

+
+
+ + {options.length === 0 ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/page.tsx index 685c0ba..4f4c54e 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/page.tsx @@ -1,7 +1,23 @@ +import Link from "next/link" import { EmptyState } from "@/shared/components/ui/empty-state" -import { PenTool } from "lucide-react" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" +import { getHomeworkAssignments } from "@/modules/homework/data-access" +import { PenTool, PlusCircle } from "lucide-react" + +export default async function AssignmentsPage() { + const assignments = await getHomeworkAssignments() + const hasAssignments = assignments.length > 0 -export default function AssignmentsPage() { return (
@@ -11,16 +27,58 @@ export default function AssignmentsPage() { Manage homework assignments.

+
- + + {!hasAssignments ? ( + + ) : ( +
+ + + + Title + Status + Due + Source Exam + Created + + + + {assignments.map((a) => ( + + + + {a.title} + + + + + {a.status} + + + {a.dueAt ? formatDate(a.dueAt) : "-"} + {a.sourceExamTitle} + {formatDate(a.createdAt)} + + ))} + +
+
+ )} ) } diff --git a/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx new file mode 100644 index 0000000..aa7478e --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx @@ -0,0 +1,41 @@ +import { notFound } from "next/navigation" +import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access" +import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view" +import { formatDate } from "@/shared/lib/utils" + +export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }) { + const { submissionId } = await params + const submission = await getHomeworkSubmissionDetails(submissionId) + + if (!submission) return notFound() + + return ( +
+
+
+

{submission.assignmentTitle}

+
+ + Student: {submission.studentName} + + + Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"} + + Status: {submission.status} +
+
+
+ + +
+ ) +} + diff --git a/src/app/(dashboard)/teacher/homework/submissions/page.tsx b/src/app/(dashboard)/teacher/homework/submissions/page.tsx index 9b77b42..2f57a03 100644 --- a/src/app/(dashboard)/teacher/homework/submissions/page.tsx +++ b/src/app/(dashboard)/teacher/homework/submissions/page.tsx @@ -1,7 +1,22 @@ +import Link from "next/link" import { EmptyState } from "@/shared/components/ui/empty-state" +import { Badge } from "@/shared/components/ui/badge" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" +import { getHomeworkSubmissions } from "@/modules/homework/data-access" import { Inbox } from "lucide-react" -export default function SubmissionsPage() { +export default async function SubmissionsPage() { + const submissions = await getHomeworkSubmissions() + const hasSubmissions = submissions.length > 0 + return (
@@ -12,11 +27,48 @@ export default function SubmissionsPage() {

- + + {!hasSubmissions ? ( + + ) : ( +
+ + + + Assignment + Student + Status + Submitted + Score + + + + {submissions.map((s) => ( + + + + {s.assignmentTitle} + + + {s.studentName} + + + {s.status} + + {s.isLate ? Late : null} + + {s.submittedAt ? formatDate(s.submittedAt) : "-"} + {typeof s.score === "number" ? s.score : "-"} + + ))} + +
+
+ )} ) } diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts index 5af7b4e..adcb29f 100644 --- a/src/modules/exams/actions.ts +++ b/src/modules/exams/actions.ts @@ -5,7 +5,7 @@ import { ActionState } from "@/shared/types/action-state" import { z } from "zod" import { createId } from "@paralleldrive/cuid2" import { db } from "@/shared/db" -import { exams, examQuestions, submissionAnswers, examSubmissions } from "@/shared/db/schema" +import { exams, examQuestions } from "@/shared/db/schema" import { eq } from "drizzle-orm" const ExamCreateSchema = z.object({ @@ -206,7 +206,6 @@ export async function deleteExamAction( } revalidatePath("/teacher/exams/all") - revalidatePath("/teacher/exams/grading") return { success: true, @@ -310,76 +309,6 @@ export async function duplicateExamAction( } } -const GradingSchema = z.object({ - submissionId: z.string().min(1), - answers: z.array(z.object({ - id: z.string(), // answer id - score: z.coerce.number().min(0), - feedback: z.string().optional() - })) -}) - -export async function gradeSubmissionAction( - prevState: ActionState | null, - formData: FormData -): Promise> { - const rawAnswers = formData.get("answersJson") as string | null - const parsed = GradingSchema.safeParse({ - submissionId: formData.get("submissionId"), - answers: rawAnswers ? JSON.parse(rawAnswers) : [] - }) - - if (!parsed.success) { - return { - success: false, - message: "Invalid grading data", - errors: parsed.error.flatten().fieldErrors - } - } - - const { submissionId, answers } = parsed.data - - try { - let totalScore = 0 - - // Update each answer - for (const ans of answers) { - await db.update(submissionAnswers) - .set({ - score: ans.score, - feedback: ans.feedback, - updatedAt: new Date() - }) - .where(eq(submissionAnswers.id, ans.id)) - - totalScore += ans.score - } - - // Update submission total score and status - await db.update(examSubmissions) - .set({ - score: totalScore, - status: "graded", - updatedAt: new Date() - }) - .where(eq(examSubmissions.id, submissionId)) - - } catch (error) { - console.error("Grading failed:", error) - return { - success: false, - message: "Database error during grading" - } - } - - revalidatePath(`/teacher/exams/grading`) - - return { - success: true, - message: "Grading saved successfully" - } -} - async function getCurrentUser() { return { id: "user_teacher_123", role: "teacher" } } diff --git a/src/modules/exams/components/assembly/exam-paper-preview.tsx b/src/modules/exams/components/assembly/exam-paper-preview.tsx index 0827b85..13df658 100644 --- a/src/modules/exams/components/assembly/exam-paper-preview.tsx +++ b/src/modules/exams/components/assembly/exam-paper-preview.tsx @@ -1,6 +1,5 @@ "use client" -import React from "react" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog" import { Button } from "@/shared/components/ui/button" diff --git a/src/modules/exams/components/exam-assembly.tsx b/src/modules/exams/components/exam-assembly.tsx index 0000020..19f4997 100644 --- a/src/modules/exams/components/exam-assembly.tsx +++ b/src/modules/exams/components/exam-assembly.tsx @@ -6,14 +6,12 @@ import { useRouter } from "next/navigation" import { toast } from "sonner" import { Search } from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" -import { Label } from "@/shared/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { Separator } from "@/shared/components/ui/separator" -import { Badge } from "@/shared/components/ui/badge" import type { Question } from "@/modules/questions/types" import { updateExamAction } from "@/modules/exams/actions" import { StructureEditor } from "./assembly/structure-editor" diff --git a/src/modules/exams/components/submission-columns.tsx b/src/modules/exams/components/submission-columns.tsx deleted file mode 100644 index ffeee95..0000000 --- a/src/modules/exams/components/submission-columns.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" - -import { ColumnDef } from "@tanstack/react-table" -import { Badge } from "@/shared/components/ui/badge" -import { Button } from "@/shared/components/ui/button" -import { Eye, CheckSquare } from "lucide-react" -import { ExamSubmission } from "../types" -import Link from "next/link" -import { formatDate } from "@/shared/lib/utils" - -export const submissionColumns: ColumnDef[] = [ - { - accessorKey: "studentName", - header: "Student", - }, - { - accessorKey: "examTitle", - header: "Exam", - }, - { - accessorKey: "submittedAt", - header: "Submitted", - cell: ({ row }) => ( - - {formatDate(row.original.submittedAt)} - - ), - }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => { - const status = row.original.status - const variant = status === "graded" ? "secondary" : "outline" - return {status} - }, - }, - { - accessorKey: "score", - header: "Score", - cell: ({ row }) => ( - {row.original.score ?? "-"} - ), - }, - { - id: "actions", - cell: ({ row }) => ( -
- - -
- ), - }, -] diff --git a/src/modules/exams/components/submission-data-table.tsx b/src/modules/exams/components/submission-data-table.tsx deleted file mode 100644 index a9db4f7..0000000 --- a/src/modules/exams/components/submission-data-table.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client" - -import * as React from "react" -import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, -} from "@tanstack/react-table" - -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/shared/components/ui/table" -import { Button } from "@/shared/components/ui/button" -import { ChevronLeft, ChevronRight } from "lucide-react" - -interface DataTableProps { - columns: ColumnDef[] - data: TData[] -} - -export function SubmissionDataTable({ columns, data }: DataTableProps) { - const [sorting, setSorting] = React.useState([]) - - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - state: { - sorting, - }, - }) - - return ( -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} - ))} - - )) - ) : ( - - - No submissions. - - - )} - -
-
-
-
- - -
-
-
- ) -} - diff --git a/src/modules/exams/data-access.ts b/src/modules/exams/data-access.ts index 7ac2357..53ce28c 100644 --- a/src/modules/exams/data-access.ts +++ b/src/modules/exams/data-access.ts @@ -1,5 +1,5 @@ import { db } from "@/shared/db" -import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema" +import { exams } from "@/shared/db/schema" import { eq, desc, like, and, or } from "drizzle-orm" import { cache } from "react" @@ -137,82 +137,3 @@ export const getExamById = cache(async (id: string) => { })), } }) - -export const getExamSubmissions = cache(async () => { - const data = await db.query.examSubmissions.findMany({ - orderBy: [desc(examSubmissions.submittedAt)], - with: { - exam: true, - student: true - } - }) - - return data.map(sub => ({ - id: sub.id, - examId: sub.examId, - examTitle: sub.exam.title, - studentName: sub.student.name || "Unknown", - submittedAt: sub.submittedAt ? sub.submittedAt.toISOString() : new Date().toISOString(), - score: sub.score || undefined, - status: sub.status as "pending" | "graded", - })) -}) - -export const getSubmissionDetails = cache(async (submissionId: string) => { - const submission = await db.query.examSubmissions.findFirst({ - where: eq(examSubmissions.id, submissionId), - with: { - student: true, - exam: true, - } - }) - - if (!submission) return null - - // Fetch answers - const answers = await db.query.submissionAnswers.findMany({ - where: eq(submissionAnswers.submissionId, submissionId), - with: { - question: true - } - }) - - // Fetch exam questions structure (to know max score and order) - const examQ = await db.query.examQuestions.findMany({ - where: eq(examQuestions.examId, submission.examId), - orderBy: [desc(examQuestions.order)], - }) - - type QuestionContent = { text?: string } & Record - - const toQuestionContent = (v: unknown): QuestionContent | null => { - if (!isRecord(v)) return null - return v as QuestionContent - } - - // Map answers with question details - const answersWithDetails = answers.map(ans => { - const eqRel = examQ.find(q => q.questionId === ans.questionId) - return { - id: ans.id, - questionId: ans.questionId, - questionContent: toQuestionContent(ans.question.content), - questionType: ans.question.type, - maxScore: eqRel?.score || 0, - studentAnswer: ans.answerContent, - score: ans.score, - feedback: ans.feedback, - order: eqRel?.order || 0 - } - }).sort((a, b) => a.order - b.order) - - return { - id: submission.id, - studentName: submission.student.name || "Unknown", - examTitle: submission.exam.title, - submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null, - status: submission.status, - totalScore: submission.score, - answers: answersWithDetails - } -}) diff --git a/src/modules/homework/actions.ts b/src/modules/homework/actions.ts new file mode 100644 index 0000000..d7ebb4c --- /dev/null +++ b/src/modules/homework/actions.ts @@ -0,0 +1,366 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { headers } from "next/headers" +import { createId } from "@paralleldrive/cuid2" +import { and, count, eq } from "drizzle-orm" + +import { db } from "@/shared/db" +import { + exams, + homeworkAnswers, + homeworkAssignmentQuestions, + homeworkAssignmentTargets, + homeworkAssignments, + homeworkSubmissions, + users, +} from "@/shared/db/schema" +import type { ActionState } from "@/shared/types/action-state" + +import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema" + +type CurrentUser = { id: string; role: "admin" | "teacher" | "student" } + +async function getCurrentUser() { + const ref = (await headers()).get("referer") || "" + const roleHint: CurrentUser["role"] = ref.includes("/admin/") + ? "admin" + : ref.includes("/student/") + ? "student" + : ref.includes("/teacher/") + ? "teacher" + : "teacher" + + const byRole = await db.query.users.findFirst({ + where: eq(users.role, roleHint), + orderBy: (u, { asc }) => [asc(u.createdAt)], + }) + + if (byRole) return { id: byRole.id, role: roleHint } + + const anyUser = await db.query.users.findFirst({ + orderBy: (u, { asc }) => [asc(u.createdAt)], + }) + + if (anyUser) return { id: anyUser.id, role: roleHint } + + return { id: "user_teacher_123", role: roleHint } +} + +async function ensureTeacher() { + const user = await getCurrentUser() + if (!user || (user.role !== "teacher" && user.role !== "admin")) throw new Error("Unauthorized") + return user +} + +async function ensureStudent() { + const user = await getCurrentUser() + if (!user || user.role !== "student") throw new Error("Unauthorized") + return user +} + +const parseStudentIds = (raw: string): string[] => { + return raw + .split(/[,\n\r\t ]+/g) + .map((s) => s.trim()) + .filter((s) => s.length > 0) +} + +export async function createHomeworkAssignmentAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const user = await ensureTeacher() + + const targetStudentIdsJson = formData.get("targetStudentIdsJson") + const targetStudentIdsText = formData.get("targetStudentIdsText") + + const parsed = CreateHomeworkAssignmentSchema.safeParse({ + sourceExamId: formData.get("sourceExamId"), + title: formData.get("title") || undefined, + description: formData.get("description") || undefined, + availableAt: formData.get("availableAt") || undefined, + dueAt: formData.get("dueAt") || undefined, + allowLate: formData.get("allowLate") || undefined, + lateDueAt: formData.get("lateDueAt") || undefined, + maxAttempts: formData.get("maxAttempts") || undefined, + publish: formData.get("publish") || undefined, + targetStudentIds: + typeof targetStudentIdsJson === "string" && targetStudentIdsJson.length > 0 + ? (JSON.parse(targetStudentIdsJson) as unknown) + : typeof targetStudentIdsText === "string" && targetStudentIdsText.trim().length > 0 + ? parseStudentIds(targetStudentIdsText) + : undefined, + }) + + if (!parsed.success) { + return { + success: false, + message: "Invalid form data", + errors: parsed.error.flatten().fieldErrors, + } + } + + const input = parsed.data + const publish = input.publish ?? true + + const exam = await db.query.exams.findFirst({ + where: eq(exams.id, input.sourceExamId), + with: { + questions: { + orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)], + }, + }, + }) + + if (!exam) return { success: false, message: "Exam not found" } + + const assignmentId = createId() + + const availableAt = input.availableAt ? new Date(input.availableAt) : null + const dueAt = input.dueAt ? new Date(input.dueAt) : null + const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null + + const targetStudentIds = + input.targetStudentIds && input.targetStudentIds.length > 0 + ? input.targetStudentIds + : ( + await db + .select({ id: users.id }) + .from(users) + .where(eq(users.role, "student")) + ).map((r) => r.id) + + await db.transaction(async (tx) => { + await tx.insert(homeworkAssignments).values({ + id: assignmentId, + sourceExamId: input.sourceExamId, + title: input.title?.trim().length ? input.title.trim() : exam.title, + description: input.description ?? null, + structure: publish ? (exam.structure as unknown) : null, + status: publish ? "published" : "draft", + creatorId: user.id, + availableAt, + dueAt, + allowLate: input.allowLate ?? false, + lateDueAt, + maxAttempts: input.maxAttempts ?? 1, + }) + + if (publish && exam.questions.length > 0) { + await tx.insert(homeworkAssignmentQuestions).values( + exam.questions.map((q) => ({ + assignmentId, + questionId: q.questionId, + score: q.score ?? 0, + order: q.order ?? 0, + })) + ) + } + + if (publish && targetStudentIds.length > 0) { + await tx.insert(homeworkAssignmentTargets).values( + targetStudentIds.map((studentId) => ({ + assignmentId, + studentId, + })) + ) + } + }) + + revalidatePath("/teacher/homework/assignments") + revalidatePath("/teacher/homework/submissions") + + return { success: true, message: "Assignment created", data: assignmentId } + } catch (error) { + if (error instanceof Error) return { success: false, message: error.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function startHomeworkSubmissionAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const user = await ensureStudent() + const assignmentId = formData.get("assignmentId") + if (typeof assignmentId !== "string" || assignmentId.length === 0) return { success: false, message: "Missing assignmentId" } + + const assignment = await db.query.homeworkAssignments.findFirst({ + where: eq(homeworkAssignments.id, assignmentId), + }) + if (!assignment) return { success: false, message: "Assignment not found" } + if (assignment.status !== "published") return { success: false, message: "Assignment not available" } + + const target = await db.query.homeworkAssignmentTargets.findFirst({ + where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, user.id)), + }) + if (!target) return { success: false, message: "Not assigned" } + + if (assignment.availableAt && assignment.availableAt > new Date()) return { success: false, message: "Not available yet" } + + const [attemptRow] = await db + .select({ c: count() }) + .from(homeworkSubmissions) + .where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, user.id))) + + const attemptNo = (attemptRow?.c ?? 0) + 1 + if (attemptNo > assignment.maxAttempts) return { success: false, message: "No attempts left" } + + const submissionId = createId() + await db.insert(homeworkSubmissions).values({ + id: submissionId, + assignmentId, + studentId: user.id, + attemptNo, + status: "started", + startedAt: new Date(), + }) + + revalidatePath("/student/learning/assignments") + + return { success: true, message: "Started", data: submissionId } + } catch (error) { + if (error instanceof Error) return { success: false, message: error.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function saveHomeworkAnswerAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const user = await ensureStudent() + const submissionId = formData.get("submissionId") + const questionId = formData.get("questionId") + const answerJson = formData.get("answerJson") + if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" } + if (typeof questionId !== "string" || questionId.length === 0) return { success: false, message: "Missing questionId" } + + const submission = await db.query.homeworkSubmissions.findFirst({ + where: eq(homeworkSubmissions.id, submissionId), + with: { assignment: true }, + }) + if (!submission) return { success: false, message: "Submission not found" } + if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" } + if (submission.status !== "started") return { success: false, message: "Submission is locked" } + + const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null + + await db.transaction(async (tx) => { + const existing = await tx.query.homeworkAnswers.findFirst({ + where: and(eq(homeworkAnswers.submissionId, submissionId), eq(homeworkAnswers.questionId, questionId)), + }) + + if (existing) { + await tx + .update(homeworkAnswers) + .set({ answerContent: payload, updatedAt: new Date() }) + .where(eq(homeworkAnswers.id, existing.id)) + } else { + await tx.insert(homeworkAnswers).values({ + id: createId(), + submissionId, + questionId, + answerContent: payload, + }) + } + }) + + return { success: true, message: "Saved", data: submissionId } + } catch (error) { + if (error instanceof Error) return { success: false, message: error.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function submitHomeworkAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const user = await ensureStudent() + const submissionId = formData.get("submissionId") + if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" } + + const submission = await db.query.homeworkSubmissions.findFirst({ + where: eq(homeworkSubmissions.id, submissionId), + with: { assignment: true }, + }) + if (!submission) return { success: false, message: "Submission not found" } + if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" } + if (submission.status !== "started") return { success: false, message: "Already submitted" } + + const now = new Date() + const dueAt = submission.assignment.dueAt + const allowLate = submission.assignment.allowLate + const lateDueAt = submission.assignment.lateDueAt + + if (dueAt && now > dueAt && !allowLate) return { success: false, message: "Past due" } + if (allowLate && lateDueAt && now > lateDueAt) return { success: false, message: "Past late due" } + + const isLate = Boolean(dueAt && now > dueAt) + + await db + .update(homeworkSubmissions) + .set({ status: "submitted", submittedAt: now, isLate, updatedAt: now }) + .where(eq(homeworkSubmissions.id, submissionId)) + + revalidatePath("/teacher/homework/submissions") + revalidatePath("/student/learning/assignments") + + return { success: true, message: "Submitted", data: submissionId } + } catch (error) { + if (error instanceof Error) return { success: false, message: error.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function gradeHomeworkSubmissionAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + await ensureTeacher() + + const rawAnswers = formData.get("answersJson") as string | null + const parsed = GradeHomeworkSchema.safeParse({ + submissionId: formData.get("submissionId"), + answers: rawAnswers ? JSON.parse(rawAnswers) : [], + }) + + if (!parsed.success) { + return { + success: false, + message: "Invalid grading data", + errors: parsed.error.flatten().fieldErrors, + } + } + + const { submissionId, answers } = parsed.data + let totalScore = 0 + + for (const ans of answers) { + await db + .update(homeworkAnswers) + .set({ score: ans.score, feedback: ans.feedback ?? null, updatedAt: new Date() }) + .where(eq(homeworkAnswers.id, ans.id)) + totalScore += ans.score + } + + await db + .update(homeworkSubmissions) + .set({ score: totalScore, status: "graded", updatedAt: new Date() }) + .where(eq(homeworkSubmissions.id, submissionId)) + + revalidatePath("/teacher/homework/submissions") + + return { success: true, message: "Grading saved" } + } catch (error) { + if (error instanceof Error) return { success: false, message: error.message } + return { success: false, message: "Unexpected error" } + } +} diff --git a/src/modules/homework/components/homework-assignment-form.tsx b/src/modules/homework/components/homework-assignment-form.tsx new file mode 100644 index 0000000..0ab859c --- /dev/null +++ b/src/modules/homework/components/homework-assignment-form.tsx @@ -0,0 +1,138 @@ +"use client" + +import { useMemo, useState } from "react" +import { useFormStatus } from "react-dom" +import { toast } from "sonner" +import { useRouter } from "next/navigation" + +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" +import { Textarea } from "@/shared/components/ui/textarea" + +import { createHomeworkAssignmentAction } from "../actions" + +type ExamOption = { id: string; title: string } + +function SubmitButton() { + const { pending } = useFormStatus() + return ( + + ) +} + +export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) { + const router = useRouter() + + const initialExamId = useMemo(() => exams[0]?.id ?? "", [exams]) + const [examId, setExamId] = useState(initialExamId) + const [allowLate, setAllowLate] = useState(false) + + const handleSubmit = async (formData: FormData) => { + if (!examId) { + toast.error("Please select an exam") + return + } + formData.set("sourceExamId", examId) + formData.set("allowLate", allowLate ? "true" : "false") + formData.set("publish", "true") + + const result = await createHomeworkAssignmentAction(null, formData) + if (result.success) { + toast.success(result.message) + router.push("/teacher/homework/assignments") + } else { + toast.error(result.message || "Failed to create") + } + } + + return ( + + + Create Assignment + + +
+
+
+ + + +
+ +
+ + +
+ +
+ +