Merge exams grading into homework
Some checks failed
CI / build-and-test (push) Failing after 3m34s
CI / deploy (push) Has been skipped

Redirect /teacher/exams/grading* to /teacher/homework/submissions; remove exam grading UI/actions/data-access; add homework student workflow and update design docs.
This commit is contained in:
SpecialX
2025-12-31 11:59:03 +08:00
parent f8e39f518d
commit 13e91e628d
36 changed files with 4491 additions and 452 deletions

View File

@@ -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.

View File

@@ -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。

View File

@@ -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`:创建一次 submissionattemptNo + 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与本模块新增功能无直接关联

View File

@@ -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);
});

View File

@@ -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;
ALTER TABLE `exams` ADD `structure` json;

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -17,6 +17,7 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
"docs/scripts/**",
]),
]);

View File

@@ -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 (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
<div className="text-sm text-muted-foreground">
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
<span className="mx-2"></span>
<span>Max Attempts: {data.assignment.maxAttempts}</span>
</div>
</div>
<HomeworkTakeView assignmentId={data.assignment.id} initialData={data} />
</div>
)
}

View File

@@ -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 (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
<p className="text-muted-foreground">Your homework assignments.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to see assignments." icon={Inbox} />
</div>
)
}
const assignments = await getStudentHomeworkAssignments(student.id)
const hasAssignments = assignments.length > 0
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
<p className="text-muted-foreground">Your homework assignments.</p>
</div>
<Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link>
</Button>
</div>
{!hasAssignments ? (
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead>Attempts</TableHead>
<TableHead>Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{a.attemptsUsed}/{a.maxAttempts}
</TableCell>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)
}

View File

@@ -57,9 +57,6 @@ async function ExamsResults({ searchParams }: { searchParams: Promise<SearchPara
<Badge variant="outline">Archived {counts.archived}</Badge>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/teacher/exams/grading">Go to Grading</Link>
</Button>
<Button asChild size="sm">
<Link href="/teacher/exams/create" className="inline-flex items-center gap-2">
<PlusCircle className="h-4 w-4" />

View File

@@ -1,40 +1,6 @@
import { notFound } from "next/navigation"
import { GradingView } from "@/modules/exams/components/grading-view"
import { getSubmissionDetails } from "@/modules/exams/data-access"
import { formatDate } from "@/shared/lib/utils"
import { redirect } from "next/navigation"
export default async function SubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }) {
const { submissionId } = await params
const submission = await getSubmissionDetails(submissionId)
if (!submission) {
return notFound()
}
return (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">{submission.examTitle}</h2>
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
<span>Student: <span className="font-medium text-foreground">{submission.studentName}</span></span>
<span></span>
<span>Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"}</span>
<span></span>
<span className="capitalize">Status: {submission.status}</span>
</div>
</div>
</div>
<GradingView
submissionId={submission.id}
studentName={submission.studentName}
examTitle={submission.examTitle}
submittedAt={submission.submittedAt}
status={submission.status || "started"}
totalScore={submission.totalScore}
answers={submission.answers}
/>
</div>
)
await params
redirect("/teacher/homework/submissions")
}

View File

@@ -1,22 +1,5 @@
import { SubmissionDataTable } from "@/modules/exams/components/submission-data-table"
import { submissionColumns } from "@/modules/exams/components/submission-columns"
import { getExamSubmissions } from "@/modules/exams/data-access"
import { redirect } from "next/navigation"
export default async function ExamGradingPage() {
const submissions = await getExamSubmissions()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grading</h2>
<p className="text-muted-foreground">Grade student exam submissions.</p>
</div>
</div>
<div className="rounded-md border bg-card">
<SubmissionDataTable columns={submissionColumns} data={submissions} />
</div>
</div>
)
redirect("/teacher/homework/submissions")
}

View File

@@ -0,0 +1,79 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { getHomeworkAssignmentById } from "@/modules/homework/data-access"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const assignment = await getHomeworkAssignmentById(id)
if (!assignment) return notFound()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold tracking-tight">{assignment.title}</h2>
<Badge variant="outline" className="capitalize">
{assignment.status}
</Badge>
</div>
<p className="text-muted-foreground mt-1">{assignment.description || "—"}</p>
<div className="mt-2 text-sm text-muted-foreground">
<span>Source Exam: {assignment.sourceExamTitle}</span>
<span className="mx-2"></span>
<span>Created: {formatDate(assignment.createdAt)}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/homework/assignments">Back</Link>
</Button>
<Button asChild>
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>View Submissions</Link>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Targets</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{assignment.targetCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Submissions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{assignment.submissionCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Due</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm">
<div className="font-medium">{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}</div>
<div className="text-muted-foreground">
Late: {assignment.allowLate ? (assignment.lateDueAt ? formatDate(assignment.lateDueAt) : "Allowed") : "Not allowed"}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/homework/data-access"
export default async function HomeworkAssignmentSubmissionsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const assignment = await getHomeworkAssignmentById(id)
if (!assignment) return notFound()
const submissions = await getHomeworkSubmissions({ assignmentId: id })
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
<p className="text-muted-foreground">{assignment.title}</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={`/teacher/homework/assignments/${id}`}>Back</Link>
</Button>
</div>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Score</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissions.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.studentName}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
{s.isLate ? <span className="ml-2 text-xs text-destructive">Late</span> : null}
</TableCell>
<TableCell className="text-muted-foreground">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell>{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell>
<Link href={`/teacher/homework/submissions/${s.id}`} className="text-sm underline-offset-4 hover:underline">
Grade
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-assignment-form"
import { getExams } from "@/modules/exams/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { FileQuestion } from "lucide-react"
export default async function CreateHomeworkAssignmentPage() {
const exams = await getExams({})
const options = exams.map((e) => ({ id: e.id, title: e.title }))
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Create Assignment</h2>
<p className="text-muted-foreground">Dispatch homework from an existing exam.</p>
</div>
</div>
{options.length === 0 ? (
<EmptyState
title="No exams available"
description="Create an exam first, then dispatch it as homework."
icon={FileQuestion}
action={{ label: "Create Exam", href: "/teacher/exams/create" }}
/>
) : (
<HomeworkAssignmentForm exams={options} />
)}
</div>
)
}

View File

@@ -1,7 +1,23 @@
import Link from "next/link"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { PenTool } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignments } from "@/modules/homework/data-access"
import { PenTool, PlusCircle } from "lucide-react"
export default async function AssignmentsPage() {
const assignments = await getHomeworkAssignments()
const hasAssignments = assignments.length > 0
export default function AssignmentsPage() {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
@@ -11,16 +27,58 @@ export default function AssignmentsPage() {
Manage homework assignments.
</p>
</div>
<Button asChild>
<Link href="/teacher/homework/assignments/create">
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Link>
</Button>
</div>
<EmptyState
title="No assignments"
description="You haven't created any assignments yet."
icon={PenTool}
action={{
label: "Create Assignment",
href: "#"
}}
/>
{!hasAssignments ? (
<EmptyState
title="No assignments"
description="You haven't created any assignments yet."
icon={PenTool}
action={{
label: "Create Assignment",
href: "/teacher/homework/assignments/create",
}}
/>
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead>Source Exam</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-muted-foreground">{a.sourceExamTitle}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)
}

View File

@@ -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 (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">{submission.assignmentTitle}</h2>
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
<span>
Student: <span className="font-medium text-foreground">{submission.studentName}</span>
</span>
<span></span>
<span>Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"}</span>
<span></span>
<span className="capitalize">Status: {submission.status}</span>
</div>
</div>
</div>
<HomeworkGradingView
submissionId={submission.id}
studentName={submission.studentName}
assignmentTitle={submission.assignmentTitle}
submittedAt={submission.submittedAt}
status={submission.status}
totalScore={submission.totalScore}
answers={submission.answers}
/>
</div>
)
}

View File

@@ -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 (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
@@ -12,11 +27,48 @@ export default function SubmissionsPage() {
</p>
</div>
</div>
<EmptyState
title="No submissions"
description="There are no homework submissions to review."
icon={Inbox}
/>
{!hasSubmissions ? (
<EmptyState
title="No submissions"
description="There are no homework submissions to review."
icon={Inbox}
/>
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Student</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissions.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/submissions/${s.id}`} className="hover:underline">
{s.assignmentTitle}
</Link>
</TableCell>
<TableCell>{s.studentName}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
{s.isLate ? <span className="ml-2 text-xs text-destructive">Late</span> : null}
</TableCell>
<TableCell className="text-muted-foreground">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell>{typeof s.score === "number" ? s.score : "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)
}

