diff --git a/docs/db/schema-changelog.md b/docs/db/schema-changelog.md
index adb539c..614522d 100644
--- a/docs/db/schema-changelog.md
+++ b/docs/db/schema-changelog.md
@@ -136,10 +136,10 @@ This release extends the Classes domain to support school-level sorting and per-
#### 2.2 Table: `class_subject_teachers`
* **Action**: `CREATE TABLE`
-* **Primary Key**: (`class_id`, `subject`)
+* **Primary Key**: (`class_id`, `subject_id`)
* **Columns**:
* `class_id` (varchar(128), FK -> `classes.id`, cascade delete)
- * `subject` (enum: `语文/数学/英语/美术/体育/科学/社会/音乐`)
+ * `subject_id` (varchar(128), FK -> `subjects.id`, cascade delete)
* `teacher_id` (varchar(128), FK -> `users.id`, set null on delete)
* `created_at`, `updated_at`
* **Reason**: Maintain a stable default “subject list” per class while allowing admin/teacher to assign the actual teacher per subject.
diff --git a/docs/design/002_teacher_dashboard_implementation.md b/docs/design/002_teacher_dashboard_implementation.md
index b37c6f5..e53a2d9 100644
--- a/docs/design/002_teacher_dashboard_implementation.md
+++ b/docs/design/002_teacher_dashboard_implementation.md
@@ -240,3 +240,20 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。
+
+---
+
+## 9. 教师加入班级学科分配逻辑修复 (2026-03-03)
+
+**日期**: 2026-03-03
+**范围**: 教师通过邀请码加入班级(含学科选择)的校验与分配
+
+### 9.1 行为调整
+
+- 教师已在班级中但选择学科加入时,不再直接返回成功,继续执行学科占用校验。
+- 班级未创建该学科映射时,先补齐映射再分配,避免误报“该班级不提供该学科”。
+- 学科已被其他老师占用时,返回明确提示。
+
+### 9.2 影响代码
+
+- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
diff --git a/docs/design/004_question_bank_implementation.md b/docs/design/004_question_bank_implementation.md
index a4f0a7f..920c894 100644
--- a/docs/design/004_question_bank_implementation.md
+++ b/docs/design/004_question_bank_implementation.md
@@ -130,3 +130,21 @@ type QuestionContent = {
### 6.6 校验
- `npm run lint`:0 errors(仓库其他位置仍存在 warnings)
- `npm run typecheck`:通过
+
+---
+
+## 7. 实现更新(2026-03-03)
+
+### 7.1 登录态与权限校验
+- 题库创建/更新/知识点加载统一使用会话身份;缺失会话时回退到首个教师账号以保持演示可用。
+- 主要修改:
+ - [actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/questions/actions.ts)
+
+### 7.2 弹窗稳定性
+- Create Question 弹窗在打开时仅在默认知识点变化时更新,避免重复 setState 造成循环更新。
+- 主要修改:
+ - [create-question-dialog.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/questions/components/create-question-dialog.tsx)
+
+### 7.3 校验
+- `npm run lint`:通过
+- `npm run typecheck`:通过
diff --git a/docs/design/005_exam_module_implementation.md b/docs/design/005_exam_module_implementation.md
index 0ff03c5..fb52633 100644
--- a/docs/design/005_exam_module_implementation.md
+++ b/docs/design/005_exam_module_implementation.md
@@ -162,6 +162,12 @@ type ExamNode = {
## 7. 变更记录
+**日期**:2026-03-03
+
+- **题库列表稳定性**:
+ - 题库卡片对题目 content/type 做解析兜底,避免异常数据导致运行时崩溃。
+ - 主要修改: [question-bank-list.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/exams/components/assembly/question-bank-list.tsx)
+
**日期**:2026-01-12 (当前)
- **列表页优化 (`/teacher/exams/all`)**:
diff --git a/docs/design/006_homework_module_implementation.md b/docs/design/006_homework_module_implementation.md
index a1f86db..d16d5eb 100644
--- a/docs/design/006_homework_module_implementation.md
+++ b/docs/design/006_homework_module_implementation.md
@@ -304,3 +304,18 @@
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。
+---
+
+## 14. 实现更新(2026-03-03)
+
+### 14.1 学生作业状态修复
+- 提交作业与学生列表查询改为使用真实登录用户,避免提交后仍显示未开始。
+- 学生列表优先展示最近一次已提交/已评分记录,提升状态准确性。
+- 主要修改:
+ - [actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/actions.ts)
+ - [data-access.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts)
+
+### 14.2 校验
+- `npm run lint`:通过
+- `npm run typecheck`:通过
+
diff --git a/docs/design/008_teacher_pages_implementation.md b/docs/design/008_teacher_pages_implementation.md
new file mode 100644
index 0000000..0379cde
--- /dev/null
+++ b/docs/design/008_teacher_pages_implementation.md
@@ -0,0 +1,313 @@
+# 教师端页面实现分析文档
+
+**日期**: 2026-03-03
+**范围**: Teacher 路由与页面实现(`src/app/(dashboard)/teacher`)
+
+---
+
+## 1. 总览
+
+教师端页面采用服务端组件为主、按页面聚合数据的方式实现,页面负责:
+
+- 读取数据(模块 data-access)
+- 组装 UI(模块 components)
+- 处理空状态与跳转
+
+所有页面路由位于 `src/app/(dashboard)/teacher`,各业务能力落在 `src/modules/*` 中。
+
+---
+
+## 2. 路由总表
+
+### 2.1 教师工作台
+- `/teacher/dashboard`
+ 实现:[dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/dashboard/page.tsx)
+
+### 2.2 班级
+- `/teacher/classes` → `/teacher/classes/my`
+ 实现:[classes/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/page.tsx)
+- `/teacher/classes/my`
+ 实现:[classes/my/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/page.tsx)
+- `/teacher/classes/my/[id]`
+ 实现:[classes/my/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
+- `/teacher/classes/students`
+ 实现:[classes/students/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/students/page.tsx)
+- `/teacher/classes/schedule`
+ 实现:[classes/schedule/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/schedule/page.tsx)
+
+### 2.3 作业
+- `/teacher/homework` → `/teacher/homework/assignments`
+ 实现:[homework/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/page.tsx)
+- `/teacher/homework/assignments`
+ 实现:[homework/assignments/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
+- `/teacher/homework/assignments/create`
+ 实现:[homework/assignments/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx)
+- `/teacher/homework/assignments/[id]`
+ 实现:[homework/assignments/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
+- `/teacher/homework/assignments/[id]/submissions`
+ 实现:[homework/assignments/[id]/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx)
+- `/teacher/homework/submissions`
+ 实现:[homework/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx)
+- `/teacher/homework/submissions/[submissionId]`
+ 实现:[homework/submissions/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx)
+
+### 2.4 考试
+- `/teacher/exams` → `/teacher/exams/all`
+ 实现:[exams/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/page.tsx)
+- `/teacher/exams/all`
+ 实现:[exams/all/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/all/page.tsx)
+- `/teacher/exams/create`
+ 实现:[exams/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/create/page.tsx)
+- `/teacher/exams/[id]/build`
+ 实现:[exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/%5Bid%5D/build/page.tsx)
+- `/teacher/exams/grading` → `/teacher/homework/submissions`
+ 实现:[exams/grading/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx)
+- `/teacher/exams/grading/[submissionId]` → `/teacher/homework/submissions`
+ 实现:[exams/grading/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx)
+
+### 2.5 题库
+- `/teacher/questions`
+ 实现:[questions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/questions/page.tsx)
+
+### 2.6 教材
+- `/teacher/textbooks`
+ 实现:[textbooks/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/page.tsx)
+- `/teacher/textbooks/[id]`
+ 实现:[textbooks/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/%5Bid%5D/page.tsx)
+
+---
+
+## 3. 页面详解(逐页)
+
+### 3.1 教师工作台 `/teacher/dashboard`
+实现:[dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/dashboard/page.tsx)
+
+- **目的**: 教师总览工作台,展示班级、课表、作业、提交、成绩趋势与教师姓名。
+- **数据来源**:
+ - 班级与课表:`getTeacherClasses`、`getClassSchedule`
+ - 作业与提交:`getHomeworkAssignments`、`getHomeworkSubmissions`
+ - 教师姓名:`users` 表查询
+ - 成绩趋势:`getTeacherGradeTrends`
+- **关键组件**: `TeacherDashboardView`
+- **渲染策略**: `dynamic = "force-dynamic"`
+
+### 3.2 班级入口 `/teacher/classes`
+实现:[classes/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/page.tsx)
+
+- **目的**: 跳转入口,统一导向“我的班级”。
+- **行为**: `redirect("/teacher/classes/my")`
+
+### 3.3 我的班级 `/teacher/classes/my`
+实现:[classes/my/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/page.tsx)
+
+- **目的**: 展示教师负责班级的卡片列表,并支持学科筛选/加入。
+- **数据来源**:
+ - 班级:`getTeacherClasses`
+ - 学科选项:`getClassSubjects`
+- **关键组件**: `MyClassesGrid`
+- **渲染策略**: `dynamic = "force-dynamic"`
+
+### 3.4 班级详情 `/teacher/classes/my/[id]`
+实现:[classes/my/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
+
+- **目的**: 展示班级概览(作业趋势、学生、课表、作业摘要)。
+- **数据来源**:
+ - 班级作业洞察:`getClassHomeworkInsights`
+ - 学生列表:`getClassStudents`
+ - 课表:`getClassSchedule`
+ - 学科成绩:`getClassStudentSubjectScoresV2`
+- **关键组件**:
+ - `ClassHeader`
+ - `ClassOverviewStats`
+ - `ClassTrendsWidget`
+ - `ClassStudentsWidget`
+ - `ClassScheduleWidget`
+ - `ClassAssignmentsWidget`
+- **空状态**: `insights` 缺失返回 `notFound()`
+- **渲染策略**: `dynamic = "force-dynamic"`
+
+### 3.5 学生列表 `/teacher/classes/students`
+实现:[classes/students/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/students/page.tsx)
+
+- **目的**: 按班级、关键词、状态筛选学生,并显示学科成绩。
+- **数据来源**:
+ - 教师班级:`getTeacherClasses`
+ - 学生列表:`getClassStudents`
+ - 学科成绩:`getStudentsSubjectScores`
+- **关键组件**:
+ - `StudentsFilters`
+ - `StudentsTable`
+ - `EmptyState` / `Skeleton`
+- **筛选逻辑**: 未显式选择班级时默认第一班级
+- **渲染策略**: `dynamic = "force-dynamic"`
+
+### 3.6 课表 `/teacher/classes/schedule`
+实现:[classes/schedule/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/schedule/page.tsx)
+
+- **目的**: 按班级查看课表。
+- **数据来源**:
+ - 班级:`getTeacherClasses`
+ - 课表:`getClassSchedule`
+- **关键组件**:
+ - `ScheduleFilters`
+ - `ScheduleView`
+ - `EmptyState` / `Skeleton`
+- **渲染策略**: `dynamic = "force-dynamic"`
+
+### 3.7 作业入口 `/teacher/homework`
+实现:[homework/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/page.tsx)
+
+- **目的**: 统一导向作业列表。
+- **行为**: `redirect("/teacher/homework/assignments")`
+
+### 3.8 作业列表 `/teacher/homework/assignments`
+实现:[homework/assignments/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
+
+- **目的**: 查看作业列表与状态,支持按班级筛选,提供创建入口。
+- **数据来源**:
+ - 作业列表:`getHomeworkAssignments`
+ - 班级列表(用于显示名称):`getTeacherClasses`
+- **关键组件**: `Table`、`Badge`、`EmptyState`
+- **空状态**: 无作业时提示创建
+- **渲染策略**: `dynamic = "force-dynamic"`
+
+### 3.9 创建作业 `/teacher/homework/assignments/create`
+实现:[homework/assignments/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx)
+
+- **目的**: 从 Exam 派发作业。
+- **数据来源**:
+ - 可派发的 Exam:`getExams`
+ - 班级列表:`getTeacherClasses`
+- **关键组件**: `HomeworkAssignmentForm`
+- **空状态**:
+ - 无 Exam:提示先创建考试
+ - 无班级:提示先创建班级
+
+### 3.10 作业详情 `/teacher/homework/assignments/[id]`
+实现:[homework/assignments/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
+
+- **目的**: 展示作业详情、错题概览与试卷内容。
+- **数据来源**: `getHomeworkAssignmentAnalytics`
+- **关键组件**:
+ - `HomeworkAssignmentQuestionErrorOverviewCard`
+ - `HomeworkAssignmentExamContentCard`
+- **空状态**: 查不到作业时 `notFound()`
+
+### 3.11 作业提交列表 `/teacher/homework/assignments/[id]/submissions`
+实现:[homework/assignments/[id]/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx)
+
+- **目的**: 按作业查看提交与评分进度。
+- **数据来源**:
+ - 作业信息:`getHomeworkAssignmentById`
+ - 提交列表:`getHomeworkSubmissions`
+- **关键组件**: `Table`、`Badge`
+- **空状态**: 作业不存在时 `notFound()`
+
+### 3.12 全部提交 `/teacher/homework/submissions`
+实现:[homework/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx)
+
+- **目的**: 按作业汇总查看所有提交与批改入口。
+- **数据来源**:
+ - 教师身份:`getTeacherIdForMutations`
+ - 作业审阅列表:`getHomeworkAssignmentReviewList`
+- **关键组件**: `Table`、`Badge`、`EmptyState`
+
+### 3.13 提交批改 `/teacher/homework/submissions/[submissionId]`
+实现:[homework/submissions/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx)
+
+- **目的**: 作业批改视图,按题打分与反馈。
+- **数据来源**: `getHomeworkSubmissionDetails`
+- **关键组件**: `HomeworkGradingView`
+- **空状态**: 查不到提交时 `notFound()`
+
+### 3.14 考试入口 `/teacher/exams`
+实现:[exams/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/page.tsx)
+
+- **目的**: 统一导向考试列表。
+- **行为**: `redirect("/teacher/exams/all")`
+
+### 3.15 考试列表 `/teacher/exams/all`
+实现:[exams/all/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/all/page.tsx)
+
+- **目的**: 列出考试并支持筛选、统计、创建入口。
+- **数据来源**: `getExams`(按关键词/状态/难度过滤)
+- **关键组件**:
+ - `ExamFilters`
+ - `ExamDataTable`
+ - `EmptyState` / `Skeleton`
+- **统计**: 对列表结果进行状态数量统计(draft/published/archived)
+
+### 3.16 创建考试 `/teacher/exams/create`
+实现:[exams/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/create/page.tsx)
+
+- **目的**: 创建考试基础信息。
+- **关键组件**: `ExamForm`
+
+### 3.17 组卷 `/teacher/exams/[id]/build`
+实现:[exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/%5Bid%5D/build/page.tsx)
+
+- **目的**: 从题库选择题目并构建考试结构。
+- **数据来源**:
+ - 考试数据:`getExamById`
+ - 题库数据:`getQuestions`
+- **关键组件**: `ExamAssembly`
+- **关键逻辑**:
+ - 读取已选题并初始化 `initialSelected`
+ - 将题目数据映射为 `Question` 类型
+ - 归一化 `structure` 并保证节点 `id` 唯一
+
+### 3.18 阅卷入口 `/teacher/exams/grading*`
+实现:[exams/grading/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx)、[exams/grading/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx)
+
+- **目的**: 统一重定向至作业批改视图。
+- **行为**: `redirect("/teacher/homework/submissions")`
+
+### 3.19 题库 `/teacher/questions`
+实现:[questions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/questions/page.tsx)
+
+- **目的**: 题库管理与筛选。
+- **数据来源**: `getQuestions`(按关键词/题型/难度过滤)
+- **关键组件**:
+ - `QuestionFilters`
+ - `QuestionDataTable`
+ - `CreateQuestionButton`
+ - `EmptyState` / `Skeleton`
+
+### 3.20 教材列表 `/teacher/textbooks`
+实现:[textbooks/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/page.tsx)
+
+- **目的**: 教材管理与筛选,创建入口。
+- **数据来源**: `getTextbooks`
+- **关键组件**:
+ - `TextbookFilters`
+ - `TextbookCard`
+ - `TextbookFormDialog`
+ - `EmptyState`
+- **渲染策略**: `dynamic = "force-dynamic"`
+
+### 3.21 教材详情 `/teacher/textbooks/[id]`
+实现:[textbooks/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/%5Bid%5D/page.tsx)
+
+- **目的**: 教材章节与知识点结构阅读与维护。
+- **数据来源**:
+ - 教材:`getTextbookById`
+ - 章节:`getChaptersByTextbookId`
+ - 知识点:`getKnowledgePointsByTextbookId`
+- **关键组件**:
+ - `TextbookReader`
+ - `TextbookSettingsDialog`
+- **空状态**: 教材不存在时 `notFound()`
+
+---
+
+## 4. 依赖模块清单
+
+教师端页面主要依赖以下模块:
+
+- 班级与排课:`src/modules/classes`
+- 作业:`src/modules/homework`
+- 考试:`src/modules/exams`
+- 题库:`src/modules/questions`
+- 教材:`src/modules/textbooks`
+- 工作台:`src/modules/dashboard`
+
diff --git a/docs/design/009_feature_gap_analysis.md b/docs/design/009_feature_gap_analysis.md
new file mode 100644
index 0000000..44f88e7
--- /dev/null
+++ b/docs/design/009_feature_gap_analysis.md
@@ -0,0 +1,150 @@
+# 功能实现对比文档(已实现 vs 规划)
+
+**日期**: 2026-03-03
+**范围**: 基于 PRD 与现有设计文档的功能落地对比
+
+---
+
+## 1. 依据与来源
+
+本对比基于以下文档:
+
+- 产品规划:PRD(`docs/product_requirements.md`)
+- 已实现模块:
+ - 教师仪表盘与班级管理:[002_teacher_dashboard_implementation.md](file:///e:/Desktop/CICD/docs/design/002_teacher_dashboard_implementation.md)
+ - 教材模块:[003_textbooks_module_implementation.md](file:///e:/Desktop/CICD/docs/design/003_textbooks_module_implementation.md)
+ - 题库模块:[004_question_bank_implementation.md](file:///e:/Desktop/CICD/docs/design/004_question_bank_implementation.md)
+ - 考试模块:[005_exam_module_implementation.md](file:///e:/Desktop/CICD/docs/design/005_exam_module_implementation.md)
+ - 作业模块:[006_homework_module_implementation.md](file:///e:/Desktop/CICD/docs/design/006_homework_module_implementation.md)
+ - 学校管理模块:[007_school_module_implementation.md](file:///e:/Desktop/CICD/docs/design/007_school_module_implementation.md)
+
+---
+
+## 2. 便利贴视图(按 Roles / Page / 功能)
+
+> **🟨 Roles**
+>
+> - **教师(Teacher)**:备课、出卷、作业批改与班级管理
+> - **学生(Student)**:作业完成与教材阅读(只读)
+> - **管理端(Admin/校长/年级主任/教研组长/班主任)**:规划中,未完全落地
+
+> **🟨 Page**
+>
+> - **教师工作台**:`/teacher/dashboard`
+> - **班级**:`/teacher/classes/*`
+> - **作业**:`/teacher/homework/*`
+> - **考试**:`/teacher/exams/*`
+> - **题库**:`/teacher/questions`
+> - **教材**:`/teacher/textbooks/*`
+
+> **🟨 功能(规划目标)**
+>
+> - **权限与角色**:多角色矩阵 + RLS 行级隔离
+> - **智能题库**:嵌套题、知识点关联
+> - **知识图谱**:知识点树 + 题目/章节关联
+> - **教材映射**:章节 ↔ 知识点
+> - **组卷引擎**:筛选/分组/结构化试卷
+> - **作业闭环**:派发-提交-批改-统计
+> - **通知中心**:分级提醒策略
+
+---
+
+## 3. 已实现页面功能清单(简述)
+
+> **🟨 教师工作台**
+>
+> - `/teacher/dashboard`
+> 功能:汇总班级、课表、作业、提交与成绩趋势
+> 目标:快速掌握教学全局
+
+> **🟨 班级**
+>
+> - `/teacher/classes`
+> 功能:班级入口重定向
+> 目标:统一进入我的班级
+> - `/teacher/classes/my`
+> 功能:班级列表与学科选择
+> 目标:管理所教班级
+> - `/teacher/classes/my/[id]`
+> 功能:班级详情概览、作业趋势、学生与课表
+> 目标:掌握班级学习情况
+> - `/teacher/classes/students`
+> 功能:学生筛选与成绩查看
+> 目标:定位学生画像与状态
+> - `/teacher/classes/schedule`
+> 功能:班级课表查看
+> 目标:排课信息可视化
+
+> **🟨 作业**
+>
+> - `/teacher/homework/assignments`
+> 功能:作业列表与状态
+> 目标:管理作业发布
+> - `/teacher/homework/assignments/create`
+> 功能:从考试派发作业
+> 目标:快速生成作业
+> - `/teacher/homework/assignments/[id]`
+> 功能:作业详情与错题概览
+> 目标:定位薄弱题型
+> - `/teacher/homework/assignments/[id]/submissions`
+> 功能:作业提交列表
+> 目标:查看班级完成度
+> - `/teacher/homework/submissions`
+> 功能:所有作业提交汇总
+> 目标:统一批改入口
+> - `/teacher/homework/submissions/[submissionId]`
+> 功能:作业批改与反馈
+> 目标:完成评分与讲评
+
+> **🟨 考试**
+>
+> - `/teacher/exams/all`
+> 功能:考试列表与筛选
+> 目标:管理考试资产
+> - `/teacher/exams/create`
+> 功能:创建考试基础信息
+> 目标:建立试卷草稿
+> - `/teacher/exams/[id]/build`
+> 功能:题库选题与结构化组卷
+> 目标:完成试卷构建
+
+> **🟨 题库**
+>
+> - `/teacher/questions`
+> 功能:题库检索与管理
+> 目标:积累与复用题目
+
+> **🟨 教材**
+>
+> - `/teacher/textbooks`
+> 功能:教材管理与筛选
+> 目标:组织课程资源
+> - `/teacher/textbooks/[id]`
+> 功能:章节与知识点维护
+> 目标:构建教材结构
+
+---
+
+## 4. 差距清单(简述)
+
+> **🟨 权限与治理**
+>
+> - 多角色 RBAC 细化权限未落地
+> - RLS 数据隔离策略未落地
+
+> **🟨 教学质量与推荐**
+>
+> - 章节 → 知识点 → 题库的自动推荐链路未落地
+> - 知识点图谱深层能力未落地
+> - 学科维度与权重/标签机制未落地
+
+> **🟨 组卷与作业高级能力**
+>
+> - AB 卷与乱序策略未落地
+> - 作业分层与交集筛选未落地
+> - 学习画像/成长档案层的评估闭环尚未体现
+
+> **🟨 通知与消息闭环**
+>
+> - 分级通知体系未落地
+
diff --git a/docs/scripts/seed-exams.ts b/docs/scripts/seed-exams.ts
index ce0718e..51509fd 100644
--- a/docs/scripts/seed-exams.ts
+++ b/docs/scripts/seed-exams.ts
@@ -4,6 +4,7 @@ import { users, exams, questions, knowledgePoints, examSubmissions, examQuestion
import { createId } from "@paralleldrive/cuid2"
import { faker } from "@faker-js/faker"
import { eq } from "drizzle-orm"
+import { hash } from "bcryptjs"
/**
* Seed Script for Next_Edu
@@ -19,6 +20,7 @@ const DIFFICULTY = [1, 2, 3, 4, 5]
async function seed() {
console.log("🌱 Starting seed process...")
+ const passwordHash = await hash("123456", 10)
// 1. Create a Teacher User if not exists
const teacherEmail = "teacher@example.com"
@@ -36,6 +38,7 @@ async function seed() {
email: teacherEmail,
role: "teacher",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Teacher",
+ password: passwordHash,
})
} else {
teacherId = existingTeacher.id
@@ -54,6 +57,7 @@ async function seed() {
email: faker.internet.email(),
role: "student",
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${sId}`,
+ password: passwordHash,
})
}
diff --git a/docs/work_log.md b/docs/work_log.md
index cff9161..eb983b9 100644
--- a/docs/work_log.md
+++ b/docs/work_log.md
@@ -1,5 +1,17 @@
# Work Log
+## 2026-03-03
+
+### 1. 教师加入班级学科分配逻辑修复
+- 修复教师已在班级中但选择学科加入时被直接返回成功的问题。
+- 班级未创建该学科映射时,先补齐映射再分配。
+- 学科已被其他老师占用时,返回明确提示。
+- 主要修改:
+ - [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
+
+### 2. 验证
+- 质量检查:`npm run lint`、`npm run typecheck` 均通过。
+
## 2026-03-02
### 1. 班级详情访问修复(基于会话身份)
diff --git a/drizzle/0009_smart_mephistopheles.sql b/drizzle/0009_smart_mephistopheles.sql
new file mode 100644
index 0000000..e17d3cc
--- /dev/null
+++ b/drizzle/0009_smart_mephistopheles.sql
@@ -0,0 +1,88 @@
+SET @has_exams_subject := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'exams'
+ AND COLUMN_NAME = 'subject_id'
+);--> statement-breakpoint
+SET @sql := IF(@has_exams_subject = 0, 'ALTER TABLE `exams` ADD `subject_id` varchar(128);', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @has_exams_grade := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'exams'
+ AND COLUMN_NAME = 'grade_id'
+);--> statement-breakpoint
+SET @sql := IF(@has_exams_grade = 0, 'ALTER TABLE `exams` ADD `grade_id` varchar(128);', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @has_kp_anchor := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'knowledge_points'
+ AND COLUMN_NAME = 'anchor_text'
+);--> statement-breakpoint
+SET @sql := IF(@has_kp_anchor = 0, 'ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @has_exams_subject_fk := (
+ SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'exams'
+ AND CONSTRAINT_NAME = 'exams_subject_id_subjects_id_fk'
+ AND CONSTRAINT_TYPE = 'FOREIGN KEY'
+);--> statement-breakpoint
+SET @sql := IF(@has_exams_subject_fk = 0, 'ALTER TABLE `exams` ADD CONSTRAINT `exams_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE no action ON UPDATE no action;', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @has_exams_grade_fk := (
+ SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'exams'
+ AND CONSTRAINT_NAME = 'exams_grade_id_grades_id_fk'
+ AND CONSTRAINT_TYPE = 'FOREIGN KEY'
+);--> statement-breakpoint
+SET @sql := IF(@has_exams_grade_fk = 0, 'ALTER TABLE `exams` ADD CONSTRAINT `exams_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE no action ON UPDATE no action;', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @has_exams_subject_idx := (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'exams'
+ AND INDEX_NAME = 'exams_subject_idx'
+);--> statement-breakpoint
+SET @sql := IF(@has_exams_subject_idx = 0, 'CREATE INDEX `exams_subject_idx` ON `exams` (`subject_id`);', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @has_exams_grade_idx := (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'exams'
+ AND INDEX_NAME = 'exams_grade_idx'
+);--> statement-breakpoint
+SET @sql := IF(@has_exams_grade_idx = 0, 'CREATE INDEX `exams_grade_idx` ON `exams` (`grade_id`);', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+INSERT IGNORE INTO `roles` (`id`, `name`, `created_at`, `updated_at`)
+SELECT UUID(), LOWER(TRIM(`role`)), NOW(), NOW()
+FROM `users`
+WHERE `role` IS NOT NULL AND TRIM(`role`) <> '';--> statement-breakpoint
+INSERT IGNORE INTO `users_to_roles` (`user_id`, `role_id`)
+SELECT `users`.`id`, `roles`.`id`
+FROM `users`
+INNER JOIN `roles` ON `roles`.`name` = LOWER(TRIM(`users`.`role`))
+WHERE `users`.`role` IS NOT NULL AND TRIM(`users`.`role`) <> '';--> statement-breakpoint
+ALTER TABLE `users` DROP COLUMN `role`;
diff --git a/drizzle/0010_subject_id_switch.sql b/drizzle/0010_subject_id_switch.sql
new file mode 100644
index 0000000..6adebc4
--- /dev/null
+++ b/drizzle/0010_subject_id_switch.sql
@@ -0,0 +1,110 @@
+SET @has_subject_id := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'class_subject_teachers'
+ AND COLUMN_NAME = 'subject_id'
+);--> statement-breakpoint
+SET @sql := IF(@has_subject_id = 0, 'ALTER TABLE `class_subject_teachers` ADD COLUMN `subject_id` VARCHAR(128) NULL;', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+INSERT INTO `subjects` (`id`, `name`, `code`)
+SELECT UUID(), '语文', 'CHINESE' FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '语文' OR `code` = 'CHINESE')
+UNION ALL
+SELECT UUID(), '数学', 'MATH' FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '数学' OR `code` = 'MATH')
+UNION ALL
+SELECT UUID(), '英语', 'ENG' FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '英语' OR `code` = 'ENG')
+UNION ALL
+SELECT UUID(), '美术', 'ART' FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '美术' OR `code` = 'ART')
+UNION ALL
+SELECT UUID(), '体育', 'PE' FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '体育' OR `code` = 'PE')
+UNION ALL
+SELECT UUID(), '科学', 'SCI' FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '科学' OR `code` = 'SCI')
+UNION ALL
+SELECT UUID(), '社会', 'SOC' FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '社会' OR `code` = 'SOC')
+UNION ALL
+SELECT UUID(), '音乐', 'MUSIC' FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '音乐' OR `code` = 'MUSIC');--> statement-breakpoint
+
+SET @has_subject_col := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'class_subject_teachers'
+ AND COLUMN_NAME = 'subject'
+);--> statement-breakpoint
+SET @sql := IF(@has_subject_col = 1, '
+ UPDATE `class_subject_teachers` cst
+ JOIN `subjects` s ON (
+ s.`name` = cst.`subject`
+ OR s.`code` = CASE cst.`subject`
+ WHEN ''语文'' THEN ''CHINESE''
+ WHEN ''数学'' THEN ''MATH''
+ WHEN ''英语'' THEN ''ENG''
+ WHEN ''美术'' THEN ''ART''
+ WHEN ''体育'' THEN ''PE''
+ WHEN ''科学'' THEN ''SCI''
+ WHEN ''社会'' THEN ''SOC''
+ WHEN ''音乐'' THEN ''MUSIC''
+ ELSE NULL
+ END
+ )
+ SET cst.`subject_id` = s.`id`
+ WHERE cst.`subject_id` IS NULL;
+', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @subject_id_nulls := (
+ SELECT COUNT(*) FROM `class_subject_teachers`
+ WHERE `subject_id` IS NULL
+);--> statement-breakpoint
+SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` MODIFY COLUMN `subject_id` VARCHAR(128) NOT NULL;', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` DROP PRIMARY KEY;', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` ADD PRIMARY KEY (`class_id`, `subject_id`);', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_col = 1, 'ALTER TABLE `class_subject_teachers` DROP COLUMN `subject`;', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @has_subject_id_idx := (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'class_subject_teachers'
+ AND INDEX_NAME = 'class_subject_teachers_subject_id_idx'
+);--> statement-breakpoint
+SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_id_idx = 0, 'CREATE INDEX `class_subject_teachers_subject_id_idx` ON `class_subject_teachers` (`subject_id`);', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;--> statement-breakpoint
+
+SET @has_subject_fk := (
+ SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'class_subject_teachers'
+ AND CONSTRAINT_NAME = 'cst_s_fk'
+ AND CONSTRAINT_TYPE = 'FOREIGN KEY'
+);--> statement-breakpoint
+SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_fk = 0, 'ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `cst_s_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE CASCADE;', 'SELECT 1');--> statement-breakpoint
+PREPARE stmt FROM @sql;--> statement-breakpoint
+EXECUTE stmt;--> statement-breakpoint
+DEALLOCATE PREPARE stmt;
diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json
index 913c054..18f0917 100644
--- a/drizzle/meta/0008_snapshot.json
+++ b/drizzle/meta/0008_snapshot.json
@@ -2,7 +2,7 @@
"version": "5",
"dialect": "mysql",
"id": "2cf4c7e4-f538-499e-a5a5-9281d556bc9d",
- "prevId": "5eaf9185-8a1e-4e35-8144-553aec7ff31f",
+ "prevId": "a6d95d47-4400-464e-bc53-45735dd6e3e3",
"tables": {
"academic_years": {
"name": "academic_years",
@@ -3068,4 +3068,4 @@
"tables": {},
"indexes": {}
}
-}
\ No newline at end of file
+}
diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json
index b4ed61d..2179b82 100644
--- a/drizzle/meta/0009_snapshot.json
+++ b/drizzle/meta/0009_snapshot.json
@@ -1,8 +1,8 @@
{
"version": "5",
"dialect": "mysql",
- "id": "5eaf9185-8a1e-4e35-8144-553aec7ff31f",
- "prevId": "3b23e056-3d79-4ea9-a03e-d1b5d56bafda",
+ "id": "551f3408-945e-4f1d-984c-bfd35fe9d0ea",
+ "prevId": "2cf4c7e4-f538-499e-a5a5-9281d556bc9d",
"tables": {
"academic_years": {
"name": "academic_years",
@@ -1180,6 +1180,20 @@
"notNull": true,
"autoincrement": false
},
+ "subject_id": {
+ "name": "subject_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "grade_id": {
+ "name": "grade_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
"start_time": {
"name": "start_time",
"type": "timestamp",
@@ -1220,7 +1234,22 @@
"default": "(now())"
}
},
- "indexes": {},
+ "indexes": {
+ "exams_subject_idx": {
+ "name": "exams_subject_idx",
+ "columns": [
+ "subject_id"
+ ],
+ "isUnique": false
+ },
+ "exams_grade_idx": {
+ "name": "exams_grade_idx",
+ "columns": [
+ "grade_id"
+ ],
+ "isUnique": false
+ }
+ },
"foreignKeys": {
"exams_creator_id_users_id_fk": {
"name": "exams_creator_id_users_id_fk",
@@ -1234,6 +1263,32 @@
],
"onDelete": "no action",
"onUpdate": "no action"
+ },
+ "exams_subject_id_subjects_id_fk": {
+ "name": "exams_subject_id_subjects_id_fk",
+ "tableFrom": "exams",
+ "tableTo": "subjects",
+ "columnsFrom": [
+ "subject_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "exams_grade_id_grades_id_fk": {
+ "name": "exams_grade_id_grades_id_fk",
+ "tableFrom": "exams",
+ "tableTo": "grades",
+ "columnsFrom": [
+ "grade_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
}
},
"compositePrimaryKeys": {
@@ -2009,6 +2064,13 @@
"notNull": false,
"autoincrement": false
},
+ "anchor_text": {
+ "name": "anchor_text",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
"parent_id": {
"name": "parent_id",
"type": "varchar(128)",
@@ -2765,14 +2827,6 @@
"notNull": false,
"autoincrement": false
},
- "role": {
- "name": "role",
- "type": "varchar(50)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false,
- "default": "'student'"
- },
"password": {
"name": "password",
"type": "varchar(255)",
@@ -3006,4 +3060,4 @@
"tables": {},
"indexes": {}
}
-}
\ No newline at end of file
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index aa6bd9e..56d4b1d 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -64,6 +64,20 @@
"when": 1768470966367,
"tag": "0008_thin_madrox",
"breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "5",
+ "when": 1772162908476,
+ "tag": "0009_smart_mephistopheles",
+ "breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "5",
+ "when": 1772439600000,
+ "tag": "0010_subject_id_switch",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/scripts/check_cst_schema.ts b/scripts/check_cst_schema.ts
new file mode 100644
index 0000000..7d85a7e
--- /dev/null
+++ b/scripts/check_cst_schema.ts
@@ -0,0 +1,38 @@
+import { config } from "dotenv"
+import mysql from "mysql2/promise"
+
+async function main() {
+ config()
+ const url = process.env.DATABASE_URL
+ if (!url) {
+ console.error("Missing DATABASE_URL")
+ process.exit(1)
+ return
+ }
+ const conn = await mysql.createConnection(url)
+ try {
+ const [rows] = await conn.query("SHOW COLUMNS FROM class_subject_teachers")
+ const [keys] = await conn.query("SHOW KEYS FROM class_subject_teachers")
+ let migrations: Array<{ id: number | string; hash: string; created_at: number | string }> | null = null
+ try {
+ const [m] = await conn.query("SELECT id, hash, created_at FROM __drizzle_migrations ORDER BY id DESC LIMIT 5")
+ migrations = m as Array<{ id: number | string; hash: string; created_at: number | string }>
+ } catch (error: unknown) {
+ console.error(error)
+ }
+ const columns = rows as Array<{ Field: string; Type: string; Null: string; Key: string }>
+ const indexes = keys as Array<{ Key_name: string; Column_name: string }>
+ console.log(columns.map((r) => `${r.Field}:${r.Type}:${r.Null}:${r.Key}`).join("\n"))
+ console.log(indexes.map((r) => `${r.Key_name}:${r.Column_name}`).join("\n"))
+ if (migrations) {
+ console.log(migrations.map((r) => `${r.id}:${r.hash}:${r.created_at}`).join("\n"))
+ }
+ } finally {
+ await conn.end()
+ }
+}
+
+main().catch((e) => {
+ console.error(e)
+ process.exit(1)
+})
diff --git a/scripts/migrate_cst_subjectid.ts b/scripts/migrate_cst_subjectid.ts
new file mode 100644
index 0000000..b13bbce
--- /dev/null
+++ b/scripts/migrate_cst_subjectid.ts
@@ -0,0 +1,58 @@
+import { config } from "dotenv"
+import { db } from "@/shared/db"
+import { sql } from "drizzle-orm"
+
+async function main() {
+ config()
+ // 1) add subject_id column if not exists (nullable first)
+ await db.execute(sql`ALTER TABLE class_subject_teachers ADD COLUMN IF NOT EXISTS subject_id VARCHAR(128) NULL;`)
+
+ // 2) backfill subject_id from subjects.name matching existing 'subject' column
+ // This assumes existing data uses subjects.name 值;若不匹配,将在 NOT NULL 约束处失败
+ await db.execute(sql`
+ UPDATE class_subject_teachers cst
+ JOIN subjects s ON (
+ s.name = cst.subject
+ OR s.code = CASE cst.subject
+ WHEN '语文' THEN 'CHINESE'
+ WHEN '数学' THEN 'MATH'
+ WHEN '英语' THEN 'ENG'
+ WHEN '美术' THEN 'ART'
+ WHEN '体育' THEN 'PE'
+ WHEN '科学' THEN 'SCI'
+ WHEN '社会' THEN 'SOC'
+ WHEN '音乐' THEN 'MUSIC'
+ ELSE NULL
+ END
+ )
+ SET cst.subject_id = s.id
+ WHERE cst.subject_id IS NULL
+ `)
+
+ // 3) enforce NOT NULL
+ await db.execute(sql`ALTER TABLE class_subject_teachers MODIFY COLUMN subject_id VARCHAR(128) NOT NULL;`)
+
+ // 4) drop old PK and create new PK (class_id, subject_id)
+ try { await db.execute(sql`ALTER TABLE class_subject_teachers DROP PRIMARY KEY;`) } catch {}
+ await db.execute(sql`ALTER TABLE class_subject_teachers ADD PRIMARY KEY (class_id, subject_id);`)
+
+ // 5) drop old subject column if exists
+ await db.execute(sql`ALTER TABLE class_subject_teachers DROP COLUMN IF EXISTS subject;`)
+
+ // 6) add index and FK
+ try { await db.execute(sql`CREATE INDEX class_subject_teachers_subject_id_idx ON class_subject_teachers (subject_id);`) } catch {}
+ try { await db.execute(sql`ALTER TABLE class_subject_teachers DROP FOREIGN KEY cst_s_fk;`) } catch {}
+ await db.execute(sql`
+ ALTER TABLE class_subject_teachers
+ ADD CONSTRAINT cst_s_fk
+ FOREIGN KEY (subject_id) REFERENCES subjects(id)
+ ON DELETE CASCADE
+ `)
+
+ console.log("Migration completed: class_subject_teachers now uses subject_id mapping.")
+}
+
+main().catch((err) => {
+ console.error(err)
+ process.exit(1)
+})
diff --git a/scripts/seed.ts b/scripts/seed.ts
index a815e5e..8759d06 100644
--- a/scripts/seed.ts
+++ b/scripts/seed.ts
@@ -18,6 +18,7 @@ import {
import { createId } from "@paralleldrive/cuid2";
import { faker } from "@faker-js/faker";
import { sql } from "drizzle-orm";
+import { hash } from "bcryptjs";
/**
* Enterprise-Grade Seed Script for Next_Edu
@@ -77,27 +78,31 @@ async function seed() {
]);
// Users
+ const passwordHash = await hash("123456", 10);
const usersData = [
{
id: "user_admin",
name: "Admin User",
email: "admin@next-edu.com",
role: "admin", // Legacy field
- image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin"
+ image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin",
+ password: passwordHash
},
{
id: "user_teacher_math",
name: "Mr. Math",
email: "math@next-edu.com",
role: "teacher",
- image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math"
+ image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math",
+ password: passwordHash
},
{
id: "user_student_1",
name: "Alice Student",
email: "alice@next-edu.com",
role: "student",
- image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice"
+ image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice",
+ password: passwordHash
}
];
@@ -122,6 +127,7 @@ async function seed() {
email: faker.internet.email().toLowerCase(),
role: "student",
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${studentId}`,
+ password: passwordHash,
});
await db.insert(usersToRoles).values({ userId: studentId, roleId: roleMap.student });
}
@@ -136,13 +142,14 @@ async function seed() {
// --- Seeding Subjects ---
await db.insert(subjects).values([
- { id: createId(), name: "Mathematics", code: "MATH", order: 1 },
- { id: createId(), name: "Physics", code: "PHYS", order: 2 },
- { id: createId(), name: "Chemistry", code: "CHEM", order: 3 },
- { id: createId(), name: "English", code: "ENG", order: 4 },
- { id: createId(), name: "History", code: "HIST", order: 5 },
- { id: createId(), name: "Geography", code: "GEO", order: 6 },
- { id: createId(), name: "Biology", code: "BIO", order: 7 },
+ { id: createId(), name: "语文", code: "CHINESE", order: 1 },
+ { id: createId(), name: "数学", code: "MATH", order: 2 },
+ { id: createId(), name: "英语", code: "ENG", order: 3 },
+ { id: createId(), name: "美术", code: "ART", order: 4 },
+ { id: createId(), name: "体育", code: "PE", order: 5 },
+ { id: createId(), name: "科学", code: "SCI", order: 6 },
+ { id: createId(), name: "社会", code: "SOC", order: 7 },
+ { id: createId(), name: "音乐", code: "MUSIC", order: 8 },
])
await db.insert(grades).values([
diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx
index dacb327..74b4db3 100644
--- a/src/app/(auth)/register/page.tsx
+++ b/src/app/(auth)/register/page.tsx
@@ -25,7 +25,7 @@ export default function RegisterPage() {
if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" }
try {
- const [{ db }, { users }] = await Promise.all([
+ const [{ db }, { roles, users, usersToRoles }] = await Promise.all([
import("@/shared/db"),
import("@/shared/db/schema"),
])
@@ -45,13 +45,25 @@ export default function RegisterPage() {
if (existing) return { success: false, message: "该邮箱已注册" }
const hashedPassword = normalizeBcryptHash(await hash(password, 10))
+ const userId = createId()
await db.insert(users).values({
- id: createId(),
+ id: userId,
name: name.length ? name : null,
email,
password: hashedPassword,
- role: "student",
})
+ const roleRow = await db.query.roles.findFirst({
+ where: eq(roles.name, "student"),
+ columns: { id: true },
+ })
+ if (!roleRow) {
+ await db.insert(roles).values({ name: "student" })
+ }
+ const resolvedRole = roleRow
+ ?? (await db.query.roles.findFirst({ where: eq(roles.name, "student"), columns: { id: true } }))
+ if (resolvedRole?.id) {
+ await db.insert(usersToRoles).values({ userId, roleId: resolvedRole.id })
+ }
return { success: true, message: "账户创建成功" }
} catch (error) {
diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx
index ecc3ac2..3f39c5a 100644
--- a/src/app/(dashboard)/dashboard/page.tsx
+++ b/src/app/(dashboard)/dashboard/page.tsx
@@ -1,19 +1,18 @@
import { redirect } from "next/navigation"
import { auth } from "@/auth"
+import { getUserProfile } from "@/modules/users/data-access"
export const dynamic = "force-dynamic"
-const normalizeRole = (value: unknown) => {
- const role = String(value ?? "").trim().toLowerCase()
- if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
- return "student"
-}
-
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/login")
- const role = normalizeRole(session.user.role)
+ const userId = String(session.user.id ?? "").trim()
+ if (!userId) redirect("/login")
+ const profile = await getUserProfile(userId)
+ if (!profile) redirect("/login")
+ const role = profile.role || "student"
if (role === "admin") redirect("/admin/dashboard")
if (role === "student") redirect("/student/dashboard")
diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx
index b78db2c..c38a8fe 100644
--- a/src/app/(dashboard)/profile/page.tsx
+++ b/src/app/(dashboard)/profile/page.tsx
@@ -2,7 +2,7 @@ import Link from "next/link"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
-import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
+import { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
@@ -44,6 +44,7 @@ export default async function ProfilePage() {
const role = userProfile.role || "student"
const isStudent = role === "student"
+ const isTeacher = role === "teacher"
const studentData =
isStudent
@@ -107,6 +108,14 @@ export default async function ProfilePage() {
})()
: null
+ const teacherData =
+ isTeacher
+ ? await (async () => {
+ const [subjects, classes] = await Promise.all([getTeacherTeachingSubjects(), getTeacherClasses()])
+ return { subjects, classes }
+ })()
+ : null
+
return (
@@ -231,6 +240,65 @@ export default async function ProfilePage() {
) : null}
+
+ {teacherData ? (
+
+
+
+
Teacher Overview
+
Your teaching subjects and classes.
+
+
+
+
+
+ Teaching Subjects
+ Subjects you are currently assigned to teach.
+
+
+ {teacherData.subjects.length === 0 ? (
+ No subjects assigned yet.
+ ) : (
+
+ {teacherData.subjects.map((subject) => (
+
+ {subject}
+
+ ))}
+
+ )}
+
+
+
+
+
+ Teaching Classes
+ Classes you are currently managing.
+
+
+ {teacherData.classes.length === 0 ? (
+ No classes assigned yet.
+ ) : (
+ teacherData.classes.map((cls) => (
+
+
+
{cls.name}
+
+ {cls.grade}
+ {cls.homeroom ? ` • ${cls.homeroom}` : ""}
+
+
+
+
+ ))
+ )}
+
+
+
+
+ ) : null}
)
}
diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx
index c5b41c9..c0f26ee 100644
--- a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx
+++ b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx
@@ -1,4 +1,3 @@
-import Link from "next/link"
import { notFound } from "next/navigation"
import { BookOpen, Inbox } from "lucide-react"
diff --git a/src/app/(dashboard)/student/learning/textbooks/page.tsx b/src/app/(dashboard)/student/learning/textbooks/page.tsx
index 7fa67ad..1910e1c 100644
--- a/src/app/(dashboard)/student/learning/textbooks/page.tsx
+++ b/src/app/(dashboard)/student/learning/textbooks/page.tsx
@@ -5,8 +5,6 @@ import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
-import { Button } from "@/shared/components/ui/button"
-import Link from "next/link"
export const dynamic = "force-dynamic"
@@ -27,12 +25,6 @@ export default async function StudentTextbooksPage({
if (!student) {
return (
-
-
-
Textbooks
-
Browse your course textbooks.
-
-
)
@@ -47,7 +39,7 @@ export default async function StudentTextbooksPage({
return (
-
+ {/*
Textbooks
Browse your course textbooks.
@@ -55,7 +47,7 @@ export default async function StudentTextbooksPage({
-
+
*/}
diff --git a/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx b/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx
index a5a7919..5254522 100644
--- a/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx
+++ b/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx
@@ -5,26 +5,21 @@ import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detai
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
-import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
export const dynamic = "force-dynamic"
-type SearchParams = { [key: string]: string | string[] | undefined }
-
export default async function ClassDetailPage({
params,
- searchParams,
}: {
params: Promise<{ id: string }>
- searchParams: Promise
}) {
const { id } = await params
// Parallel data fetching
const [insights, students, schedule] = await Promise.all([
- getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
+ getClassHomeworkInsights({ classId: id, limit: 20 }),
getClassStudents({ classId: id }),
getClassSchedule({ classId: id }),
])
@@ -32,7 +27,7 @@ export default async function ClassDetailPage({
if (!insights) return notFound()
// Fetch subject scores
- const studentScores = await getClassStudentSubjectScoresV2(id)
+ const studentScores = await getClassStudentSubjectScoresV2({ classId: id })
// Data mapping for widgets
const assignmentSummaries = insights.assignments.map(a => ({
@@ -91,10 +86,7 @@ export default async function ClassDetailPage({
{/* Main Content Area (Left 2/3) */}
-
+
-
+
)
}
diff --git a/src/app/(dashboard)/teacher/classes/students/page.tsx b/src/app/(dashboard)/teacher/classes/students/page.tsx
index a7ab8f0..59ce2af 100644
--- a/src/app/(dashboard)/teacher/classes/students/page.tsx
+++ b/src/app/(dashboard)/teacher/classes/students/page.tsx
@@ -82,7 +82,6 @@ function StudentsResultsFallback() {
export default async function StudentsPage({ searchParams }: { searchParams: Promise
}) {
const classes = await getTeacherClasses()
- const params = await searchParams
// Logic to determine default class (first one available)
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
diff --git a/src/app/(dashboard)/teacher/exams/create/page.tsx b/src/app/(dashboard)/teacher/exams/create/page.tsx
index 1ce35ed..da1e91a 100644
--- a/src/app/(dashboard)/teacher/exams/create/page.tsx
+++ b/src/app/(dashboard)/teacher/exams/create/page.tsx
@@ -1,37 +1,9 @@
import { ExamForm } from "@/modules/exams/components/exam-form"
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbPage,
- BreadcrumbSeparator,
-} from "@/shared/components/ui/breadcrumb"
export default function CreateExamPage() {
return (
-
-
-
-
-
- Exams
-
-
-
- Create
-
-
-
-
-
-
Create Exam
-
- Set up a new exam draft and choose your assembly method.
-
-
-
-
+
+
)
diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx
index 17041d9..627e475 100644
--- a/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx
+++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx
@@ -5,7 +5,6 @@ import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
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"
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
diff --git a/src/app/(dashboard)/teacher/homework/assignments/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/page.tsx
index 66c5dbe..4ec9baf 100644
--- a/src/app/(dashboard)/teacher/homework/assignments/page.tsx
+++ b/src/app/(dashboard)/teacher/homework/assignments/page.tsx
@@ -14,6 +14,7 @@ import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignments } from "@/modules/homework/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { PenTool, PlusCircle } from "lucide-react"
+import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
@@ -27,9 +28,10 @@ const getParam = (params: SearchParams, key: string) => {
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise
}) {
const sp = await searchParams
const classId = getParam(sp, "classId") || undefined
+ const creatorId = await getTeacherIdForMutations()
const [assignments, classes] = await Promise.all([
- getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }),
+ getHomeworkAssignments({ creatorId, classId: classId && classId !== "all" ? classId : undefined }),
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
])
const hasAssignments = assignments.length > 0
diff --git a/src/app/(dashboard)/teacher/questions/page.tsx b/src/app/(dashboard)/teacher/questions/page.tsx
index 51bd9ff..fff5112 100644
--- a/src/app/(dashboard)/teacher/questions/page.tsx
+++ b/src/app/(dashboard)/teacher/questions/page.tsx
@@ -23,6 +23,7 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise String(r.name ?? "").trim().toLowerCase())
- if (role === "admin" && currentRole !== "admin") {
+ if (role === "admin" && !currentMapped.includes("admin")) {
return NextResponse.json({ success: false, message: "Forbidden" }, { status: 403 })
}
@@ -58,16 +59,33 @@ export async function POST(req: Request) {
.map((s) => String(s).trim())
.filter((s): s is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(s as ClassSubject))
+ const roleRow = await db.query.roles.findFirst({
+ where: eq(roles.name, role),
+ columns: { id: true },
+ })
+ if (!roleRow) {
+ await db.insert(roles).values({ name: role })
+ }
+ const resolvedRole = roleRow
+ ?? (await db.query.roles.findFirst({ where: eq(roles.name, role), columns: { id: true } }))
+ const roleId = resolvedRole?.id
+
await db
.update(users)
.set({
- role,
name,
phone: phone.length ? phone : null,
address: address.length ? address : null,
})
.where(eq(users.id, userId))
+ if (roleId) {
+ await db
+ .insert(usersToRoles)
+ .values({ userId, roleId })
+ .onDuplicateKeyUpdate({ set: { roleId } })
+ }
+
if (role === "student" && codes.length) {
for (const code of codes) {
await enrollStudentByInvitationCode(userId, code)
@@ -87,14 +105,26 @@ export async function POST(req: Request) {
}
}
+ // Resolve subject ids when possible (by name exact match)
+ const subjectsFound = await db
+ .select({ id: subjects.id, name: subjects.name })
+ .from(subjects)
+ .where(inArray(subjects.name, teacherSubjects))
+ const subjectIdByName = new Map()
+ for (const s of subjectsFound) {
+ if (s.name && s.id) subjectIdByName.set(String(s.name), String(s.id))
+ }
+
for (const code of codes) {
const classId = byCode.get(code)
if (!classId) continue
for (const subject of teacherSubjects) {
+ const subjectId = subjectIdByName.get(subject)
+ if (!subjectId) continue
await db
.insert(classSubjectTeachers)
- .values({ classId, subject, teacherId: userId })
- .onDuplicateKeyUpdate({ set: { teacherId: userId, updatedAt: new Date() } })
+ .values({ classId, subjectId, teacherId: userId })
+ .onDuplicateKeyUpdate({ set: { teacherId: userId, subjectId, updatedAt: new Date() } })
}
}
}
@@ -106,4 +136,3 @@ export async function POST(req: Request) {
return NextResponse.json({ success: true })
}
-
diff --git a/src/app/api/onboarding/status/route.ts b/src/app/api/onboarding/status/route.ts
index fe223cf..5f61aac 100644
--- a/src/app/api/onboarding/status/route.ts
+++ b/src/app/api/onboarding/status/route.ts
@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
-import { users } from "@/shared/db/schema"
+import { roles, users, usersToRoles } from "@/shared/db/schema"
export const dynamic = "force-dynamic"
@@ -14,12 +14,32 @@ export async function GET() {
return NextResponse.json({ required: false })
}
- const row = await db.query.users.findFirst({
- where: eq(users.id, userId),
- columns: { onboardedAt: true, role: true },
- })
+ const [row, roleRows] = await Promise.all([
+ db.query.users.findFirst({
+ where: eq(users.id, userId),
+ columns: { onboardedAt: true },
+ }),
+ db
+ .select({ name: roles.name })
+ .from(usersToRoles)
+ .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
+ .where(eq(usersToRoles.userId, userId)),
+ ])
+
+ const normalizeRole = (value: string) => {
+ const role = value.trim().toLowerCase()
+ if (role === "grade_head" || role === "teaching_head") return "teacher"
+ if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
+ return ""
+ }
+
+ const mappedRoles = roleRows.map((r) => normalizeRole(r.name)).filter(Boolean)
+ const resolvedRole = mappedRoles.find((r) => r === "admin")
+ ?? mappedRoles.find((r) => r === "teacher")
+ ?? mappedRoles.find((r) => r === "parent")
+ ?? mappedRoles.find((r) => r === "student")
+ ?? "student"
const required = !row?.onboardedAt
- return NextResponse.json({ required, role: row?.role ?? "student" })
+ return NextResponse.json({ required, role: resolvedRole })
}
-
diff --git a/src/auth.ts b/src/auth.ts
index 3bb5270..63e4348 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -1,13 +1,23 @@
-import { compare, hash } from "bcryptjs"
+import { compare } from "bcryptjs"
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
const normalizeRole = (value: unknown) => {
const role = String(value ?? "").trim().toLowerCase()
+ if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return "student"
}
+const resolvePrimaryRole = (roleNames: string[]) => {
+ const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean)
+ if (mapped.includes("admin")) return "admin"
+ if (mapped.includes("teacher")) return "teacher"
+ if (mapped.includes("parent")) return "parent"
+ if (mapped.includes("student")) return "student"
+ return "student"
+}
+
const normalizeBcryptHash = (value: string) => {
if (value.startsWith("$2")) return value
if (value.startsWith("$")) return `$2b${value}`
@@ -30,7 +40,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const password = String(credentials?.password ?? "")
if (!email || !password) return null
- const [{ eq }, { db }, { users }] = await Promise.all([
+ const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
import("drizzle-orm"),
import("@/shared/db"),
import("@/shared/db/schema"),
@@ -48,11 +58,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const ok = await compare(password, normalizedPassword)
if (!ok) return null
+ const roleRows = await db
+ .select({ name: roles.name })
+ .from(usersToRoles)
+ .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
+ .where(eq(usersToRoles.userId, user.id))
+
+ const resolvedRole = resolvePrimaryRole(roleRows.map((r) => r.name))
+
return {
id: user.id,
name: user.name ?? undefined,
email: user.email,
- role: normalizeRole(user.role),
+ role: resolvedRole,
}
},
}),
@@ -67,19 +85,26 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const userId = String(token.id ?? "").trim()
if (userId) {
- const [{ eq }, { db }, { users }] = await Promise.all([
+ const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
import("drizzle-orm"),
import("@/shared/db"),
import("@/shared/db/schema"),
])
- const fresh = await db.query.users.findFirst({
+ const [fresh, roleRows] = await Promise.all([
+ db.query.users.findFirst({
where: eq(users.id, userId),
- columns: { role: true, name: true },
- })
+ columns: { name: true },
+ }),
+ db
+ .select({ name: roles.name })
+ .from(usersToRoles)
+ .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
+ .where(eq(usersToRoles.userId, userId)),
+ ])
if (fresh) {
- token.role = normalizeRole(fresh.role ?? token.role)
+ token.role = resolvePrimaryRole(roleRows.map((r) => r.name))
token.name = fresh.name ?? token.name
}
}
diff --git a/src/modules/classes/actions.ts b/src/modules/classes/actions.ts
index 2db9684..5db6e8d 100644
--- a/src/modules/classes/actions.ts
+++ b/src/modules/classes/actions.ts
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache"
-import { and, eq, sql, or, inArray } from "drizzle-orm"
+import { and, eq, sql, or } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
@@ -16,6 +16,7 @@ import {
deleteTeacherClass,
enrollStudentByEmail,
enrollStudentByInvitationCode,
+ enrollTeacherByInvitationCode,
ensureClassInvitationCode,
regenerateClassInvitationCode,
setClassSubjectTeachers,
@@ -371,8 +372,18 @@ export async function joinClassByInvitationCodeAction(
return { success: false, message: "Unauthorized" }
}
+ const subjectValue = formData.get("subject")
+ const subject = role === "teacher" && typeof subjectValue === "string" ? subjectValue.trim() : null
+
+ if (role === "teacher" && (!subject || subject.length === 0)) {
+ return { success: false, message: "Subject is required" }
+ }
+
try {
- const classId = await enrollStudentByInvitationCode(session.user.id, code)
+ const classId =
+ role === "teacher"
+ ? await enrollTeacherByInvitationCode(session.user.id, code, subject)
+ : await enrollStudentByInvitationCode(session.user.id, code)
if (role === "student") {
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")
diff --git a/src/modules/classes/components/class-detail/class-quick-actions.tsx b/src/modules/classes/components/class-detail/class-quick-actions.tsx
index 2e5b6c9..aedb9cb 100644
--- a/src/modules/classes/components/class-detail/class-quick-actions.tsx
+++ b/src/modules/classes/components/class-detail/class-quick-actions.tsx
@@ -1,6 +1,6 @@
import Link from "next/link"
-import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
+import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
diff --git a/src/modules/classes/components/class-detail/class-schedule-widget.tsx b/src/modules/classes/components/class-detail/class-schedule-widget.tsx
index 893b4fa..36c5ff1 100644
--- a/src/modules/classes/components/class-detail/class-schedule-widget.tsx
+++ b/src/modules/classes/components/class-detail/class-schedule-widget.tsx
@@ -42,7 +42,7 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
return (
- {WEEKDAYS.slice(0, 5).map((day, i) => (
+ {WEEKDAYS.slice(0, 5).map((day) => (
{day}
diff --git a/src/modules/classes/components/class-detail/class-students-widget.tsx b/src/modules/classes/components/class-detail/class-students-widget.tsx
index 0a22e9a..f201685 100644
--- a/src/modules/classes/components/class-detail/class-students-widget.tsx
+++ b/src/modules/classes/components/class-detail/class-students-widget.tsx
@@ -6,7 +6,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avat
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
-import { formatDate } from "@/shared/lib/utils"
interface StudentSummary {
id: string
diff --git a/src/modules/classes/components/class-detail/class-trends-widget.tsx b/src/modules/classes/components/class-detail/class-trends-widget.tsx
index 14804d7..cf7d19f 100644
--- a/src/modules/classes/components/class-detail/class-trends-widget.tsx
+++ b/src/modules/classes/components/class-detail/class-trends-widget.tsx
@@ -1,13 +1,12 @@
"use client"
-import { useState, useMemo } from "react"
+import { useState } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChevronDown } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
-import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
@@ -31,7 +30,6 @@ interface AssignmentSummary {
}
interface ClassTrendsWidgetProps {
- classId: string
assignments: AssignmentSummary[]
compact?: boolean
className?: string
@@ -121,7 +119,7 @@ export function ClassSubmissionTrendChart({
)
}
-export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
+export function ClassTrendsWidget({ assignments, compact, className }: ClassTrendsWidgetProps) {
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
const [selectedSubject, setSelectedSubject] = useState
("all")
diff --git a/src/modules/classes/components/my-classes-grid.tsx b/src/modules/classes/components/my-classes-grid.tsx
index 832e712..cc723e6 100644
--- a/src/modules/classes/components/my-classes-grid.tsx
+++ b/src/modules/classes/components/my-classes-grid.tsx
@@ -1,7 +1,7 @@
"use client"
import Link from "next/link"
-import { useMemo, useState } from "react"
+import { useState } from "react"
import { useRouter } from "next/navigation"
import {
Plus,
@@ -10,11 +10,9 @@ import {
Users,
MapPin,
GraduationCap,
- Search,
} from "lucide-react"
import { toast } from "sonner"
-import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -30,30 +28,35 @@ import {
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
-import type { TeacherClass, ClassScheduleItem } from "../types"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
+import type { TeacherClass } from "../types"
import {
ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction,
joinClassByInvitationCodeAction,
} from "../actions"
-const GRADIENTS = [
- "bg-card border-border",
- "bg-card border-border",
- "bg-card border-border",
- "bg-card border-border",
- "bg-card border-border",
-]
-
-function getClassGradient(id: string) {
- return "bg-card border-border shadow-sm hover:shadow-md"
+const getSeededValue = (seed: string, index: number) => {
+ let h = 2166136261
+ const str = `${seed}:${index}`
+ for (let i = 0; i < str.length; i += 1) {
+ h ^= str.charCodeAt(i)
+ h = Math.imul(h, 16777619)
+ }
+ return (h >>> 0) / 4294967296
}
-export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
+export function MyClassesGrid({
+ classes,
+ subjectOptions,
+}: {
+ classes: TeacherClass[]
+ subjectOptions: string[]
+}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
+ const [joinSubject, setJoinSubject] = useState("")
const handleJoin = async (formData: FormData) => {
setIsWorking(true)
@@ -62,6 +65,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
if (res.success) {
toast.success(res.message || "Joined class successfully")
setJoinOpen(false)
+ setJoinSubject("")
router.refresh()
} else {
toast.error(res.message || "Failed to join class")
@@ -83,6 +87,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
onOpenChange={(open) => {
if (isWorking) return
setJoinOpen(open)
+ if (!open) setJoinSubject("")
}}
>
@@ -140,12 +145,30 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
Ask your administrator for the code if you don't have one.
+
+
+
+
+
-
@@ -167,7 +190,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
/>
) : (
classes.map((c) => (
-
+
))
)}
@@ -182,11 +205,9 @@ import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
function ClassTicket({
c,
- isWorking,
onWorkingChange,
}: {
c: TeacherClass
- isWorking: boolean
onWorkingChange: (v: boolean) => void
}) {
const router = useRouter()
@@ -256,7 +277,11 @@ function ClassTicket({
{/* Decorative Barcode Strip */}
{Array.from({ length: 20 }).map((_, i) => (
-
+
))}
@@ -320,7 +345,7 @@ function ClassTicket({
{Array.from({ length: 16 }).map((_, i) => (
-
0.5 && "bg-black")}>
+
0.5 && "bg-black")}>
))}
@@ -373,12 +398,7 @@ function ClassTicket({
{/* Real Chart */}
-
+
diff --git a/src/modules/classes/components/schedule-view.tsx b/src/modules/classes/components/schedule-view.tsx
index a6ecf4d..f832dbd 100644
--- a/src/modules/classes/components/schedule-view.tsx
+++ b/src/modules/classes/components/schedule-view.tsx
@@ -2,11 +2,9 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
-import { Clock, MapPin, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
+import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
-import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
-import { Badge } from "@/shared/components/ui/badge"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
@@ -518,4 +516,4 @@ export function ScheduleView({
)
-}
\ No newline at end of file
+}
diff --git a/src/modules/classes/components/students-filters.tsx b/src/modules/classes/components/students-filters.tsx
index cfd26e1..4e45a1e 100644
--- a/src/modules/classes/components/students-filters.tsx
+++ b/src/modules/classes/components/students-filters.tsx
@@ -1,9 +1,9 @@
"use client"
-import { useEffect, useMemo, useState } from "react"
+import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
-import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
+import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
import { toast } from "sonner"
import { Input } from "@/shared/components/ui/input"
@@ -33,7 +33,6 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label"
-import { cn } from "@/shared/lib/utils"
import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions"
@@ -78,8 +77,6 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
- const hasFilters = search || classId !== "all" || status !== "all"
-
return (
diff --git a/src/modules/classes/components/students-table.tsx b/src/modules/classes/components/students-table.tsx
index 04c9560..5dc9a0b 100644
--- a/src/modules/classes/components/students-table.tsx
+++ b/src/modules/classes/components/students-table.tsx
@@ -5,11 +5,10 @@ import { useRouter } from "next/navigation"
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
import { toast } from "sonner"
-import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
-import { cn, formatDate } from "@/shared/lib/utils"
+import { cn } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
diff --git a/src/modules/classes/data-access.ts b/src/modules/classes/data-access.ts
index dbc4f78..9f96e38 100644
--- a/src/modules/classes/data-access.ts
+++ b/src/modules/classes/data-access.ts
@@ -2,9 +2,10 @@ import "server-only";
import { randomInt } from "node:crypto"
import { cache } from "react"
-import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
+import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
+import { auth } from "@/auth"
import { db } from "@/shared/db"
import {
classes,
@@ -19,7 +20,9 @@ import {
schools,
subjects,
exams,
+ roles,
users,
+ usersToRoles,
} from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS } from "./types"
import type {
@@ -43,16 +46,22 @@ import type {
UpdateTeacherClassInput,
} from "./types"
-const getDefaultTeacherId = cache(async () => {
- const [row] = await db
+const getSessionTeacherId = async (): Promise
=> {
+ const session = await auth()
+ const userId = String(session?.user?.id ?? "").trim()
+ if (!userId) return null
+
+ const [teacher] = await db
.select({ id: users.id })
.from(users)
- .where(eq(users.role, "teacher"))
- .orderBy(asc(users.createdAt))
+ .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
+ .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
+ .where(and(eq(users.id, userId), eq(roles.name, "teacher")))
.limit(1)
+ return teacher?.id ?? null
+}
- return row?.id
-})
+// Strict subjectId-based mapping: no aliasing
const isDuplicateInvitationCodeError = (err: unknown) => {
if (!err) return false
@@ -80,11 +89,20 @@ const generateUniqueInvitationCode = async (): Promise => {
}
export const getTeacherIdForMutations = async (): Promise => {
- const teacherId = await getDefaultTeacherId()
- if (!teacherId) throw new Error("No teacher available")
+ const teacherId = await getSessionTeacherId()
+ if (!teacherId) throw new Error("Teacher not found")
return teacherId
}
+export const getClassSubjects = async (): Promise => {
+ const rows = await db.query.subjects.findMany({
+ orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
+ })
+
+ const names = rows.map((r) => r.name.trim()).filter((n) => n.length > 0)
+ return Array.from(new Set(names))
+}
+
const normalizeSortText = (v: string | null | undefined) => (typeof v === "string" ? v.trim().toLowerCase() : "")
const parseFirstInt = (v: string) => {
@@ -118,23 +136,30 @@ const compareClassLike = (
return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room))
}
+const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise => {
+ const ownedIds = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId))
+ const assignedIds = await db
+ .select({ id: classSubjectTeachers.classId })
+ .from(classSubjectTeachers)
+ .where(eq(classSubjectTeachers.teacherId, teacherId))
+ return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)]))
+}
+
+const getTeacherSubjectIdsForClass = async (teacherId: string, classId: string): Promise => {
+ const rows = await db
+ .select({ subjectId: classSubjectTeachers.subjectId })
+ .from(classSubjectTeachers)
+ .where(and(eq(classSubjectTeachers.teacherId, teacherId), eq(classSubjectTeachers.classId, classId)))
+ return Array.from(new Set(rows.map((r) => String(r.subjectId))))
+}
+
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise => {
- const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
+ const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const rows = await (async () => {
try {
- const ownedIds = await db
- .select({ id: classes.id })
- .from(classes)
- .where(eq(classes.teacherId, teacherId))
-
- const enrolledIds = await db
- .select({ id: classEnrollments.classId })
- .from(classEnrollments)
- .where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
-
- const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
+ const allIds = await getAccessibleClassIdsForTeacher(teacherId)
if (allIds.length === 0) return []
@@ -206,7 +231,9 @@ export const getTeacherOptions = cache(async (): Promise => {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
- .where(eq(users.role, "teacher"))
+ .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
+ .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
+ .where(eq(roles.name, "teacher"))
.orderBy(asc(users.createdAt))
return rows.map((r) => ({
@@ -216,6 +243,23 @@ export const getTeacherOptions = cache(async (): Promise => {
}))
})
+export const getTeacherTeachingSubjects = cache(async (): Promise => {
+ const teacherId = await getSessionTeacherId()
+ if (!teacherId) return []
+
+ const rows = await db
+ .select({ subject: subjects.name })
+ .from(classSubjectTeachers)
+ .innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
+ .where(eq(classSubjectTeachers.teacherId, teacherId))
+ .groupBy(subjects.name)
+ .orderBy(asc(subjects.name))
+
+ return rows
+ .map((r) => r.subject as ClassSubject)
+ .filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s))
+})
+
export const getAdminClasses = cache(async (): Promise => {
const [rows, subjectRows] = await Promise.all([
(async () => {
@@ -304,14 +348,15 @@ export const getAdminClasses = cache(async (): Promise =>
db
.select({
classId: classSubjectTeachers.classId,
- subject: classSubjectTeachers.subject,
+ subject: subjects.name,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
+ .innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
- .orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
+ .orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
])
const subjectsByClassId = new Map>()
@@ -425,16 +470,17 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise>()
@@ -589,14 +635,17 @@ export const getStudentSchedule = cache(async (studentId: string): Promise => {
- const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
+ const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
const q = params?.q?.trim().toLowerCase()
const status = params?.status?.trim().toLowerCase()
- const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
+ const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
+ if (accessibleIds.length === 0) return []
+
+ const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
if (classId) {
conditions.push(eq(classes.id, classId))
@@ -647,12 +696,15 @@ export const getClassStudents = cache(
export const getClassSchedule = cache(
async (params?: { classId?: string; teacherId?: string }): Promise => {
- const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
+ const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
- const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
+ const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
+ if (accessibleIds.length === 0) return []
+
+ const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
if (classId) conditions.push(eq(classSchedule.classId, classId))
const rows = await db
@@ -707,11 +759,13 @@ const toScoreStats = (scores: number[]): ScoreStats => {
export const getClassHomeworkInsights = cache(
async (params: { classId: string; teacherId?: string; limit?: number }): Promise => {
- const teacherId = params.teacherId ?? (await getDefaultTeacherId())
+ const teacherId = params.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return null
const classId = params.classId.trim()
if (!classId) return null
+ const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
+ if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return null
const [classRow] = await db
.select({
@@ -721,12 +775,15 @@ export const getClassHomeworkInsights = cache(
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
+ teacherId: classes.teacherId,
})
.from(classes)
- .where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
+ .where(and(eq(classes.id, classId), inArray(classes.id, accessibleIds)))
.limit(1)
if (!classRow) return null
+ const isHomeroomTeacher = classRow.teacherId === teacherId
+ const subjectIdFilter = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
const enrollments = await db
.select({
@@ -735,12 +792,29 @@ export const getClassHomeworkInsights = cache(
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
- .where(and(eq(classes.teacherId, teacherId), eq(classEnrollments.classId, classId)))
+ .where(and(inArray(classes.id, accessibleIds), eq(classEnrollments.classId, classId)))
const activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId)
const inactiveStudentIds = enrollments.filter((e) => e.status !== "active").map((e) => e.studentId)
const studentIds = enrollments.map((e) => e.studentId)
+ if (!isHomeroomTeacher && subjectIdFilter.length === 0) {
+ return {
+ class: {
+ id: classRow.id,
+ name: classRow.name,
+ grade: classRow.grade,
+ homeroom: classRow.homeroom,
+ room: classRow.room,
+ invitationCode: classRow.invitationCode ?? null,
+ },
+ studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length },
+ assignments: [],
+ latest: null,
+ overallScores: { count: 0, avg: null, median: null, min: null, max: null },
+ }
+ }
+
if (studentIds.length === 0) {
return {
class: {
@@ -782,6 +856,10 @@ export const getClassHomeworkInsights = cache(
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
+ const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)]
+ if (subjectIdFilter.length > 0) {
+ assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter))
+ }
const assignments = await db
.select({
id: homeworkAssignments.id,
@@ -795,7 +873,7 @@ export const getClassHomeworkInsights = cache(
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
- .where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
+ .where(and(...assignmentConditions))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
@@ -1239,6 +1317,12 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode()
try {
+ const subjectRows = await db
+ .select({ id: subjects.id, name: subjects.name })
+ .from(subjects)
+ .where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
+ const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
+
await db.transaction(async (tx) => {
await tx.insert(classes).values({
id,
@@ -1253,13 +1337,14 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
teacherId,
})
- await tx.insert(classSubjectTeachers).values(
- DEFAULT_CLASS_SUBJECTS.map((subject) => ({
+ const values = DEFAULT_CLASS_SUBJECTS
+ .filter((name) => idByName.has(name))
+ .map((name) => ({
classId: id,
- subject,
+ subjectId: idByName.get(name)!,
teacherId: null,
}))
- )
+ await tx.insert(classSubjectTeachers).values(values)
})
return id
} catch (err) {
@@ -1291,13 +1376,21 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
const [teacher] = await db
.select({ id: users.id })
.from(users)
- .where(and(eq(users.id, teacherId), eq(users.role, "teacher")))
+ .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
+ .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
+ .where(and(eq(users.id, teacherId), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode()
try {
+ const subjectRows = await db
+ .select({ id: subjects.id, name: subjects.name })
+ .from(subjects)
+ .where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
+ const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
+
await db.transaction(async (tx) => {
await tx.insert(classes).values({
id,
@@ -1312,13 +1405,14 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
teacherId,
})
- await tx.insert(classSubjectTeachers).values(
- DEFAULT_CLASS_SUBJECTS.map((subject) => ({
+ const values = DEFAULT_CLASS_SUBJECTS
+ .filter((name) => idByName.has(name))
+ .map((name) => ({
classId: id,
- subject,
+ subjectId: idByName.get(name)!,
teacherId: null,
}))
- )
+ await tx.insert(classSubjectTeachers).values(values)
})
return id
} catch (err) {
@@ -1410,6 +1504,123 @@ export async function enrollStudentByInvitationCode(studentId: string, invitatio
return cls.id
}
+export async function enrollTeacherByInvitationCode(
+ teacherId: string,
+ invitationCode: string,
+ subject: string | null
+): Promise {
+ const tid = teacherId.trim()
+ const code = invitationCode.trim()
+ if (!tid) throw new Error("Missing teacher id")
+ if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
+
+ const [teacher] = await db
+ .select({ id: users.id })
+ .from(users)
+ .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
+ .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
+ .where(and(eq(users.id, tid), eq(roles.name, "teacher")))
+ .limit(1)
+
+ if (!teacher) throw new Error("Teacher not found")
+
+ const [cls] = await db
+ .select({ id: classes.id, teacherId: classes.teacherId })
+ .from(classes)
+ .where(eq(classes.invitationCode, code))
+ .limit(1)
+
+ if (!cls) throw new Error("Invalid invitation code")
+ if (cls.teacherId === tid) return cls.id
+
+ const subjectValue = typeof subject === "string" ? subject.trim() : ""
+ const [existing] = await db
+ .select({ id: classSubjectTeachers.classId })
+ .from(classSubjectTeachers)
+ .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid)))
+ .limit(1)
+
+ if (existing && !subjectValue) return cls.id
+ if (subjectValue) {
+ const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
+ if (!subRow) throw new Error("Subject not found")
+ const sid = subRow.id
+
+ const [mapping] = await db
+ .select({ teacherId: classSubjectTeachers.teacherId })
+ .from(classSubjectTeachers)
+ .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid)))
+ .limit(1)
+
+ if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")
+ if (mapping?.teacherId === tid) return cls.id
+ if (!mapping) {
+ await db
+ .insert(classSubjectTeachers)
+ .values({ classId: cls.id, subjectId: sid, teacherId: null })
+ .onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
+ }
+
+ const [existingSubject] = await db
+ .select({ id: classSubjectTeachers.classId })
+ .from(classSubjectTeachers)
+ .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
+ .limit(1)
+
+ if (existingSubject) return cls.id
+
+ await db
+ .update(classSubjectTeachers)
+ .set({ teacherId: tid })
+ .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
+
+ const [assigned] = await db
+ .select({ id: classSubjectTeachers.classId })
+ .from(classSubjectTeachers)
+ .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
+ .limit(1)
+
+ if (!assigned) throw new Error("Subject already assigned")
+ } else {
+ const subjectRows = await db
+ .select({ id: classSubjectTeachers.subjectId, name: subjects.name })
+ .from(classSubjectTeachers)
+ .innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
+ .where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
+
+ const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
+ if (!preferred) throw new Error("Class already has assigned teachers")
+ const sid = subjectRows.find((r) => r.name === preferred)!.id
+
+ await db
+ .update(classSubjectTeachers)
+ .set({ teacherId: tid })
+ .where(
+ and(
+ eq(classSubjectTeachers.classId, cls.id),
+ eq(classSubjectTeachers.subjectId, sid),
+ isNull(classSubjectTeachers.teacherId)
+ )
+ )
+
+ const [assigned] = await db
+ .select({ id: classSubjectTeachers.classId })
+ .from(classSubjectTeachers)
+ .where(
+ and(
+ eq(classSubjectTeachers.classId, cls.id),
+ eq(classSubjectTeachers.subjectId, sid),
+ eq(classSubjectTeachers.teacherId, tid)
+ )
+ )
+ .limit(1)
+
+ if (!assigned) throw new Error("Class already has assigned teachers")
+ }
+
+ return cls.id
+}
+
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise {
const teacherId = await getTeacherIdForMutations()
@@ -1468,7 +1679,9 @@ export async function updateAdminClass(
const [teacher] = await db
.select({ id: users.id })
.from(users)
- .where(and(eq(users.id, nextTeacherId), eq(users.role, "teacher")))
+ .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
+ .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
+ .where(and(eq(users.id, nextTeacherId), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
@@ -1498,7 +1711,9 @@ export async function setClassSubjectTeachers(params: {
const rows = await db
.select({ id: users.id })
.from(users)
- .where(and(eq(users.role, "teacher"), inArray(users.id, teacherIds)))
+ .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
+ .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
+ .where(and(eq(roles.name, "teacher"), inArray(users.id, teacherIds)))
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
}
@@ -1508,15 +1723,24 @@ export async function setClassSubjectTeachers(params: {
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
}
+ // Map subject names to ids
+ const subjectRows = await db
+ .select({ id: subjects.id, name: subjects.name })
+ .from(subjects)
+ .where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
+ const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
+
+ const values = DEFAULT_CLASS_SUBJECTS
+ .filter((name) => idByName.has(name))
+ .map((name) => ({
+ classId,
+ subjectId: idByName.get(name)!,
+ teacherId: teacherBySubject.get(name) ?? null,
+ }))
+
await db
.insert(classSubjectTeachers)
- .values(
- DEFAULT_CLASS_SUBJECTS.map((subject) => ({
- classId,
- subject,
- teacherId: teacherBySubject.get(subject) ?? null,
- }))
- )
+ .values(values)
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
}
@@ -1564,13 +1788,19 @@ export async function enrollStudentByEmail(classId: string, email: string): Prom
if (!owned) throw new Error("Class not found")
const [student] = await db
- .select({ id: users.id, role: users.role })
+ .select({ id: users.id })
.from(users)
.where(eq(users.email, normalized))
.limit(1)
if (!student) throw new Error("Student not found")
- if (student.role !== "student") throw new Error("User is not a student")
+ const [studentRole] = await db
+ .select({ id: usersToRoles.userId })
+ .from(usersToRoles)
+ .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
+ .where(and(eq(usersToRoles.userId, student.id), eq(roles.name, "student")))
+ .limit(1)
+ if (!studentRole) throw new Error("User is not a student")
await db
.insert(classEnrollments)
@@ -1823,8 +2053,26 @@ export const getStudentsSubjectScores = cache(
)
export const getClassStudentSubjectScoresV2 = cache(
- async (classId: string): Promise