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}
-
)
}
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 }) => (
-
-
-
- View
-
-
-
-
- Grade
-
-
-
- ),
- },
-]
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.
-
-
- )}
-
-
-
-
-
- table.previousPage()} disabled={!table.getCanPreviousPage()}>
-
- Previous
-
- table.nextPage()} disabled={!table.getCanNextPage()}>
- Next
-
-
-
-
-
- )
-}
-
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 (
+
+ {pending ? "Creating..." : "Create Assignment"}
+
+ )
+}
+
+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
+
+
+
+
+
+ )
+}
+
diff --git a/src/modules/exams/components/grading-view.tsx b/src/modules/homework/components/homework-grading-view.tsx
similarity index 86%
rename from src/modules/exams/components/grading-view.tsx
rename to src/modules/homework/components/homework-grading-view.tsx
index 6471784..4c974b7 100644
--- a/src/modules/exams/components/grading-view.tsx
+++ b/src/modules/homework/components/homework-grading-view.tsx
@@ -11,7 +11,7 @@ import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
-import { gradeSubmissionAction } from "../actions"
+import { gradeHomeworkSubmissionAction } from "../actions"
const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null
@@ -29,57 +29,52 @@ type Answer = {
order: number
}
-type GradingViewProps = {
+type HomeworkGradingViewProps = {
submissionId: string
studentName: string
- examTitle: string
+ assignmentTitle: string
submittedAt: string | null
status: string
totalScore: number | null
answers: Answer[]
}
-export function GradingView({
+export function HomeworkGradingView({
submissionId,
- studentName,
- examTitle,
- submittedAt,
- status,
- totalScore,
- answers: initialAnswers
-}: GradingViewProps) {
+ answers: initialAnswers,
+}: HomeworkGradingViewProps) {
const router = useRouter()
const [answers, setAnswers] = useState(initialAnswers)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleScoreChange = (id: string, val: string) => {
const score = val === "" ? 0 : parseInt(val)
- setAnswers(prev => prev.map(a => a.id === id ? { ...a, score } : a))
+ setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score } : a)))
}
const handleFeedbackChange = (id: string, val: string) => {
- setAnswers(prev => prev.map(a => a.id === id ? { ...a, feedback: val } : a))
+ setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, feedback: val } : a)))
}
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
const handleSubmit = async () => {
setIsSubmitting(true)
- const payload = answers.map(a => ({
+ const payload = answers.map((a) => ({
id: a.id,
score: a.score || 0,
- feedback: a.feedback
+ feedback: a.feedback,
}))
const formData = new FormData()
formData.set("submissionId", submissionId)
formData.set("answersJson", JSON.stringify(payload))
- const result = await gradeSubmissionAction(null, formData)
-
+ const result = await gradeHomeworkSubmissionAction(null, formData)
+
if (result.success) {
toast.success("Grading saved")
- router.push("/teacher/exams/grading")
+ router.push("/teacher/homework/submissions")
} else {
toast.error(result.message || "Failed to save")
}
@@ -88,7 +83,6 @@ export function GradingView({
return (
- {/* Left: Questions & Answers */}
Student Response
@@ -101,7 +95,6 @@ export function GradingView({
Question {index + 1}
{ans.questionContent?.text}
- {/* Render options if multiple choice, etc. - Simplified for now */}
Max: {ans.maxScore}
@@ -114,7 +107,7 @@ export function GradingView({
: JSON.stringify(ans.studentAnswer)}
-
+
))}
@@ -122,7 +115,6 @@ export function GradingView({
- {/* Right: Grading Panel */}
Grading
@@ -131,7 +123,7 @@ export function GradingView({
{currentTotal}
-
+
{answers.map((ans, index) => (
@@ -145,10 +137,10 @@ export function GradingView({
- handleScoreChange(ans.id, e.target.value)}
@@ -156,7 +148,7 @@ export function GradingView({
-