View File

@@ -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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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" }
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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<ExamSubmission>[] = [
{
accessorKey: "studentName",
header: "Student",
},
{
accessorKey: "examTitle",
header: "Exam",
},
{
accessorKey: "submittedAt",
header: "Submitted",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs whitespace-nowrap">
{formatDate(row.original.submittedAt)}
</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.original.status
const variant = status === "graded" ? "secondary" : "outline"
return <Badge variant={variant} className="capitalize">{status}</Badge>
},
},
{
accessorKey: "score",
header: "Score",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">{row.original.score ?? "-"}</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/exams/grading/${row.original.id}`}>
<Eye className="h-4 w-4 mr-1" /> View
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/teacher/exams/grading/${row.original.id}`}>
<CheckSquare className="h-4 w-4 mr-1" /> Grade
</Link>
</Button>
</div>
),
},
]

View File

@@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export function SubmissionDataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
sorting,
},
})
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="group">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No submissions.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="space-x-2">
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -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<string, unknown>
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
}
})

View File

@@ -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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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" }
}
}

View File

@@ -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 (
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Assignment"}
</Button>
)
}
export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
const router = useRouter()
const initialExamId = useMemo(() => exams[0]?.id ?? "", [exams])
const [examId, setExamId] = useState<string>(initialExamId)
const [allowLate, setAllowLate] = useState<boolean>(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 (
<Card>
<CardHeader>
<CardTitle>Create Assignment</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label>Source Exam</Label>
<Select value={examId} onValueChange={setExamId}>
<SelectTrigger>
<SelectValue placeholder="Select an exam" />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="sourceExamId" value={examId} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Assignment Title (optional)</Label>
<Input id="title" name="title" placeholder="Defaults to exam title" />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea id="description" name="description" className="min-h-[80px]" />
</div>
<div className="grid gap-2">
<Label htmlFor="availableAt">Available At (optional)</Label>
<Input id="availableAt" name="availableAt" type="datetime-local" />
</div>
<div className="grid gap-2">
<Label htmlFor="dueAt">Due At (optional)</Label>
<Input id="dueAt" name="dueAt" type="datetime-local" />
</div>
<div className="flex items-center gap-2 md:col-span-2">
<input
id="allowLate"
type="checkbox"
checked={allowLate}
onChange={(e) => setAllowLate(e.target.checked)}
/>
<Label htmlFor="allowLate">Allow late submissions</Label>
<input type="hidden" name="allowLate" value={allowLate ? "true" : "false"} />
</div>
<div className="grid gap-2">
<Label htmlFor="lateDueAt">Late Due At (optional)</Label>
<Input id="lateDueAt" name="lateDueAt" type="datetime-local" />
</div>
<div className="grid gap-2">
<Label htmlFor="maxAttempts">Max Attempts</Label>
<Input id="maxAttempts" name="maxAttempts" type="number" min={1} max={20} defaultValue={1} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="targetStudentIdsText">Target student IDs (optional)</Label>
<Textarea
id="targetStudentIdsText"
name="targetStudentIdsText"
placeholder="Leave empty to assign to all students. You can paste IDs separated by comma or newline."
className="min-h-[90px]"
/>
</div>
</div>
<CardFooter className="justify-end">
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -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<string, unknown> => 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 (
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left: Questions & Answers */}
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4">
<h3 className="font-semibold">Student Response</h3>
@@ -101,7 +95,6 @@ export function GradingView({
<div className="space-y-1">
<span className="text-sm font-medium text-muted-foreground">Question {index + 1}</span>
<div className="text-sm">{ans.questionContent?.text}</div>
{/* Render options if multiple choice, etc. - Simplified for now */}
</div>
<Badge variant="outline">Max: {ans.maxScore}</Badge>
</div>
@@ -114,7 +107,7 @@ export function GradingView({
: JSON.stringify(ans.studentAnswer)}
</p>
</div>
<Separator />
</div>
))}
@@ -122,7 +115,6 @@ export function GradingView({
</ScrollArea>
</div>
{/* Right: Grading Panel */}
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4">
<h3 className="font-semibold">Grading</h3>
@@ -131,7 +123,7 @@ export function GradingView({
<span className="font-bold text-lg text-primary">{currentTotal}</span>
</div>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
{answers.map((ans, index) => (
@@ -145,10 +137,10 @@ export function GradingView({
<CardContent className="py-3 px-4 space-y-3">
<div className="grid gap-2">
<Label htmlFor={`score-${ans.id}`}>Score</Label>
<Input
<Input
id={`score-${ans.id}`}
type="number"
min={0}
type="number"
min={0}
max={ans.maxScore}
value={ans.score ?? ""}
onChange={(e) => handleScoreChange(ans.id, e.target.value)}
@@ -156,7 +148,7 @@ export function GradingView({
</div>
<div className="grid gap-2">
<Label htmlFor={`fb-${ans.id}`}>Feedback</Label>
<Textarea
<Textarea
id={`fb-${ans.id}`}
placeholder="Optional feedback..."
className="min-h-[60px] resize-none"

View File

@@ -0,0 +1,345 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Checkbox } from "@/shared/components/ui/checkbox"
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type Option = { id: string; text: string }
const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
}
const getOptions = (content: unknown): Option[] => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: Option[] = []
for (const item of raw) {
if (!isRecord(item)) continue
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
out.push({ id, text })
}
return out
}
const toAnswerShape = (questionType: string, v: unknown) => {
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
return { answer: v }
}
const parseSavedAnswer = (saved: unknown, questionType: string) => {
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
return toAnswerShape(questionType, saved)
}
type HomeworkTakeViewProps = {
assignmentId: string
initialData: StudentHomeworkTakeData
}
export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeViewProps) {
const router = useRouter()
const [submissionId, setSubmissionId] = useState<string | null>(initialData.submission?.id ?? null)
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
const [isBusy, setIsBusy] = useState(false)
const initialAnswersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>()
for (const q of initialData.questions) {
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
}
return map
}, [initialData.questions])
const [answersByQuestionId, setAnswersByQuestionId] = useState(() => {
const obj: Record<string, { answer: unknown }> = {}
for (const [k, v] of initialAnswersByQuestionId.entries()) obj[k] = v
return obj
})
const isStarted = submissionStatus === "started"
const canEdit = isStarted && Boolean(submissionId)
const handleStart = async () => {
setIsBusy(true)
const fd = new FormData()
fd.set("assignmentId", assignmentId)
const res = await startHomeworkSubmissionAction(null, fd)
if (res.success && res.data) {
setSubmissionId(res.data)
setSubmissionStatus("started")
toast.success("Started")
router.refresh()
} else {
toast.error(res.message || "Failed to start")
}
setIsBusy(false)
}
const handleSaveQuestion = async (questionId: string) => {
if (!submissionId) return
setIsBusy(true)
const payload = answersByQuestionId[questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("questionId", questionId)
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (res.success) toast.success("Saved")
else toast.error(res.message || "Failed to save")
setIsBusy(false)
}
const handleSubmit = async () => {
if (!submissionId) return
setIsBusy(true)
for (const q of initialData.questions) {
const payload = answersByQuestionId[q.questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("questionId", q.questionId)
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (!res.success) {
toast.error(res.message || "Failed to save")
setIsBusy(false)
return
}
}
const submitFd = new FormData()
submitFd.set("submissionId", submissionId)
const submitRes = await submitHomeworkAction(null, submitFd)
if (submitRes.success) {
toast.success("Submitted")
setSubmissionStatus("submitted")
router.push("/student/learning/assignments")
} else {
toast.error(submitRes.message || "Failed to submit")
}
setIsBusy(false)
}
return (
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold">Questions</h3>
<Badge variant="outline" className="capitalize">
{submissionStatus === "not_started" ? "not started" : submissionStatus}
</Badge>
</div>
{!canEdit ? (
<Button onClick={handleStart} disabled={isBusy}>
{isBusy ? "Starting..." : "Start"}
</Button>
) : (
<Button onClick={handleSubmit} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit"}
</Button>
)}
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
{initialData.questions.map((q, idx) => {
const text = getQuestionText(q.questionContent)
const options = getOptions(q.questionContent)
const value = answersByQuestionId[q.questionId]?.answer
return (
<Card key={q.questionId} className="border-l-4 border-l-primary/20">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center justify-between">
<span>
Q{idx + 1} <span className="text-muted-foreground font-normal">({q.questionType})</span>
</span>
<span className="text-xs text-muted-foreground">Max: {q.maxScore}</span>
</CardTitle>
</CardHeader>
<CardContent className="py-3 px-4 space-y-4">
<div className="text-sm">{text || "—"}</div>
{q.questionType === "text" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<Textarea
value={typeof value === "string" ? value : ""}
onChange={(e) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: e.target.value },
}))
}
className="min-h-[100px]"
disabled={!canEdit}
/>
</div>
) : q.questionType === "judgment" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<Select
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: v === "true" },
}))
}
disabled={!canEdit}
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
</div>
) : q.questionType === "single_choice" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: v },
}))
}
disabled={!canEdit}
>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : q.questionType === "multiple_choice" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<div className="space-y-2">
{options.map((o) => {
const selected = Array.isArray(value) ? value.includes(o.id) : false
return (
<label key={o.id} className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
checked={selected}
onCheckedChange={(checked) => {
const isChecked = checked === true
setAnswersByQuestionId((prev) => {
const current = Array.isArray(prev[q.questionId]?.answer)
? (prev[q.questionId]?.answer as string[])
: []
const next = isChecked
? Array.from(new Set([...current, o.id]))
: current.filter((x) => x !== o.id)
return { ...prev, [q.questionId]: { answer: next } }
})
}}
disabled={!canEdit}
/>
<span className="text-sm">{o.text}</span>
</label>
)
})}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">Unsupported question type</div>
)}
{canEdit ? (
<>
<Separator />
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleSaveQuestion(q.questionId)}
disabled={isBusy}
>
Save
</Button>
</div>
</>
) : null}
</CardContent>
</Card>
)
})}
</div>
</ScrollArea>
</div>
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4">
<h3 className="font-semibold">Info</h3>
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Status</span>
<span className="capitalize text-foreground">{submissionStatus === "not_started" ? "not started" : submissionStatus}</span>
</div>
<div className="flex items-center justify-between">
<span>Questions</span>
<span className="text-foreground tabular-nums">{initialData.questions.length}</span>
</div>
</div>
</div>
<div className="flex-1 p-4">
<div className="space-y-3 text-sm">
<div className="text-muted-foreground">{initialData.assignment.description || "—"}</div>
</div>
</div>
<div className="border-t p-4 bg-muted/20">
{canEdit ? (
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit"}
</Button>
) : (
<Button className="w-full" onClick={handleStart} disabled={isBusy}>
{isBusy ? "Starting..." : "Start"}
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,334 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, inArray, isNull, lte, or } from "drizzle-orm"
import { db } from "@/shared/db"
import {
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
users,
} from "@/shared/db/schema"
import type {
HomeworkAssignmentListItem,
HomeworkQuestionContent,
HomeworkAssignmentStatus,
HomeworkSubmissionDetails,
HomeworkSubmissionListItem,
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
StudentHomeworkTakeData,
} from "./types"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
if (!isRecord(v)) return null
return v as HomeworkQuestionContent
}
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[] }) => {
const conditions = []
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
if (params?.ids && params.ids.length > 0) conditions.push(inArray(homeworkAssignments.id, params.ids))
const data = await db.query.homeworkAssignments.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(homeworkAssignments.createdAt)],
with: {
sourceExam: true,
},
})
return data.map((a) => {
const item: HomeworkAssignmentListItem = {
id: a.id,
sourceExamId: a.sourceExamId,
sourceExamTitle: a.sourceExam.title,
title: a.title,
status: (a.status as HomeworkAssignmentListItem["status"]) ?? "draft",
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
allowLate: a.allowLate,
lateDueAt: a.lateDueAt ? a.lateDueAt.toISOString() : null,
maxAttempts: a.maxAttempts,
createdAt: a.createdAt.toISOString(),
updatedAt: a.updatedAt.toISOString(),
}
return item
})
})
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string }) => {
const conditions = []
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
const data = await db.query.homeworkSubmissions.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(homeworkSubmissions.updatedAt)],
with: {
assignment: true,
student: true,
},
})
return data.map((s) => {
const item: HomeworkSubmissionListItem = {
id: s.id,
assignmentId: s.assignmentId,
assignmentTitle: s.assignment.title,
studentName: s.student.name || "Unknown",
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
score: s.score ?? null,
status: (s.status as HomeworkSubmissionListItem["status"]) ?? "started",
isLate: s.isLate,
}
return item
})
})
export const getHomeworkAssignmentById = cache(async (id: string) => {
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, id),
with: {
sourceExam: true,
},
})
if (!assignment) return null
const [targetsRow] = await db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, id))
const [submissionsRow] = await db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, id))
return {
id: assignment.id,
title: assignment.title,
description: assignment.description,
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam.title,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
targetCount: targetsRow?.c ?? 0,
submissionCount: submissionsRow?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
}
})
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
with: {
student: true,
assignment: true,
},
})
if (!submission) return null
const answers = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, submissionId),
with: {
question: true,
},
})
const assignmentQ = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
orderBy: [desc(homeworkAssignmentQuestions.order)],
})
const answersWithDetails = answers
.map((ans) => {
const aqRel = assignmentQ.find((q) => q.questionId === ans.questionId)
return {
id: ans.id,
questionId: ans.questionId,
questionContent: toQuestionContent(ans.question.content),
questionType: ans.question.type,
maxScore: aqRel?.score || 0,
studentAnswer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
order: aqRel?.order || 0,
}
})
.sort((a, b) => a.order - b.order)
return {
id: submission.id,
assignmentId: submission.assignmentId,
assignmentTitle: submission.assignment.title,
studentName: submission.student.name || "Unknown",
submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null,
status: submission.status as HomeworkSubmissionDetails["status"],
totalScore: submission.score,
answers: answersWithDetails,
}
})
export const getDemoStudentUser = cache(async (): Promise<{ id: string; name: string } | null> => {
const student = await db.query.users.findFirst({
where: eq(users.role, "student"),
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (student) return { id: student.id, name: student.name || "Student" }
const anyUser = await db.query.users.findFirst({
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (!anyUser) return null
return { id: anyUser.id, name: anyUser.name || "User" }
})
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
if (v === "started") return "in_progress"
if (v === "submitted") return "submitted"
if (v === "graded") return "graded"
return "not_started"
}
export const getStudentHomeworkAssignments = cache(async (studentId: string): Promise<StudentHomeworkAssignmentListItem[]> => {
const now = new Date()
const targetAssignmentIds = db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, studentId))
const assignments = await db.query.homeworkAssignments.findMany({
where: and(
eq(homeworkAssignments.status, "published"),
inArray(homeworkAssignments.id, targetAssignmentIds),
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
),
orderBy: [desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt)],
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
orderBy: [desc(homeworkSubmissions.createdAt)],
})
const attemptsByAssignmentId = new Map<string, number>()
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
}
return assignments.map((a) => {
const latest = latestByAssignmentId.get(a.id) ?? null
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
const item: StudentHomeworkAssignmentListItem = {
id: a.id,
title: a.title,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
maxAttempts: a.maxAttempts,
attemptsUsed,
progressStatus: toStudentProgressStatus(latest?.status),
latestSubmissionId: latest?.id ?? null,
latestSubmittedAt: latest?.submittedAt ? latest.submittedAt.toISOString() : null,
latestScore: latest?.score ?? null,
}
return item
})
})
export const getStudentHomeworkTakeData = cache(async (assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null> => {
const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, studentId)),
})
if (!target) return null
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
})
if (!assignment) return null
if (assignment.status !== "published") return null
const now = new Date()
if (assignment.availableAt && assignment.availableAt > now) return null
const startedSubmission = await db.query.homeworkSubmissions.findFirst({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.studentId, studentId),
eq(homeworkSubmissions.status, "started")
),
orderBy: (s, { desc }) => [desc(s.createdAt)],
})
const latestSubmission =
startedSubmission ??
(await db.query.homeworkSubmissions.findFirst({
where: and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, studentId)),
orderBy: (s, { desc }) => [desc(s.createdAt)],
}))
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: { question: true },
orderBy: (q, { asc }) => [asc(q.order)],
})
const savedByQuestionId = new Map<string, unknown>()
if (latestSubmission) {
const answers = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
})
for (const ans of answers) savedByQuestionId.set(ans.questionId, ans.answerContent)
}
return {
assignment: {
id: assignment.id,
title: assignment.title,
description: assignment.description,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
},
submission: latestSubmission
? {
id: latestSubmission.id,
status: (latestSubmission.status as NonNullable<StudentHomeworkTakeData["submission"]>["status"]) ?? "started",
attemptNo: latestSubmission.attemptNo,
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
score: latestSubmission.score ?? null,
}
: null,
questions: assignmentQuestions.map((aq) => ({
questionId: aq.questionId,
questionType: aq.question.type,
questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
savedAnswer: savedByQuestionId.get(aq.questionId) ?? null,
})),
}
})

View File

@@ -0,0 +1,28 @@
import { z } from "zod"
export const CreateHomeworkAssignmentSchema = z.object({
sourceExamId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
availableAt: z.string().optional(),
dueAt: z.string().optional(),
allowLate: z.coerce.boolean().optional(),
lateDueAt: z.string().optional(),
maxAttempts: z.coerce.number().int().min(1).max(20).optional(),
targetStudentIds: z.array(z.string().min(1)).optional(),
publish: z.coerce.boolean().optional(),
})
export type CreateHomeworkAssignmentInput = z.infer<typeof CreateHomeworkAssignmentSchema>
export const GradeHomeworkSchema = z.object({
submissionId: z.string().min(1),
answers: z.array(
z.object({
id: z.string().min(1),
score: z.coerce.number().min(0),
feedback: z.string().optional(),
})
),
})

View File

@@ -0,0 +1,99 @@
export type HomeworkAssignmentStatus = "draft" | "published" | "archived"
export type HomeworkSubmissionStatus = "started" | "submitted" | "graded"
export interface HomeworkAssignmentListItem {
id: string
sourceExamId: string
sourceExamTitle: string
title: string
status: HomeworkAssignmentStatus
availableAt: string | null
dueAt: string | null
allowLate: boolean
lateDueAt: string | null
maxAttempts: number
createdAt: string
updatedAt: string
}
export interface HomeworkSubmissionListItem {
id: string
assignmentId: string
assignmentTitle: string
studentName: string
submittedAt: string | null
score: number | null
status: HomeworkSubmissionStatus
isLate: boolean
}
export type HomeworkQuestionContent = { text?: string } & Record<string, unknown>
export type HomeworkSubmissionAnswerDetails = {
id: string
questionId: string
questionContent: HomeworkQuestionContent | null
questionType: string
maxScore: number
studentAnswer: unknown
score: number | null
feedback: string | null
order: number
}
export type HomeworkSubmissionDetails = {
id: string
assignmentId: string
assignmentTitle: string
studentName: string
submittedAt: string | null
status: HomeworkSubmissionStatus
totalScore: number | null
answers: HomeworkSubmissionAnswerDetails[]
}
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
export interface StudentHomeworkAssignmentListItem {
id: string
title: string
dueAt: string | null
availableAt: string | null
maxAttempts: number
attemptsUsed: number
progressStatus: StudentHomeworkProgressStatus
latestSubmissionId: string | null
latestSubmittedAt: string | null
latestScore: number | null
}
export type StudentHomeworkTakeQuestion = {
questionId: string
questionType: string
questionContent: HomeworkQuestionContent | null
maxScore: number
order: number
savedAnswer: unknown
}
export type StudentHomeworkTakeData = {
assignment: {
id: string
title: string
description: string | null
availableAt: string | null
dueAt: string | null
allowLate: boolean
lateDueAt: string | null
maxAttempts: number
}
submission: {
id: string
status: HomeworkSubmissionStatus
attemptNo: number
submittedAt: string | null
score: number | null
} | null
questions: StudentHomeworkTakeQuestion[]
}

View File

@@ -96,7 +96,6 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
items: [
{ title: "All Exams", href: "/teacher/exams/all" },
{ title: "Create Exam", href: "/teacher/exams/create" },
{ title: "Grading", href: "/teacher/exams/grading" },
]
},
{

View File

@@ -13,7 +13,12 @@ import {
exams,
examQuestions,
examSubmissions,
submissionAnswers
submissionAnswers,
homeworkAssignments,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkSubmissions,
homeworkAnswers
} from "./schema";
// --- Users & Roles Relations ---
@@ -23,7 +28,9 @@ export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
usersToRoles: many(usersToRoles),
createdExams: many(exams),
createdHomeworkAssignments: many(homeworkAssignments),
submissions: many(examSubmissions),
homeworkSubmissions: many(homeworkSubmissions),
authoredQuestions: many(questions),
}));
@@ -169,3 +176,62 @@ export const submissionAnswersRelations = relations(submissionAnswers, ({ one })
references: [questions.id],
}),
}));
export const homeworkAssignmentsRelations = relations(homeworkAssignments, ({ one, many }) => ({
creator: one(users, {
fields: [homeworkAssignments.creatorId],
references: [users.id],
}),
sourceExam: one(exams, {
fields: [homeworkAssignments.sourceExamId],
references: [exams.id],
}),
questions: many(homeworkAssignmentQuestions),
targets: many(homeworkAssignmentTargets),
submissions: many(homeworkSubmissions),
}));
export const homeworkAssignmentQuestionsRelations = relations(homeworkAssignmentQuestions, ({ one }) => ({
assignment: one(homeworkAssignments, {
fields: [homeworkAssignmentQuestions.assignmentId],
references: [homeworkAssignments.id],
}),
question: one(questions, {
fields: [homeworkAssignmentQuestions.questionId],
references: [questions.id],
}),
}));
export const homeworkAssignmentTargetsRelations = relations(homeworkAssignmentTargets, ({ one }) => ({
assignment: one(homeworkAssignments, {
fields: [homeworkAssignmentTargets.assignmentId],
references: [homeworkAssignments.id],
}),
student: one(users, {
fields: [homeworkAssignmentTargets.studentId],
references: [users.id],
}),
}));
export const homeworkSubmissionsRelations = relations(homeworkSubmissions, ({ one, many }) => ({
assignment: one(homeworkAssignments, {
fields: [homeworkSubmissions.assignmentId],
references: [homeworkAssignments.id],
}),
student: one(users, {
fields: [homeworkSubmissions.studentId],
references: [users.id],
}),
answers: many(homeworkAnswers),
}));
export const homeworkAnswersRelations = relations(homeworkAnswers, ({ one }) => ({
submission: one(homeworkSubmissions, {
fields: [homeworkAnswers.submissionId],
references: [homeworkSubmissions.id],
}),
question: one(questions, {
fields: [homeworkAnswers.questionId],
references: [questions.id],
}),
}));

View File

@@ -269,6 +269,127 @@ export const submissionAnswers = mysqlTable("submission_answers", {
submissionIdx: index("submission_idx").on(table.submissionId),
}));
export const homeworkAssignments = mysqlTable("homework_assignments", {
id: id("id").primaryKey(),
sourceExamId: varchar("source_exam_id", { length: 128 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
structure: json("structure"),
status: varchar("status", { length: 50 }).default("draft"),
creatorId: varchar("creator_id", { length: 128 }).notNull(),
availableAt: timestamp("available_at"),
dueAt: timestamp("due_at"),
allowLate: boolean("allow_late").default(false).notNull(),
lateDueAt: timestamp("late_due_at"),
maxAttempts: int("max_attempts").default(1).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
creatorIdx: index("hw_assignment_creator_idx").on(table.creatorId),
sourceExamIdx: index("hw_assignment_source_exam_idx").on(table.sourceExamId),
statusIdx: index("hw_assignment_status_idx").on(table.status),
sourceExamFk: foreignKey({
columns: [table.sourceExamId],
foreignColumns: [exams.id],
name: "hw_asg_exam_fk",
}).onDelete("cascade"),
creatorFk: foreignKey({
columns: [table.creatorId],
foreignColumns: [users.id],
name: "hw_asg_creator_fk",
}).onDelete("cascade"),
}));
export const homeworkAssignmentQuestions = mysqlTable("homework_assignment_questions", {
assignmentId: varchar("assignment_id", { length: 128 }).notNull(),
questionId: varchar("question_id", { length: 128 }).notNull(),
score: int("score").default(0),
order: int("order").default(0),
}, (table) => ({
pk: primaryKey({ columns: [table.assignmentId, table.questionId] }),
assignmentIdx: index("hw_assignment_questions_assignment_idx").on(table.assignmentId),
assignmentFk: foreignKey({
columns: [table.assignmentId],
foreignColumns: [homeworkAssignments.id],
name: "hw_aq_a_fk",
}).onDelete("cascade"),
questionFk: foreignKey({
columns: [table.questionId],
foreignColumns: [questions.id],
name: "hw_aq_q_fk",
}).onDelete("cascade"),
}));
export const homeworkAssignmentTargets = mysqlTable("homework_assignment_targets", {
assignmentId: varchar("assignment_id", { length: 128 }).notNull(),
studentId: varchar("student_id", { length: 128 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.assignmentId, table.studentId] }),
assignmentIdx: index("hw_assignment_targets_assignment_idx").on(table.assignmentId),
studentIdx: index("hw_assignment_targets_student_idx").on(table.studentId),
assignmentFk: foreignKey({
columns: [table.assignmentId],
foreignColumns: [homeworkAssignments.id],
name: "hw_at_a_fk",
}).onDelete("cascade"),
studentFk: foreignKey({
columns: [table.studentId],
foreignColumns: [users.id],
name: "hw_at_s_fk",
}).onDelete("cascade"),
}));
export const homeworkSubmissions = mysqlTable("homework_submissions", {
id: id("id").primaryKey(),
assignmentId: varchar("assignment_id", { length: 128 }).notNull(),
studentId: varchar("student_id", { length: 128 }).notNull(),
attemptNo: int("attempt_no").default(1).notNull(),
score: int("score"),
status: varchar("status", { length: 50 }).default("started"),
startedAt: timestamp("started_at").defaultNow().notNull(),
submittedAt: timestamp("submitted_at"),
isLate: boolean("is_late").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
assignmentStudentIdx: index("hw_assignment_student_idx").on(table.assignmentId, table.studentId),
assignmentFk: foreignKey({
columns: [table.assignmentId],
foreignColumns: [homeworkAssignments.id],
name: "hw_sub_a_fk",
}).onDelete("cascade"),
studentFk: foreignKey({
columns: [table.studentId],
foreignColumns: [users.id],
name: "hw_sub_student_fk",
}).onDelete("cascade"),
}));
export const homeworkAnswers = mysqlTable("homework_answers", {
id: id("id").primaryKey(),
submissionId: varchar("submission_id", { length: 128 }).notNull(),
questionId: varchar("question_id", { length: 128 }).notNull(),
answerContent: json("answer_content"),
score: int("score"),
feedback: text("feedback"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
submissionIdx: index("hw_answer_submission_idx").on(table.submissionId),
submissionQuestionIdx: index("hw_answer_submission_question_idx").on(table.submissionId, table.questionId),
submissionFk: foreignKey({
columns: [table.submissionId],
foreignColumns: [homeworkSubmissions.id],
name: "hw_ans_sub_fk",
}).onDelete("cascade"),
questionFk: foreignKey({
columns: [table.questionId],
foreignColumns: [questions.id],
name: "hw_ans_q_fk",
}),
}));
// Re-export old courses table if needed or deprecate it.
// Assuming we are replacing the old simple schema with this robust one.
// But if there were existing tables, we might keep them or comment them out.