From eb08c0ab68f94271347686edb5ee58cd3091f488 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:32:26 +0800 Subject: [PATCH] sync-docs-and-fixes --- docs/db/schema-changelog.md | 4 +- .../002_teacher_dashboard_implementation.md | 17 + .../004_question_bank_implementation.md | 18 + docs/design/005_exam_module_implementation.md | 6 + .../006_homework_module_implementation.md | 15 + .../008_teacher_pages_implementation.md | 313 ++++++++++++++ docs/design/009_feature_gap_analysis.md | 150 +++++++ docs/scripts/seed-exams.ts | 4 + docs/work_log.md | 12 + drizzle/0009_smart_mephistopheles.sql | 88 ++++ drizzle/0010_subject_id_switch.sql | 110 +++++ drizzle/meta/0008_snapshot.json | 4 +- drizzle/meta/0009_snapshot.json | 78 +++- drizzle/meta/_journal.json | 16 +- scripts/check_cst_schema.ts | 38 ++ scripts/migrate_cst_subjectid.ts | 58 +++ scripts/seed.ts | 27 +- src/app/(auth)/register/page.tsx | 18 +- src/app/(dashboard)/dashboard/page.tsx | 13 +- src/app/(dashboard)/profile/page.tsx | 70 +++- .../student/learning/textbooks/[id]/page.tsx | 1 - .../student/learning/textbooks/page.tsx | 12 +- .../teacher/classes/my/[id]/page.tsx | 14 +- .../(dashboard)/teacher/classes/my/page.tsx | 10 +- .../teacher/classes/students/page.tsx | 1 - .../(dashboard)/teacher/exams/create/page.tsx | 32 +- .../homework/assignments/[id]/page.tsx | 1 - .../teacher/homework/assignments/page.tsx | 4 +- .../(dashboard)/teacher/questions/page.tsx | 9 +- src/app/api/onboarding/complete/route.ts | 51 ++- src/app/api/onboarding/status/route.ts | 34 +- src/auth.ts | 41 +- src/modules/classes/actions.ts | 15 +- .../class-detail/class-quick-actions.tsx | 2 +- .../class-detail/class-schedule-widget.tsx | 2 +- .../class-detail/class-students-widget.tsx | 1 - .../class-detail/class-trends-widget.tsx | 6 +- .../classes/components/my-classes-grid.tsx | 76 ++-- .../classes/components/schedule-view.tsx | 6 +- .../classes/components/students-filters.tsx | 7 +- .../classes/components/students-table.tsx | 3 +- src/modules/classes/data-access.ts | 381 +++++++++++++++--- .../student-dashboard/student-stats-grid.tsx | 2 - .../teacher-classes-card.tsx | 2 - .../teacher-dashboard-view.tsx | 1 - src/modules/dashboard/data-access.ts | 67 ++- .../assembly/exam-paper-preview.tsx | 16 +- .../assembly/question-bank-list.tsx | 19 +- .../assembly/selected-question-list.tsx | 29 +- .../components/assembly/structure-editor.tsx | 29 +- src/modules/exams/components/exam-actions.tsx | 2 +- .../exams/components/exam-assembly.tsx | 20 +- src/modules/exams/data-access.ts | 4 +- src/modules/homework/actions.ts | 108 +++-- .../components/homework-take-view.tsx | 1 - .../student-homework-review-view.tsx | 3 +- src/modules/homework/data-access.ts | 34 +- src/modules/layout/config/navigation.ts | 6 - src/modules/questions/actions.ts | 81 +++- .../components/create-question-dialog.tsx | 116 +++++- .../questions/components/question-filters.tsx | 35 +- src/modules/questions/types.ts | 11 + src/modules/school/data-access.ts | 7 +- .../components/profile-settings-form.tsx | 1 - .../components/chapter-sidebar-list.tsx | 20 - .../textbooks/components/textbook-card.tsx | 38 +- .../textbooks/components/textbook-filters.tsx | 2 +- .../textbooks/components/textbook-reader.tsx | 159 +++++++- src/modules/textbooks/data-access.ts | 1 - src/modules/users/data-access.ts | 30 +- src/shared/components/onboarding-gate.tsx | 3 +- src/shared/components/ui/rich-text-editor.tsx | 9 +- src/shared/db/schema.ts | 16 +- 73 files changed, 2218 insertions(+), 422 deletions(-) create mode 100644 docs/design/008_teacher_pages_implementation.md create mode 100644 docs/design/009_feature_gap_analysis.md create mode 100644 drizzle/0009_smart_mephistopheles.sql create mode 100644 drizzle/0010_subject_id_switch.sql create mode 100644 scripts/check_cst_schema.ts create mode 100644 scripts/migrate_cst_subjectid.ts 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>> => { - // 1. Get student IDs in the class + async (params: { classId: string; teacherId?: string }): Promise>> => { + const teacherId = params.teacherId ?? (await getSessionTeacherId()) + if (!teacherId) return new Map() + const classId = params.classId.trim() + if (!classId) return new Map() + + const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId) + if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return new Map() + + const [classRow] = await db + .select({ id: classes.id, teacherId: classes.teacherId }) + .from(classes) + .where(eq(classes.id, classId)) + .limit(1) + if (!classRow) return new Map() + + const isHomeroomTeacher = classRow.teacherId === teacherId + const subjectIds = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId) + if (!isHomeroomTeacher && subjectIds.length === 0) return new Map() + const enrollments = await db .select({ studentId: classEnrollments.studentId }) .from(classEnrollments) @@ -1833,7 +2081,24 @@ export const getClassStudentSubjectScoresV2 = cache( eq(classEnrollments.status, "active") )) - const studentIds = enrollments.map(e => e.studentId) - return getStudentsSubjectScores(studentIds) + const studentIds = enrollments.map((e) => e.studentId) + const studentScores = await getStudentsSubjectScores(studentIds) + if (subjectIds.length === 0) return studentScores + + // Map subjectIds to names for filtering + const subjectRows = await db + .select({ id: subjects.id, name: subjects.name }) + .from(subjects) + .where(inArray(subjects.id, subjectIds)) + const allowed = new Set(subjectRows.map((s) => s.name)) + const filtered = new Map>() + for (const [studentId, scores] of studentScores.entries()) { + const nextScores: Record = {} + for (const [subject, score] of Object.entries(scores)) { + if (allowed.has(subject)) nextScores[subject] = score + } + filtered.set(studentId, nextScores) + } + return filtered } ) diff --git a/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx b/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx index 1e81a47..298e84c 100644 --- a/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx @@ -15,10 +15,8 @@ type Stat = { } export function StudentStatsGrid({ - enrolledClassCount, dueSoonCount, overdueCount, - gradedCount, ranking, }: { enrolledClassCount: number diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx index 5425e77..234ef27 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx @@ -7,8 +7,6 @@ import { EmptyState } from "@/shared/components/ui/empty-state" import type { TeacherClass } from "@/modules/classes/types" export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) { - const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0) - return ( diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx index 6591d46..d2341ef 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx @@ -14,7 +14,6 @@ const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => { } export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) { - const totalStudents = data.classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0) const todayWeekday = toWeekday(new Date()) const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const)) diff --git a/src/modules/dashboard/data-access.ts b/src/modules/dashboard/data-access.ts index 3ab2b43..9d5016a 100644 --- a/src/modules/dashboard/data-access.ts +++ b/src/modules/dashboard/data-access.ts @@ -1,7 +1,7 @@ import "server-only" import { cache } from "react" -import { count, desc, eq, gt } from "drizzle-orm" +import { count, desc, eq, gt, inArray } from "drizzle-orm" import { db } from "@/shared/db" import { @@ -11,9 +11,11 @@ import { homeworkAssignments, homeworkSubmissions, questions, + roles, sessions, textbooks, users, + usersToRoles, } from "@/shared/db/schema" import type { AdminDashboardData } from "./types" @@ -23,7 +25,7 @@ export const getAdminDashboardData = cache(async (): Promise const [ activeSessionsRow, userCountRow, - userRoleRows, + userRoleCountRows, classCountRow, textbookCountRow, chapterCountRow, @@ -37,7 +39,11 @@ export const getAdminDashboardData = cache(async (): Promise ] = await Promise.all([ db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)), db.select({ value: count() }).from(users), - db.select({ role: users.role, value: count() }).from(users).groupBy(users.role), + db + .select({ role: roles.name, value: count() }) + .from(usersToRoles) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .groupBy(roles.name), db.select({ value: count() }).from(classes), db.select({ value: count() }).from(textbooks), db.select({ value: count() }).from(chapters), @@ -52,7 +58,6 @@ export const getAdminDashboardData = cache(async (): Promise id: users.id, name: users.name, email: users.email, - role: users.role, createdAt: users.createdAt, }) .from(users) @@ -72,17 +77,55 @@ export const getAdminDashboardData = cache(async (): Promise const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0) const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0) - const userRoleCounts = userRoleRows + const userRoleCounts = userRoleCountRows .map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) })) .sort((a, b) => b.count - a.count) - const recentUsers = recentUserRows.map((u) => ({ - id: u.id, - name: u.name, - email: u.email, - role: u.role, - createdAt: u.createdAt.toISOString(), - })) + 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 recentUserIds = recentUserRows.map((u) => u.id) + const recentRoleRows = recentUserIds.length + ? await db + .select({ + userId: usersToRoles.userId, + roleName: roles.name, + }) + .from(usersToRoles) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(inArray(usersToRoles.userId, recentUserIds)) + : [] + + const rolesByUserId = new Map() + for (const row of recentRoleRows) { + const list = rolesByUserId.get(row.userId) ?? [] + list.push(row.roleName) + rolesByUserId.set(row.userId, list) + } + + const resolvePrimaryRole = (roleNames: string[]) => { + const mapped = roleNames.map(normalizeRole).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 recentUsers = recentUserRows.map((u) => { + const roleNames = rolesByUserId.get(u.id) ?? [] + return { + id: u.id, + name: u.name, + email: u.email, + role: resolvePrimaryRole(roleNames), + createdAt: u.createdAt.toISOString(), + } + }) return { activeSessionsCount, diff --git a/src/modules/exams/components/assembly/exam-paper-preview.tsx b/src/modules/exams/components/assembly/exam-paper-preview.tsx index 07aef66..d93a9c8 100644 --- a/src/modules/exams/components/assembly/exam-paper-preview.tsx +++ b/src/modules/exams/components/assembly/exam-paper-preview.tsx @@ -25,6 +25,20 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor // Helper to flatten questions for continuous numbering let questionCounter = 0 + const parseContent = (raw: unknown): QuestionContent => { + if (raw && typeof raw === "object") return raw as QuestionContent + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw) as unknown + if (parsed && typeof parsed === "object") return parsed as QuestionContent + return { text: raw } + } catch { + return { text: raw } + } + } + return {} + } + const renderNode = (node: ExamNode, depth: number = 0) => { if (node.type === 'group') { return ( @@ -45,7 +59,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor if (node.type === 'question' && node.question) { questionCounter++ const q = node.question - const content = q.content as QuestionContent + const content = parseContent(q.content) return (
diff --git a/src/modules/exams/components/assembly/question-bank-list.tsx b/src/modules/exams/components/assembly/question-bank-list.tsx index 1ec59a2..ab2af3b 100644 --- a/src/modules/exams/components/assembly/question-bank-list.tsx +++ b/src/modules/exams/components/assembly/question-bank-list.tsx @@ -28,13 +28,26 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
{questions.map((q) => { const added = isAdded(q.id) - const content = q.content as { text?: string } + const parsedContent = (() => { + if (q.content && typeof q.content === "object") return q.content as { text?: string } + if (typeof q.content === "string") { + try { + const parsed = JSON.parse(q.content) as unknown + if (parsed && typeof parsed === "object") return parsed as { text?: string } + return { text: q.content } + } catch { + return { text: q.content } + } + } + return { text: "" } + })() + const typeLabel = typeof q.type === "string" ? q.type.replace("_", " ") : "unknown" return (
- {q.type.replace("_", " ")} + {typeLabel} Lvl {q.difficulty} @@ -46,7 +59,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor ))}

- {content.text || "No content preview"} + {parsedContent.text || "No content preview"}

diff --git a/src/modules/exams/components/assembly/selected-question-list.tsx b/src/modules/exams/components/assembly/selected-question-list.tsx index d7d8293..18111e9 100644 --- a/src/modules/exams/components/assembly/selected-question-list.tsx +++ b/src/modules/exams/components/assembly/selected-question-list.tsx @@ -118,7 +118,22 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: { onMove: (dir: 'up' | 'down') => void onScoreChange: (score: number) => void }) { - const content = item.question?.content as { text?: string } + const rawContent = item.question?.content + const parsedContent = (() => { + if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> } + if (typeof rawContent === "string") { + try { + const parsed = JSON.parse(rawContent) as unknown + if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> } + return { text: rawContent } + } catch { + return { text: rawContent } + } + } + return { text: "" } + })() + + const options = Array.isArray(parsedContent.options) ? parsedContent.options : [] return (
@@ -127,7 +142,7 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: { {index + 1}

- {content?.text || "Question content"} + {parsedContent.text || "Question content"}

+ {(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && ( +
+ {options.map((opt, idx) => ( +
+ {opt.id ?? String.fromCharCode(65 + idx)}. + {opt.text ?? ""} +
+ ))} +
+ )}
diff --git a/src/modules/exams/components/assembly/structure-editor.tsx b/src/modules/exams/components/assembly/structure-editor.tsx index c057f5c..90ce293 100644 --- a/src/modules/exams/components/assembly/structure-editor.tsx +++ b/src/modules/exams/components/assembly/structure-editor.tsx @@ -82,7 +82,22 @@ function SortableItem({ opacity: isDragging ? 0.5 : 1, } - const content = item.question?.content as { text?: string } + const rawContent = item.question?.content + const parsedContent = (() => { + if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> } + if (typeof rawContent === "string") { + try { + const parsed = JSON.parse(rawContent) as unknown + if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> } + return { text: rawContent } + } catch { + return { text: rawContent } + } + } + return { text: "" } + })() + + const options = Array.isArray(parsedContent.options) ? parsedContent.options : [] return (
@@ -92,7 +107,7 @@ function SortableItem({

- {content?.text || "Question content"} + {parsedContent.text || "Question content"}

+ {(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && ( +
+ {options.map((opt, idx) => ( +
+ {opt.id ?? String.fromCharCode(65 + idx)}. + {opt.text ?? ""} +
+ ))} +
+ )}
diff --git a/src/modules/exams/components/exam-actions.tsx b/src/modules/exams/components/exam-actions.tsx index ecb6c15..d8f83b0 100644 --- a/src/modules/exams/components/exam-actions.tsx +++ b/src/modules/exams/components/exam-actions.tsx @@ -79,7 +79,7 @@ export function ExamActions({ exam }: ExamActionsProps) { toast.error("Failed to load exam preview") setShowViewDialog(false) } - } catch (e) { + } catch { toast.error("Failed to load exam preview") setShowViewDialog(false) } finally { diff --git a/src/modules/exams/components/exam-assembly.tsx b/src/modules/exams/components/exam-assembly.tsx index 1cafa6b..9f7b049 100644 --- a/src/modules/exams/components/exam-assembly.tsx +++ b/src/modules/exams/components/exam-assembly.tsx @@ -1,7 +1,6 @@ "use client" -import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react" -import { useFormStatus } from "react-dom" +import { useCallback, useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react" import { useRouter } from "next/navigation" import { toast } from "sonner" import { Search, Eye } from "lucide-react" @@ -34,15 +33,6 @@ type ExamAssemblyProps = { questionOptions: Question[] } -function SubmitButton({ label }: { label: string }) { - const { pending } = useFormStatus() - return ( - - ) -} - export function ExamAssembly(props: ExamAssemblyProps) { const router = useRouter() const [search, setSearch] = useState("") @@ -83,7 +73,7 @@ export function ExamAssembly(props: ExamAssemblyProps) { return [] }) - const fetchQuestions = (reset: boolean = false) => { + const fetchQuestions = useCallback((reset: boolean = false) => { startBankTransition(async () => { const nextPage = reset ? 1 : page + 1 try { @@ -107,11 +97,11 @@ export function ExamAssembly(props: ExamAssemblyProps) { setHasMore(result.data.length === 20) setPage(nextPage) } - } catch (error) { + } catch { toast.error("Failed to load questions") } }) - } + }, [deferredSearch, page, startBankTransition, typeFilter, difficultyFilter]) const isFirstRender = useRef(true) @@ -123,7 +113,7 @@ export function ExamAssembly(props: ExamAssemblyProps) { } } fetchQuestions(true) - }, [deferredSearch, typeFilter, difficultyFilter]) + }, [deferredSearch, typeFilter, difficultyFilter, fetchQuestions]) // Recursively calculate total score const assignedTotal = useMemo(() => { diff --git a/src/modules/exams/data-access.ts b/src/modules/exams/data-access.ts index d002eae..4782452 100644 --- a/src/modules/exams/data-access.ts +++ b/src/modules/exams/data-access.ts @@ -149,8 +149,8 @@ export const omitScheduledAtFromDescription = (description: string | null): stri try { const meta = JSON.parse(description) if (typeof meta === "object" && meta !== null) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { scheduledAt, ...rest } = meta as any + const rest = { ...(meta as Record) } + delete rest.scheduledAt return JSON.stringify(rest) } return description diff --git a/src/modules/homework/actions.ts b/src/modules/homework/actions.ts index 81eb139..5b37bfa 100644 --- a/src/modules/homework/actions.ts +++ b/src/modules/homework/actions.ts @@ -1,64 +1,64 @@ "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 { and, count, eq, inArray } from "drizzle-orm" +import { auth } from "@/auth" import { db } from "@/shared/db" import { classes, classEnrollments, + classSubjectTeachers, exams, homeworkAnswers, homeworkAssignmentQuestions, homeworkAssignmentTargets, homeworkAssignments, homeworkSubmissions, + roles, users, + usersToRoles, } 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" } +type TeacherRole = "admin" | "teacher" +type StudentRole = "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_math", role: roleHint } +const getSessionUserId = async (): Promise => { + const session = await auth() + const userId = String(session?.user?.id ?? "").trim() + return userId.length > 0 ? userId : null } -async function ensureTeacher() { - const user = await getCurrentUser() - if (!user || (user.role !== "teacher" && user.role !== "admin")) throw new Error("Unauthorized") - return user +async function ensureTeacher(): Promise<{ id: string; role: TeacherRole }> { + const userId = await getSessionUserId() + if (!userId) throw new Error("Unauthorized") + const [row] = await db + .select({ id: users.id, role: roles.name }) + .from(users) + .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"]))) + .limit(1) + if (!row) throw new Error("Unauthorized") + return { id: row.id, role: row.role as TeacherRole } } -async function ensureStudent() { - const user = await getCurrentUser() - if (!user || user.role !== "student") throw new Error("Unauthorized") - return user +async function ensureStudent(): Promise<{ id: string; role: StudentRole }> { + const userId = await getSessionUserId() + if (!userId) throw new Error("Unauthorized") + const [row] = 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, userId), eq(roles.name, "student"))) + .limit(1) + if (!row) throw new Error("Unauthorized") + return { id: row.id, role: "student" } } const parseStudentIds = (raw: string): string[] => { @@ -108,12 +108,12 @@ export async function createHomeworkAssignmentAction( const input = parsed.data const publish = input.publish ?? true - const [ownedClass] = await db - .select({ id: classes.id }) + const [classRow] = await db + .select({ id: classes.id, teacherId: classes.teacherId }) .from(classes) - .where(user.role === "admin" ? eq(classes.id, input.classId) : and(eq(classes.id, input.classId), eq(classes.teacherId, user.id))) + .where(eq(classes.id, input.classId)) .limit(1) - if (!ownedClass) return { success: false, message: "Class not found" } + if (!classRow) return { success: false, message: "Class not found" } const exam = await db.query.exams.findFirst({ where: eq(exams.id, input.sourceExamId), @@ -126,23 +126,43 @@ export async function createHomeworkAssignmentAction( if (!exam) return { success: false, message: "Exam not found" } + if (user.role !== "admin" && classRow.teacherId !== user.id) { + const assignedSubjectRows = await db + .select({ subjectId: classSubjectTeachers.subjectId }) + .from(classSubjectTeachers) + .where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, user.id))) + if (assignedSubjectRows.length === 0) { + return { success: false, message: "Not assigned to this class" } + } + const assignedSubjectIds = new Set(assignedSubjectRows.map((r) => r.subjectId)) + if (!exam.subjectId) { + return { success: false, message: "Exam subject not set" } + } + if (!assignedSubjectIds.has(exam.subjectId)) { + return { success: false, message: "Not assigned to this subject" } + } + } + 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 classScope = + user.role === "admin" + ? eq(classes.id, input.classId) + : classRow.teacherId === user.id + ? eq(classes.teacherId, user.id) + : eq(classes.id, input.classId) + const classStudentIds = ( await db .select({ studentId: classEnrollments.studentId }) .from(classEnrollments) .innerJoin(classes, eq(classes.id, classEnrollments.classId)) .where( - and( - eq(classEnrollments.classId, input.classId), - eq(classEnrollments.status, "active"), - user.role === "admin" ? eq(classes.id, input.classId) : eq(classes.teacherId, user.id) - ) + and(eq(classEnrollments.classId, input.classId), eq(classEnrollments.status, "active"), classScope) ) ).map((r) => r.studentId) diff --git a/src/modules/homework/components/homework-take-view.tsx b/src/modules/homework/components/homework-take-view.tsx index 5295e2a..a632b8b 100644 --- a/src/modules/homework/components/homework-take-view.tsx +++ b/src/modules/homework/components/homework-take-view.tsx @@ -12,7 +12,6 @@ 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 { Clock, CheckCircle2, Save, FileText } from "lucide-react" import type { StudentHomeworkTakeData } from "../types" diff --git a/src/modules/homework/components/student-homework-review-view.tsx b/src/modules/homework/components/student-homework-review-view.tsx index 7fe9d0a..0be97ba 100644 --- a/src/modules/homework/components/student-homework-review-view.tsx +++ b/src/modules/homework/components/student-homework-review-view.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/sha import { Checkbox } from "@/shared/components/ui/checkbox" import { Label } from "@/shared/components/ui/label" import { ScrollArea } from "@/shared/components/ui/scroll-area" -import { CheckCircle2, FileText, ChevronLeft } from "lucide-react" +import { FileText, ChevronLeft } from "lucide-react" import Link from "next/link" import type { StudentHomeworkTakeData } from "../types" @@ -57,7 +57,6 @@ type HomeworkReviewViewProps = { export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) { const submissionStatus = initialData.submission?.status ?? "not_started" const isGraded = submissionStatus === "graded" - const isSubmitted = submissionStatus === "submitted" const answersByQuestionId = useMemo(() => { const map = new Map() diff --git a/src/modules/homework/data-access.ts b/src/modules/homework/data-access.ts index 5467752..b0ddc58 100644 --- a/src/modules/homework/data-access.ts +++ b/src/modules/homework/data-access.ts @@ -3,6 +3,7 @@ import "server-only" import { cache } from "react" import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm" +import { auth } from "@/auth" import { db } from "@/shared/db" import { classEnrollments, @@ -11,7 +12,9 @@ import { homeworkAssignmentTargets, homeworkAssignments, homeworkSubmissions, + roles, users, + usersToRoles, } from "@/shared/db/schema" import type { @@ -550,17 +553,20 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string): }) 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 session = await auth() + const userId = String(session?.user?.id ?? "").trim() + if (!userId) return null - 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 [student] = await db + .select({ id: users.id, name: users.name }) + .from(users) + .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(and(eq(users.id, userId), eq(roles.name, "student"))) + .limit(1) + + if (!student) return null + return { id: student.id, name: student.name || "Student" } }) const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => { @@ -592,19 +598,23 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr 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)], + orderBy: [desc(homeworkSubmissions.updatedAt)], }) const attemptsByAssignmentId = new Map() const latestByAssignmentId = new Map() + const latestSubmittedByAssignmentId = new Map() 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) + if (s.status === "submitted" || s.status === "graded") { + if (!latestSubmittedByAssignmentId.has(s.assignmentId)) latestSubmittedByAssignmentId.set(s.assignmentId, s) + } } return assignments.map((a) => { - const latest = latestByAssignmentId.get(a.id) ?? null + const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0 const item: StudentHomeworkAssignmentListItem = { diff --git a/src/modules/layout/config/navigation.ts b/src/modules/layout/config/navigation.ts index 0c8ab69..ccd250a 100644 --- a/src/modules/layout/config/navigation.ts +++ b/src/modules/layout/config/navigation.ts @@ -5,7 +5,6 @@ import { LayoutDashboard, Settings, Users, - FileText, MessageSquare, Shield, CreditCard, @@ -156,11 +155,6 @@ export const NAV_CONFIG: Record = { icon: Calendar, href: "/student/schedule", }, - { - title: "Resources", - icon: FileText, - href: "/student/resources", - }, ], parent: [ { diff --git a/src/modules/questions/actions.ts b/src/modules/questions/actions.ts index fc80872..b1d4068 100644 --- a/src/modules/questions/actions.ts +++ b/src/modules/questions/actions.ts @@ -1,29 +1,51 @@ "use server"; import { db } from "@/shared/db"; -import { questions, questionsToKnowledgePoints } from "@/shared/db/schema"; +import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks, roles, users, usersToRoles } from "@/shared/db/schema"; import { CreateQuestionSchema } from "./schema"; import type { CreateQuestionInput } from "./schema"; import { ActionState } from "@/shared/types/action-state"; import { revalidatePath } from "next/cache"; import { createId } from "@paralleldrive/cuid2"; -import { and, eq } from "drizzle-orm"; +import { and, asc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { getQuestions, type GetQuestionsParams } from "./data-access"; +import type { KnowledgePointOption } from "./types"; +import { auth } from "@/auth"; -async function getCurrentUser() { - return { - id: "user_teacher_math", - role: "teacher", - }; -} +const getSessionUserId = async (): Promise => { + const session = await auth(); + const userId = String(session?.user?.id ?? "").trim(); + return userId.length > 0 ? userId : null; +}; async function ensureTeacher() { - const user = await getCurrentUser(); - if (!user || (user.role !== "teacher" && user.role !== "admin")) { + const userId = await getSessionUserId(); + if (!userId) { + const [fallback] = await db + .select({ id: users.id, role: roles.name }) + .from(users) + .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(inArray(roles.name, ["teacher", "admin"])) + .orderBy(asc(users.createdAt)) + .limit(1); + if (!fallback) { + throw new Error("Unauthorized: Only teachers can perform this action."); + } + return { id: fallback.id, role: fallback.role as "teacher" | "admin" }; + } + const [row] = await db + .select({ id: users.id, role: roles.name }) + .from(users) + .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"]))) + .limit(1); + if (!row) { throw new Error("Unauthorized: Only teachers can perform this action."); } - return user; + return { id: row.id, role: row.role as "teacher" | "admin" }; } type Tx = Parameters[0]>[0] @@ -244,3 +266,40 @@ export async function getQuestionsAction(params: GetQuestionsParams) { await ensureTeacher(); return await getQuestions(params); } + +export async function getKnowledgePointOptionsAction(): Promise { + await ensureTeacher(); + + const rows = await db + .select({ + id: knowledgePoints.id, + name: knowledgePoints.name, + chapterId: chapters.id, + chapterTitle: chapters.title, + textbookId: textbooks.id, + textbookTitle: textbooks.title, + subject: textbooks.subject, + grade: textbooks.grade, + }) + .from(knowledgePoints) + .leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) + .leftJoin(textbooks, eq(textbooks.id, chapters.textbookId)) + .orderBy( + asc(textbooks.title), + asc(chapters.order), + asc(chapters.title), + asc(knowledgePoints.order), + asc(knowledgePoints.name) + ); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + chapterId: row.chapterId ?? null, + chapterTitle: row.chapterTitle ?? null, + textbookId: row.textbookId ?? null, + textbookTitle: row.textbookTitle ?? null, + subject: row.subject ?? null, + grade: row.grade ?? null, + })); +} diff --git a/src/modules/questions/components/create-question-dialog.tsx b/src/modules/questions/components/create-question-dialog.tsx index 5ec3756..660273b 100644 --- a/src/modules/questions/components/create-question-dialog.tsx +++ b/src/modules/questions/components/create-question-dialog.tsx @@ -27,6 +27,7 @@ import { FormMessage, } from "@/shared/components/ui/form" import { Input } from "@/shared/components/ui/input" +import { ScrollArea } from "@/shared/components/ui/scroll-area" import { Select, SelectContent, @@ -36,9 +37,9 @@ import { } from "@/shared/components/ui/select" import { Textarea } from "@/shared/components/ui/textarea" import { BaseQuestionSchema } from "../schema" -import { createNestedQuestion, updateQuestionAction } from "../actions" +import { createNestedQuestion, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions" import { toast } from "sonner" -import { Question } from "../types" +import { KnowledgePointOption, Question } from "../types" const QuestionFormSchema = BaseQuestionSchema.extend({ difficulty: z.number().min(1).max(5), @@ -111,6 +112,10 @@ export function CreateQuestionDialog({ const router = useRouter() const [isPending, setIsPending] = useState(false) const isEdit = !!initialData + const [knowledgePointOptions, setKnowledgePointOptions] = useState([]) + const [knowledgePointQuery, setKnowledgePointQuery] = useState("") + const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState([]) + const [isLoadingKnowledgePoints, setIsLoadingKnowledgePoints] = useState(false) const form = useForm({ resolver: zodResolver(QuestionFormSchema), @@ -151,7 +156,60 @@ export function CreateQuestionDialog({ } }, [initialData, form, open, defaultContent, defaultType]) + useEffect(() => { + if (!open) return + setIsLoadingKnowledgePoints(true) + getKnowledgePointOptionsAction() + .then((rows) => { + setKnowledgePointOptions(rows) + }) + .catch(() => { + toast.error("Failed to load knowledge points") + }) + .finally(() => { + setIsLoadingKnowledgePoints(false) + }) + }, [open]) + + useEffect(() => { + if (!open) return + if (initialData) { + const nextIds = initialData.knowledgePoints.map((kp) => kp.id) + setSelectedKnowledgePointIds((prev) => { + if (prev.length === nextIds.length && prev.every((id, idx) => id === nextIds[idx])) { + return prev + } + return nextIds + }) + return + } + setSelectedKnowledgePointIds((prev) => { + if ( + prev.length === defaultKnowledgePointIds.length && + prev.every((id, idx) => id === defaultKnowledgePointIds[idx]) + ) { + return prev + } + return defaultKnowledgePointIds + }) + }, [open, initialData, defaultKnowledgePointIds]) + const questionType = form.watch("type") + const filteredKnowledgePoints = knowledgePointOptions.filter((kp) => { + const query = knowledgePointQuery.trim().toLowerCase() + if (!query) return true + const fullLabel = [ + kp.textbookTitle, + kp.chapterTitle, + kp.name, + kp.subject, + kp.grade, + ] + .filter(Boolean) + .join(" ") + .toLowerCase() + return fullLabel.includes(query) + }) const buildContent = (data: QuestionFormValues) => { const text = data.content.trim() @@ -194,7 +252,7 @@ export function CreateQuestionDialog({ type: data.type, difficulty: data.difficulty, content: buildContent(data), - knowledgePointIds: isEdit ? [] : defaultKnowledgePointIds, + knowledgePointIds: selectedKnowledgePointIds, } const fd = new FormData() fd.set("json", JSON.stringify(payload)) @@ -306,6 +364,58 @@ export function CreateQuestionDialog({ )} /> +
+
+ Knowledge Points + + {selectedKnowledgePointIds.length > 0 ? `${selectedKnowledgePointIds.length} selected` : "Optional"} + +
+ setKnowledgePointQuery(e.target.value)} + /> +
+ + {isLoadingKnowledgePoints ? ( +
Loading...
+ ) : filteredKnowledgePoints.length === 0 ? ( +
No knowledge points found.
+ ) : ( +
+ {filteredKnowledgePoints.map((kp) => { + const labelParts = [ + kp.textbookTitle, + kp.chapterTitle, + kp.name, + ].filter(Boolean) + const label = labelParts.join(" · ") + return ( + + ) + })} +
+ )} +
+
+
+ {(questionType === "single_choice" || questionType === "multiple_choice") && (
diff --git a/src/modules/questions/components/question-filters.tsx b/src/modules/questions/components/question-filters.tsx index 5b300ca..f506f32 100644 --- a/src/modules/questions/components/question-filters.tsx +++ b/src/modules/questions/components/question-filters.tsx @@ -1,5 +1,6 @@ "use client" +import { useEffect, useState } from "react" import { useQueryState, parseAsString } from "nuqs" import { Search, X } from "lucide-react" @@ -12,11 +13,25 @@ import { SelectValue, } from "@/shared/components/ui/select" import { Button } from "@/shared/components/ui/button" +import { getKnowledgePointOptionsAction } from "../actions" +import type { KnowledgePointOption } from "../types" export function QuestionFilters() { const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) const [type, setType] = useQueryState("type", parseAsString.withDefault("all")) const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all")) + const [knowledgePointId, setKnowledgePointId] = useQueryState("kp", parseAsString.withDefault("all")) + const [knowledgePointOptions, setKnowledgePointOptions] = useState([]) + + useEffect(() => { + getKnowledgePointOptionsAction() + .then((rows) => { + setKnowledgePointOptions(rows) + }) + .catch(() => { + setKnowledgePointOptions([]) + }) + }, []) return (
@@ -56,14 +71,32 @@ export function QuestionFilters() { Hard (5) + - {(search || type !== "all" || difficulty !== "all") && ( + {(search || type !== "all" || difficulty !== "all" || knowledgePointId !== "all") && ( + ))} +
+ + )} +
diff --git a/src/modules/textbooks/data-access.ts b/src/modules/textbooks/data-access.ts index 6cf237a..7e0dd86 100644 --- a/src/modules/textbooks/data-access.ts +++ b/src/modules/textbooks/data-access.ts @@ -9,7 +9,6 @@ import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema" import type { Chapter, CreateChapterInput, - CreateKnowledgePointInput, CreateTextbookInput, KnowledgePoint, Textbook, diff --git a/src/modules/users/data-access.ts b/src/modules/users/data-access.ts index 1693865..78e5d38 100644 --- a/src/modules/users/data-access.ts +++ b/src/modules/users/data-access.ts @@ -4,7 +4,7 @@ import { cache } from "react" import { eq } from "drizzle-orm" import { db } from "@/shared/db" -import { users } from "@/shared/db/schema" +import { roles, users, usersToRoles } from "@/shared/db/schema" export type UserProfile = { id: string @@ -21,6 +21,25 @@ export type UserProfile = { updatedAt: Date } +const rolePriority = ["admin", "teacher", "parent", "student"] as const + +const normalizeRoleName = (value: string) => { + const role = value.trim().toLowerCase() + if (role === "grade_head" || role === "teaching_head") return "teacher" + if (role === "admin" || role === "teacher" || role === "parent" || role === "student") return role + return "" +} + +const resolvePrimaryRole = (roleNames: string[]) => { + const mapped = roleNames.map(normalizeRoleName).filter(Boolean) + if (mapped.length) { + for (const role of rolePriority) { + if (mapped.includes(role)) return role + } + } + return "student" +} + export const getUserProfile = cache(async (userId: string): Promise => { const user = await db.query.users.findFirst({ where: eq(users.id, userId), @@ -28,12 +47,19 @@ export const getUserProfile = cache(async (userId: string): Promise r.name)) + return { id: user.id, name: user.name, email: user.email, image: user.image, - role: user.role, + role, phone: user.phone, address: user.address, gender: user.gender, diff --git a/src/shared/components/onboarding-gate.tsx b/src/shared/components/onboarding-gate.tsx index 43bd4e3..35c3447 100644 --- a/src/shared/components/onboarding-gate.tsx +++ b/src/shared/components/onboarding-gate.tsx @@ -25,7 +25,7 @@ function isRecord(v: unknown): v is Record { export function OnboardingGate() { const router = useRouter() - const { status, data: session } = useSession() + const { status, data: session, update } = useSession() const [required, setRequired] = useState(false) const [currentRole, setCurrentRole] = useState("student") const [open, setOpen] = useState(false) @@ -142,6 +142,7 @@ export function OnboardingGate() { throw new Error(msg || "提交失败") } + await update?.() toast.success("配置完成") setRequired(false) setOpen(false) diff --git a/src/shared/components/ui/rich-text-editor.tsx b/src/shared/components/ui/rich-text-editor.tsx index ced1241..80d36d8 100644 --- a/src/shared/components/ui/rich-text-editor.tsx +++ b/src/shared/components/ui/rich-text-editor.tsx @@ -18,19 +18,12 @@ import { ListOrdered, Quote, Undo, - Redo, - MoreHorizontal + Redo } from "lucide-react" import { cn } from "@/shared/lib/utils" import { Button } from "@/shared/components/ui/button" import { Separator } from "@/shared/components/ui/separator" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/shared/components/ui/dropdown-menu" // Since we don't have Toggle component yet, let's create a local one or use Button // We will use Button for simplicity and to avoid dependency issues if Radix Toggle isn't installed diff --git a/src/shared/db/schema.ts b/src/shared/db/schema.ts index 87603ed..1dedeb6 100644 --- a/src/shared/db/schema.ts +++ b/src/shared/db/schema.ts @@ -25,10 +25,6 @@ export const users = mysqlTable("users", { email: varchar("email", { length: 255 }).notNull().unique(), emailVerified: timestamp("emailVerified", { mode: "date" }), image: varchar("image", { length: 255 }), - - // Custom Role Field for RBAC (Default Role) - role: varchar("role", { length: 50 }).default("student"), - // Credentials Auth (Optional) password: varchar("password", { length: 255 }), @@ -338,23 +334,27 @@ export const classes = mysqlTable("classes", { }).onDelete("set null"), })); -export const classSubjectEnum = mysqlEnum("subject", ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"]); - export const classSubjectTeachers = mysqlTable("class_subject_teachers", { classId: varchar("class_id", { length: 128 }).notNull(), - subject: classSubjectEnum.notNull(), + subjectId: varchar("subject_id", { length: 128 }).notNull(), teacherId: varchar("teacher_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), }, (table) => ({ - pk: primaryKey({ columns: [table.classId, table.subject] }), + pk: primaryKey({ columns: [table.classId, table.subjectId] }), classIdx: index("class_subject_teachers_class_idx").on(table.classId), teacherIdx: index("class_subject_teachers_teacher_idx").on(table.teacherId), + subjectIdIdx: index("class_subject_teachers_subject_id_idx").on(table.subjectId), classFk: foreignKey({ columns: [table.classId], foreignColumns: [classes.id], name: "cst_c_fk", }).onDelete("cascade"), + subjectFk: foreignKey({ + columns: [table.subjectId], + foreignColumns: [subjects.id], + name: "cst_s_fk", + }).onDelete("cascade"), })); export const classEnrollments = mysqlTable("class_enrollments", {