refactor: P0-1/2/4 解耦修复 - 拆分过耦合文件 + dashboard 解耦
This commit is contained in:
@@ -117,7 +117,7 @@
|
|||||||
▼
|
▼
|
||||||
┌──────────┐
|
┌──────────┐
|
||||||
│ classes │ ◀── 耦合最严重模块
|
│ classes │ ◀── 耦合最严重模块
|
||||||
└────┬─────┘ data-access.ts 2104 行
|
└────┬─────┘ data-access.ts 已拆分为 5 文件 (✅ P0-1 已修复)
|
||||||
│ ═══ 混入 homework/scheduling/grades 逻辑
|
│ ═══ 混入 homework/scheduling/grades 逻辑
|
||||||
│ 直查 homeworkAssignments/exams
|
│ 直查 homeworkAssignments/exams
|
||||||
│
|
│
|
||||||
@@ -321,20 +321,22 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
▼
|
▼
|
||||||
[DataAccess] dashboard/data-access.getAdminDashboardData
|
[DataAccess] dashboard/data-access.getAdminDashboardData
|
||||||
│
|
│
|
||||||
├─▶ db.query.sessions.count() ❌ 跨模块直查(auth)
|
├─▶ users/data-access.getUsersDashboardStats() ✅ 通过模块 data-access
|
||||||
├─▶ db.query.users.count() ❌ 跨模块直查(users)
|
│ ├─ userCount / activeSessionsCount / userRoleCounts
|
||||||
├─▶ db.query.usersToRoles ❌ 跨模块直查(users)
|
│ └─ recentUsers (含角色解析)
|
||||||
├─▶ db.query.roles ❌ 跨模块直查(users)
|
├─▶ classes/data-access.getClassesDashboardStats() ✅ 通过模块 data-access
|
||||||
├─▶ db.query.classes.count() ❌ 跨模块直查(classes)
|
│ └─ classCount
|
||||||
├─▶ db.query.textbooks.count() ❌ 跨模块直查(textbooks)
|
├─▶ textbooks/data-access.getTextbooksDashboardStats() ✅ 通过模块 data-access
|
||||||
├─▶ db.query.chapters.count() ❌ 跨模块直查(textbooks)
|
│ └─ textbookCount / chapterCount
|
||||||
├─▶ db.query.questions.count() ❌ 跨模块直查(questions)
|
├─▶ questions/data-access.getQuestionsDashboardStats() ✅ 通过模块 data-access
|
||||||
├─▶ db.query.exams (含 scope 过滤) ❌ 跨模块直查(exams)
|
│ └─ questionCount
|
||||||
├─▶ db.query.homeworkAssignments ❌ 跨模块直查(homework)
|
├─▶ exams/data-access.getExamsDashboardStats(scope?) ✅ 通过模块 data-access
|
||||||
└─▶ db.query.homeworkSubmissions ❌ 跨模块直查(homework)
|
│ └─ examCount (含 scope 过滤)
|
||||||
|
└─▶ homework/stats-service.getHomeworkDashboardStats(scope?) ✅ 通过模块 data-access
|
||||||
|
├─ homeworkAssignmentCount / homeworkAssignmentPublishedCount
|
||||||
|
└─ homeworkSubmissionCount / homeworkSubmissionToGradeCount
|
||||||
|
|
||||||
⚠️ 单函数直查 11 张跨模块表,是本次审查最严重的封装违规
|
✅ P0-4 已修复:dashboard 改为并行调用各模块 dashboard stats 函数,不再直查跨模块表
|
||||||
建议:各模块暴露 getModuleStats(scope) 函数,dashboard 聚合调用
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -402,7 +404,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`questions`(类型)、`classes`(❌ 直查)、`school`(❌ 直查 subjects/grades)、`questions`(❌ 直查 insert)
|
- 依赖:`shared/*`、`@/auth`、`questions`(类型)、`classes`(❌ 直查)、`school`(❌ 直查 subjects/grades)、`questions`(❌ 直查 insert)
|
||||||
- 被依赖:`homework`(通过 sourceExamId 外键,合理)、`dashboard`(❌ 直查)、`proctoring`(❌ 直查)
|
- 被依赖:`homework`(通过 sourceExamId 外键,合理)、`dashboard`(通过 data-access,P0-4 已修复)、`proctoring`(❌ 直查)
|
||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ❌ P0:`persistAiGeneratedExamDraft` 直接 insert 到 `questions` 表
|
- ❌ P0:`persistAiGeneratedExamDraft` 直接 insert 到 `questions` 表
|
||||||
@@ -429,23 +431,25 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`createHomeworkAssignmentAction` / `startHomeworkSubmissionAction` / `saveHomeworkAnswerAction` / `submitHomeworkAction` / `gradeHomeworkSubmissionAction`
|
- Actions:`createHomeworkAssignmentAction` / `startHomeworkSubmissionAction` / `saveHomeworkAnswerAction` / `submitHomeworkAction` / `gradeHomeworkSubmissionAction`
|
||||||
- Data-access:`getHomeworkAssignments` / `getHomeworkAssignmentById` / `getHomeworkSubmissions` / `getStudentHomeworkAssignments` / `getStudentHomeworkTakeData` / `getStudentDashboardGrades` / `getHomeworkAssignmentAnalytics` / `getHomeworkAssignmentReviewList` / `getHomeworkSubmissionDetails` / `getTeacherGradeTrends` / `getDemoStudentUser`
|
- Data-access:`getHomeworkAssignments` / `getHomeworkAssignmentById` / `getHomeworkSubmissions` / `getStudentHomeworkAssignments` / `getStudentHomeworkTakeData` / `getHomeworkAssignmentReviewList` / `getHomeworkSubmissionDetails` / `getDemoStudentUser` / `isRecord` / `toQuestionContent` / `getAssignmentMaxScoreById`(后三者供 stats-service 使用)
|
||||||
|
- Stats-service:`getTeacherGradeTrends` / `getHomeworkAssignmentAnalytics` / `getStudentDashboardGrades`(从 data-access.ts re-export 以保持向后兼容)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`exams`(❌ 直查 5 处)、`classes`(❌ 直查)、`school`(❌ 直查 subjects)、`users`(❌ 直查)
|
- 依赖:`shared/*`、`@/auth`、`exams`(❌ 直查 5 处)、`classes`(❌ 直查)、`school`(❌ 直查 subjects)、`users`(❌ 直查)
|
||||||
- 被依赖:`dashboard`(通过 data-access,合理)、`parent`(通过 data-access,合理)、`classes`(❌ classes 反向直查 homework 表)
|
- 被依赖:`dashboard`(通过 data-access,合理)、`parent`(通过 data-access,合理)、`classes`(❌ classes 反向直查 homework 表)
|
||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ❌ P0:`data-access.ts` 1038 行(超 1000 硬上限),必须拆分
|
- ✅ P0 已解决:`data-access.ts` 已拆分至 596 行(原 1038 行超 1000 硬上限),统计函数迁移至 `stats-service.ts`
|
||||||
- ❌ P0:`getStudentDashboardGrades` 混入 150+ 行排名计算业务逻辑
|
- ✅ P0 已解决:`getStudentDashboardGrades` 排名计算逻辑迁移至 `stats-service.ts`
|
||||||
- ❌ P0:`getHomeworkAssignmentAnalytics` 混入 145+ 行错误率统计业务逻辑
|
- ✅ P0 已解决:`getHomeworkAssignmentAnalytics` 错误率统计逻辑迁移至 `stats-service.ts`
|
||||||
- ❌ P1:5 处直查 `exams` 表
|
- ❌ P1:5 处直查 `exams` 表
|
||||||
- ❌ P1:`actions.ts` 多处直接 DB 操作(`createHomeworkAssignmentAction` 157 行)
|
- ❌ P1:`actions.ts` 多处直接 DB 操作(`createHomeworkAssignmentAction` 157 行)
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `data-access.ts` | 1038 | 作业 CRUD + 学生视角 + 分析 + 批改(超硬上限) |
|
| `data-access.ts` | 596 | 作业 CRUD + 学生视角 + 批改(含 re-export stats 函数) |
|
||||||
|
| `stats-service.ts` | 346 | 统计分析(教师趋势/作业分析/学生仪表盘成绩) |
|
||||||
| `actions.ts` | 387 | 5 个 Server Action |
|
| `actions.ts` | 387 | 5 个 Server Action |
|
||||||
| `types.ts` | 186 | 类型定义 |
|
| `types.ts` | 186 | 类型定义 |
|
||||||
| `schema.ts` | 29 | Zod 校验 |
|
| `schema.ts` | 29 | Zod 校验 |
|
||||||
@@ -489,7 +493,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`
|
- 依赖:`shared/*`、`@/auth`
|
||||||
- 被依赖:`questions`(❌ 直查)、`exams`(通过类型)、`dashboard`(❌ 直查)
|
- 被依赖:`questions`(❌ 直查)、`exams`(通过类型)、`dashboard`(通过 data-access,P0-4 已修复)
|
||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ✅ 无跨模块 DB 访问
|
- ✅ 无跨模块 DB 访问
|
||||||
@@ -549,10 +553,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`school`(❌ actions 直查 grades 表)、`homework`(❌ data-access 直查 5 张 homework 表)、`exams`(❌ data-access 直查)
|
- 依赖:`shared/*`、`@/auth`、`school`(❌ actions 直查 grades 表)、`homework`(❌ data-access 直查 5 张 homework 表)、`exams`(❌ data-access 直查)
|
||||||
- 被依赖:`exams`/`homework`/`grades`/`attendance`/`scheduling`/`dashboard`/`parent`/`course-plans`/`users`(8+ 处直查 classes 表)
|
- 被依赖:`exams`/`homework`/`grades`/`attendance`/`scheduling`/`dashboard`(通过 data-access,P0-4 已修复)/`parent`/`course-plans`/`users`(8+ 处直查 classes 表)
|
||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ❌ P0:`data-access.ts` 2104 行(超 1000 硬上限 2.1 倍),必须拆分
|
- ✅ P0-1 已修复:`data-access.ts` 已拆分为 5 个文件(data-access/data-access-stats/data-access-schedule/data-access-students/data-access-admin),所有文件均 ≤800 行
|
||||||
- ❌ P0:混入 homework 逻辑(`getClassHomeworkInsights` + `getGradeHomeworkInsights` = 532 行)
|
- ❌ P0:混入 homework 逻辑(`getClassHomeworkInsights` + `getGradeHomeworkInsights` = 532 行)
|
||||||
- ❌ P0:混入 scheduling 逻辑(课表 CRUD,与 scheduling 模块写同一张表)
|
- ❌ P0:混入 scheduling 逻辑(课表 CRUD,与 scheduling 模块写同一张表)
|
||||||
- ❌ P0:混入 grades 逻辑(`getStudentsSubjectScores`)
|
- ❌ P0:混入 grades 逻辑(`getStudentsSubjectScores`)
|
||||||
@@ -563,7 +567,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `data-access.ts` | 2104 | 班级 CRUD + 作业洞察 + 课表 + 成绩(严重超限) |
|
| `data-access.ts` | 656 | 核心班级 CRUD + 邀请码 + 教师班级管理(含 re-export 向后兼容) |
|
||||||
|
| `data-access-stats.ts` | 604 | 作业统计查询(班级/年级作业洞察) |
|
||||||
|
| `data-access-schedule.ts` | 230 | 课表查询(学生/班级课表 CRUD) |
|
||||||
|
| `data-access-students.ts` | 280 | 学生相关查询(科目成绩、学生名单、学生班级) |
|
||||||
|
| `data-access-admin.ts` | 441 | 管理员班级管理(管理员班级 CRUD、年级管理班级查询) |
|
||||||
| `actions.ts` | 765 | 9 个 Server Action(三组重复) |
|
| `actions.ts` | 765 | 9 个 Server Action(三组重复) |
|
||||||
| `types.ts` | 201 | 类型定义(含跨领域类型污染) |
|
| `types.ts` | 201 | 类型定义(含跨领域类型污染) |
|
||||||
|
|
||||||
@@ -665,7 +673,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`classes`(❌ `batchImportUsers` 直查 classes + 直写 classEnrollments)
|
- 依赖:`shared/*`、`@/auth`、`classes`(❌ `batchImportUsers` 直查 classes + 直写 classEnrollments)
|
||||||
- 被依赖:`dashboard`(❌ 直查)、`grades`(❌ 直查)、`homework`(❌ 直查)
|
- 被依赖:`dashboard`(通过 data-access,P0-4 已修复)、`grades`(❌ 直查)、`homework`(❌ 直查)
|
||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ❌ P1:`import-export.ts` 四重职责混合(导入解析 + 导出 + 用户创建 + 班级注册)
|
- ❌ P1:`import-export.ts` 四重职责混合(导入解析 + 导出 + 用户创建 + 班级注册)
|
||||||
@@ -690,18 +698,18 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- Data-access:`getAdminDashboardData` / `getTeacherDashboardData` / `getStudentDashboardData`
|
- Data-access:`getAdminDashboardData` / `getTeacherDashboardData` / `getStudentDashboardData`
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`classes`(合理)、`homework`(合理)、`grades`(合理)、❌ 直查 11 张跨模块表
|
- 依赖:`shared/*`、`@/auth`、`classes`(通过 data-access,合理)、`homework`(通过 data-access,合理)、`grades`(合理)、`users`/`textbooks`/`questions`/`exams`(通过各模块 dashboard stats 函数,P0-4 已修复)
|
||||||
- 被依赖:无
|
- 被依赖:无
|
||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ❌ P0:`getAdminDashboardData` 直查 11 张跨模块表(sessions/users/usersToRoles/roles/classes/textbooks/chapters/questions/exams/homeworkAssignments/homeworkSubmissions)
|
- ✅ P0-4 已修复:`getAdminDashboardData` 改为并行调用各模块 dashboard stats 函数(`getUsersDashboardStats`/`getClassesDashboardStats`/`getTextbooksDashboardStats`/`getQuestionsDashboardStats`/`getExamsDashboardStats`/`getHomeworkDashboardStats`),不再直查跨模块表
|
||||||
- ⚠️ P2:教师仪表盘直查 `users` 表获取教师姓名
|
- ⚠️ P2:教师仪表盘直查 `users` 表获取教师姓名
|
||||||
- ✅ 学生/教师仪表盘正确通过各模块 data-access 获取数据
|
- ✅ 学生/教师仪表盘正确通过各模块 data-access 获取数据
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `data-access.ts` | - | 仪表盘数据聚合(含违规直查) |
|
| `data-access.ts` | - | 仪表盘数据聚合(P0-4 已修复,通过各模块 data-access 获取数据) |
|
||||||
| `types.ts` | - | 类型定义 |
|
| `types.ts` | - | 类型定义 |
|
||||||
| `components/*` | 14 文件 | 三种角色仪表盘组件 |
|
| `components/*` | 14 文件 | 三种角色仪表盘组件 |
|
||||||
|
|
||||||
@@ -1093,8 +1101,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
| 文件 | 行数 | 问题 | 拆分建议 |
|
| 文件 | 行数 | 问题 | 拆分建议 |
|
||||||
|------|------|------|---------|
|
|------|------|------|---------|
|
||||||
| `classes/data-access.ts` | 2104 | 混入 homework/scheduling/grades 逻辑 | 拆为 data-access.ts + data-access-enrollments.ts + data-access-insights.ts;迁移 homework/scheduling/grades 逻辑回所属模块 |
|
| `classes/data-access.ts` | ~~2104~~ → 656 | ~~混入 homework/scheduling/grades 逻辑~~ ✅ 已拆分 | 已拆为 5 个文件:data-access.ts(656行) + data-access-stats.ts(604行) + data-access-schedule.ts(230行) + data-access-students.ts(280行) + data-access-admin.ts(441行),通过 re-export 保持向后兼容 |
|
||||||
| `homework/data-access.ts` | 1038 | 混入排名计算业务逻辑 | 拆为 data-access.ts + data-access-student.ts + data-access-analytics.ts + data-access-grading.ts |
|
| `homework/data-access.ts` | ~~1038~~ → 596 | ~~混入排名计算业务逻辑~~ ✅ 已拆分 | 已拆为 data-access.ts(596行) + stats-service.ts(346行),统计函数迁移至 stats-service.ts |
|
||||||
| `shared/db/schema.ts` | 1111 | 54 张表混合 | 按业务域拆分为 schema/auth.ts + schema/academic.ts + schema/exam.ts + ...,通过 index.ts 聚合 |
|
| `shared/db/schema.ts` | 1111 | 54 张表混合 | 按业务域拆分为 schema/auth.ts + schema/academic.ts + schema/exam.ts + ...,通过 index.ts 聚合 |
|
||||||
|
|
||||||
### P0-2:shared/lib ↔ auth 循环依赖
|
### P0-2:shared/lib ↔ auth 循环依赖
|
||||||
@@ -1110,14 +1118,20 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
|||||||
- logger 函数改为接收 `session` 参数(由调用方传入)
|
- logger 函数改为接收 `session` 参数(由调用方传入)
|
||||||
- 或通过依赖注入打破循环
|
- 或通过依赖注入打破循环
|
||||||
|
|
||||||
### P0-3:dashboard 跨模块直接查询 11 张表
|
### P0-3:dashboard 跨模块直接查询 11 张表 ✅ 已修复
|
||||||
|
|
||||||
`dashboard/data-access.ts` 的 `getAdminDashboardData` 直查 sessions/users/usersToRoles/roles/classes/textbooks/chapters/questions/exams/homeworkAssignments/homeworkSubmissions。
|
`dashboard/data-access.ts` 的 `getAdminDashboardData` 原直查 sessions/users/usersToRoles/roles/classes/textbooks/chapters/questions/exams/homeworkAssignments/homeworkSubmissions。
|
||||||
|
|
||||||
**解耦建议**:
|
**修复方案**(已实施):
|
||||||
- 各模块暴露 `getModuleStats(scope?)` 函数
|
- 各模块新增 dashboard stats 函数:
|
||||||
- dashboard 聚合调用:`Promise.all([getUsersStats(scope), getClassStats(scope), ...])`
|
- `users/data-access.ts` → `getUsersDashboardStats()`(userCount/activeSessionsCount/userRoleCounts/recentUsers)
|
||||||
- 至少消除 dashboard 中重复实现的 exam/homework scope 过滤
|
- `classes/data-access.ts` → `getClassesDashboardStats()`(classCount)
|
||||||
|
- `textbooks/data-access.ts` → `getTextbooksDashboardStats()`(textbookCount/chapterCount)
|
||||||
|
- `questions/data-access.ts` → `getQuestionsDashboardStats()`(questionCount)
|
||||||
|
- `exams/data-access.ts` → `getExamsDashboardStats(scope?)`(examCount,支持 scope 过滤)
|
||||||
|
- `homework/stats-service.ts` → `getHomeworkDashboardStats(scope?)`(4 个计数,支持 scope 过滤)
|
||||||
|
- dashboard 改为并行调用:`Promise.all([getUsersDashboardStats(), getClassesDashboardStats(), ...])`
|
||||||
|
- 返回值结构保持不变,调用方无需修改
|
||||||
|
|
||||||
### P0-4:messaging 绕过 notifications 直接写通知
|
### P0-4:messaging 绕过 notifications 直接写通知
|
||||||
|
|
||||||
@@ -1245,8 +1259,8 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
|||||||
## 3.4 解耦优先级路线图
|
## 3.4 解耦优先级路线图
|
||||||
|
|
||||||
### 立即执行(P0)
|
### 立即执行(P0)
|
||||||
1. 拆分 `classes/data-access.ts`(2104 行 → 按职责拆 3-4 个文件)
|
1. ~~拆分 `classes/data-access.ts`(2104 行 → 按职责拆 3-4 个文件)~~ ✅ 已完成(拆为 5 个文件:data-access.ts 656行 + data-access-stats.ts 604行 + data-access-schedule.ts 230行 + data-access-students.ts 280行 + data-access-admin.ts 441行)
|
||||||
2. 拆分 `homework/data-access.ts`(1038 行 → 分离排名逻辑)
|
2. ~~拆分 `homework/data-access.ts`(1038 行 → 分离排名逻辑)~~ ✅ 已完成(拆为 data-access.ts 596行 + stats-service.ts 346行)
|
||||||
3. 修复 `shared/lib` ↔ `auth` 循环依赖
|
3. 修复 `shared/lib` ↔ `auth` 循环依赖
|
||||||
4. dashboard 改为通过各模块 data-access 获取数据
|
4. dashboard 改为通过各模块 data-access 获取数据
|
||||||
5. messaging 写通知改为通过 notifications dispatcher
|
5. messaging 写通知改为通过 notifications dispatcher
|
||||||
@@ -1301,7 +1315,7 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
|||||||
| **classes** | ✅ | ✅ | ❌直查 | ❌直查5表 | - | - | - | ❌直查 | - | - | ❌混入 | - | - | - |
|
| **classes** | ✅ | ✅ | ❌直查 | ❌直查5表 | - | - | - | ❌直查 | - | - | ❌混入 | - | - | - |
|
||||||
| **school** | ✅ | ✅ | - | - | - | - | - | - | - | ⚠️可接受 | - | - | - | - |
|
| **school** | ✅ | ✅ | - | - | - | - | - | - | - | ⚠️可接受 | - | - | - | - |
|
||||||
| **grades** | ✅ | ✅ | ✅外键 | ✅外键 | - | - | ❌直查 | ❌直查 | - | ❌直查 | - | - | - | - |
|
| **grades** | ✅ | ✅ | ✅外键 | ✅外键 | - | - | ❌直查 | ❌直查 | - | ❌直查 | - | - | - | - |
|
||||||
| **dashboard** | ✅ | ✅ | ❌直查 | ✅/❌直查 | ❌直查 | ❌直查 | ✅/❌直查 | - | - | ❌直查 | - | - | - | - |
|
| **dashboard** | ✅ | ✅ | ✅data-access | ✅data-access | ✅data-access | ✅data-access | ✅data-access | - | - | ✅data-access | - | - | - | - |
|
||||||
| **users** | ✅ | ✅ | - | - | - | - | ❌写enrollments | - | - | - | - | - | - | - |
|
| **users** | ✅ | ✅ | - | - | - | - | ❌写enrollments | - | - | - | - | - | - | - |
|
||||||
| **messaging** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | - | ❌绕过 | - |
|
| **messaging** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | - | ❌绕过 | - |
|
||||||
| **notifications** | ✅ | ✅ | - | - | - | - | ❌直查 | - | - | - | - | ⟳反向依赖 | - | - |
|
| **notifications** | ✅ | ✅ | - | - | - | - | ❌直查 | - | - | - | - | ⟳反向依赖 | - | - |
|
||||||
|
|||||||
@@ -2048,6 +2048,14 @@
|
|||||||
"createAiExamAction"
|
"createAiExamAction"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "getExamsDashboardStats",
|
||||||
|
"signature": "(scope?: DataScope) => Promise<ExamsDashboardStats>",
|
||||||
|
"purpose": "获取考试仪表盘统计数据(考试总数,支持数据范围过滤)",
|
||||||
|
"usedBy": [
|
||||||
|
"dashboard/data-access.getAdminDashboardData"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "omitScheduledAtFromDescription",
|
"name": "omitScheduledAtFromDescription",
|
||||||
"signature": "(description: string | null) => string",
|
"signature": "(description: string | null) => string",
|
||||||
@@ -2535,13 +2543,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dataAccess": [
|
"dataAccess": [
|
||||||
{
|
|
||||||
"name": "getTeacherGradeTrends",
|
|
||||||
"signature": "(teacherId: string, limit?: number) => Promise<TeacherGradeTrendItem[]>",
|
|
||||||
"usedBy": [
|
|
||||||
"dashboard (教师仪表盘)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "getHomeworkAssignments",
|
"name": "getHomeworkAssignments",
|
||||||
"signature": "(params?: { creatorId?, ids?, classId?, scope? }) => Promise<HomeworkAssignmentListItem[]>",
|
"signature": "(params?: { creatorId?, ids?, classId?, scope? }) => Promise<HomeworkAssignmentListItem[]>",
|
||||||
@@ -2571,20 +2572,6 @@
|
|||||||
"student/dashboard"
|
"student/dashboard"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "getStudentDashboardGrades",
|
|
||||||
"signature": "(studentId: string) => Promise<StudentDashboardGradeProps>",
|
|
||||||
"usedBy": [
|
|
||||||
"dashboard/data-access.ts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "getHomeworkAssignmentAnalytics",
|
|
||||||
"signature": "(assignmentId: string) => Promise<HomeworkAssignmentAnalytics | null>",
|
|
||||||
"usedBy": [
|
|
||||||
"homework错误分析组件"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "getHomeworkAssignmentById",
|
"name": "getHomeworkAssignmentById",
|
||||||
"signature": "(id: string, scope?: DataScope) => Promise<...>",
|
"signature": "(id: string, scope?: DataScope) => Promise<...>",
|
||||||
@@ -2618,6 +2605,58 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"statsService": [
|
||||||
|
{
|
||||||
|
"name": "getTeacherGradeTrends",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"signature": "(teacherId: string, limit?: number) => Promise<TeacherGradeTrendItem[]>",
|
||||||
|
"purpose": "教师仪表盘年级趋势数据",
|
||||||
|
"deps": [
|
||||||
|
"data-access.getAssignmentMaxScoreById"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"dashboard (教师仪表盘)"
|
||||||
|
],
|
||||||
|
"reExportedFrom": "data-access.ts (向后兼容)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getHomeworkAssignmentAnalytics",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"signature": "(assignmentId: string) => Promise<HomeworkAssignmentAnalytics | null>",
|
||||||
|
"purpose": "作业整体分析(含题目错误率/错答样本)",
|
||||||
|
"deps": [
|
||||||
|
"data-access.isRecord",
|
||||||
|
"data-access.toQuestionContent"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"homework错误分析组件"
|
||||||
|
],
|
||||||
|
"reExportedFrom": "data-access.ts (向后兼容)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getStudentDashboardGrades",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"signature": "(studentId: string) => Promise<StudentDashboardGradeProps>",
|
||||||
|
"purpose": "学生仪表盘成绩(趋势/近期/班级排名)",
|
||||||
|
"deps": [
|
||||||
|
"data-access.getAssignmentMaxScoreById"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"dashboard/data-access.ts"
|
||||||
|
],
|
||||||
|
"reExportedFrom": "data-access.ts (向后兼容)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getHomeworkDashboardStats",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"signature": "(scope?: DataScope) => Promise<HomeworkDashboardStats>",
|
||||||
|
"purpose": "获取作业仪表盘统计数据(作业数/已发布数/提交数/待批改数,支持数据范围过滤)",
|
||||||
|
"usedBy": [
|
||||||
|
"dashboard/data-access.getAdminDashboardData"
|
||||||
|
],
|
||||||
|
"reExportedFrom": "data-access.ts (向后兼容)"
|
||||||
|
}
|
||||||
|
],
|
||||||
"schema": [
|
"schema": [
|
||||||
{
|
{
|
||||||
"name": "CreateHomeworkAssignmentSchema",
|
"name": "CreateHomeworkAssignmentSchema",
|
||||||
@@ -2941,6 +2980,14 @@
|
|||||||
"teacher/questions/page.tsx"
|
"teacher/questions/page.tsx"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "getQuestionsDashboardStats",
|
||||||
|
"signature": "() => Promise<QuestionsDashboardStats>",
|
||||||
|
"purpose": "获取题目仪表盘统计数据(题目总数)",
|
||||||
|
"usedBy": [
|
||||||
|
"dashboard/data-access.getAdminDashboardData"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "GetQuestionsParams",
|
"name": "GetQuestionsParams",
|
||||||
"type": "type",
|
"type": "type",
|
||||||
@@ -3230,6 +3277,14 @@
|
|||||||
"usedBy": [
|
"usedBy": [
|
||||||
"reorderChaptersAction"
|
"reorderChaptersAction"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getTextbooksDashboardStats",
|
||||||
|
"signature": "() => Promise<TextbooksDashboardStats>",
|
||||||
|
"purpose": "获取教材仪表盘统计数据(教材总数、章节总数)",
|
||||||
|
"usedBy": [
|
||||||
|
"dashboard/data-access.getAdminDashboardData"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"hooks": [
|
"hooks": [
|
||||||
@@ -3618,6 +3673,14 @@
|
|||||||
"classes内部"
|
"classes内部"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "getClassesDashboardStats",
|
||||||
|
"signature": "() => Promise<ClassesDashboardStats>",
|
||||||
|
"purpose": "获取班级仪表盘统计数据(班级总数)",
|
||||||
|
"usedBy": [
|
||||||
|
"dashboard/data-access.getAdminDashboardData"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "createTeacherClass",
|
"name": "createTeacherClass",
|
||||||
"signature": "(input) => Promise<string>",
|
"signature": "(input) => Promise<string>",
|
||||||
@@ -4005,7 +4068,83 @@
|
|||||||
"purpose": "班级作业组件"
|
"purpose": "班级作业组件"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "data-access.ts",
|
||||||
|
"lines": 656,
|
||||||
|
"description": "核心班级 CRUD + 邀请码 + 教师班级管理(含 re-export 向后兼容)",
|
||||||
|
"exports": [
|
||||||
|
"getTeacherClasses",
|
||||||
|
"getTeacherOptions",
|
||||||
|
"getTeacherTeachingSubjects",
|
||||||
|
"createTeacherClass",
|
||||||
|
"ensureClassInvitationCode",
|
||||||
|
"regenerateClassInvitationCode",
|
||||||
|
"enrollStudentByInvitationCode",
|
||||||
|
"enrollTeacherByInvitationCode",
|
||||||
|
"updateTeacherClass",
|
||||||
|
"setClassSubjectTeachers",
|
||||||
|
"deleteTeacherClass",
|
||||||
|
"enrollStudentByEmail",
|
||||||
|
"setStudentEnrollmentStatus",
|
||||||
|
"getSessionTeacherId",
|
||||||
|
"getTeacherIdForMutations",
|
||||||
|
"getAccessibleClassIdsForTeacher",
|
||||||
|
"getTeacherSubjectIdsForClass",
|
||||||
|
"getClassSubjects",
|
||||||
|
"compareClassLike",
|
||||||
|
"isDuplicateInvitationCodeError",
|
||||||
|
"generateUniqueInvitationCode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data-access-stats.ts",
|
||||||
|
"lines": 604,
|
||||||
|
"description": "作业统计查询(班级/年级作业洞察)",
|
||||||
|
"exports": [
|
||||||
|
"getClassHomeworkInsights",
|
||||||
|
"getGradeHomeworkInsights",
|
||||||
|
"getClassesDashboardStats"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data-access-schedule.ts",
|
||||||
|
"lines": 230,
|
||||||
|
"description": "课表查询(学生/班级课表 CRUD)",
|
||||||
|
"exports": [
|
||||||
|
"getStudentSchedule",
|
||||||
|
"getClassSchedule",
|
||||||
|
"createClassScheduleItem",
|
||||||
|
"updateClassScheduleItem",
|
||||||
|
"deleteClassScheduleItem"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data-access-students.ts",
|
||||||
|
"lines": 280,
|
||||||
|
"description": "学生相关查询(科目成绩、学生名单、学生班级)",
|
||||||
|
"exports": [
|
||||||
|
"getStudentsSubjectScores",
|
||||||
|
"getClassStudentSubjectScoresV2",
|
||||||
|
"getStudentClasses",
|
||||||
|
"getClassStudents"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data-access-admin.ts",
|
||||||
|
"lines": 441,
|
||||||
|
"description": "管理员班级管理(管理员班级 CRUD、年级管理班级查询)",
|
||||||
|
"exports": [
|
||||||
|
"getAdminClasses",
|
||||||
|
"getGradeManagedClasses",
|
||||||
|
"getManagedGrades",
|
||||||
|
"createAdminClass",
|
||||||
|
"updateAdminClass",
|
||||||
|
"deleteAdminClass"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"school": {
|
"school": {
|
||||||
"path": "src/modules/school",
|
"path": "src/modules/school",
|
||||||
@@ -4250,7 +4389,12 @@
|
|||||||
"name": "getAdminDashboardData",
|
"name": "getAdminDashboardData",
|
||||||
"signature": "(scope?: DataScope) => Promise<AdminDashboardData>",
|
"signature": "(scope?: DataScope) => Promise<AdminDashboardData>",
|
||||||
"deps": [
|
"deps": [
|
||||||
"shared/db",
|
"users/data-access.getUsersDashboardStats",
|
||||||
|
"classes/data-access.getClassesDashboardStats",
|
||||||
|
"textbooks/data-access.getTextbooksDashboardStats",
|
||||||
|
"questions/data-access.getQuestionsDashboardStats",
|
||||||
|
"exams/data-access.getExamsDashboardStats",
|
||||||
|
"homework/stats-service.getHomeworkDashboardStats",
|
||||||
"DataScope"
|
"DataScope"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
@@ -4655,11 +4799,32 @@
|
|||||||
"shared.db.schema.users"
|
"shared.db.schema.users"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "getUsersDashboardStats",
|
||||||
|
"signature": "() => Promise<UsersDashboardStats>",
|
||||||
|
"file": "data-access.ts",
|
||||||
|
"deps": [
|
||||||
|
"shared.db",
|
||||||
|
"shared.db.schema.users",
|
||||||
|
"shared.db.schema.sessions",
|
||||||
|
"shared.db.schema.usersToRoles",
|
||||||
|
"shared.db.schema.roles"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"dashboard/data-access.getAdminDashboardData"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "UserProfile",
|
"name": "UserProfile",
|
||||||
"type": "type",
|
"type": "type",
|
||||||
"file": "data-access.ts",
|
"file": "data-access.ts",
|
||||||
"definition": "{ id, name, email, image, role, phone, address, gender, age, onboardedAt, createdAt, updatedAt }"
|
"definition": "{ id, name, email, image, role, phone, address, gender, age, onboardedAt, createdAt, updatedAt }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "UsersDashboardStats",
|
||||||
|
"type": "type",
|
||||||
|
"file": "data-access.ts",
|
||||||
|
"definition": "{ userCount, activeSessionsCount, userRoleCounts, recentUsers }"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"importExport": [
|
"importExport": [
|
||||||
@@ -10527,38 +10692,38 @@
|
|||||||
{
|
{
|
||||||
"from": "dashboard",
|
"from": "dashboard",
|
||||||
"to": "exams",
|
"to": "exams",
|
||||||
"type": "violation",
|
"type": "data-access",
|
||||||
"description": "直接查询 exams 表(违规)"
|
"description": "调用 getExamsDashboardStats 获取考试统计(P0-4 已修复)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "dashboard",
|
"from": "dashboard",
|
||||||
"to": "homework",
|
"to": "homework",
|
||||||
"type": "violation",
|
"type": "data-access",
|
||||||
"description": "直接查询 homeworkAssignments/homeworkSubmissions 表(违规)"
|
"description": "调用 getHomeworkDashboardStats 获取作业统计(P0-4 已修复)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "dashboard",
|
"from": "dashboard",
|
||||||
"to": "classes",
|
"to": "classes",
|
||||||
"type": "violation",
|
"type": "data-access",
|
||||||
"description": "直接查询 classes 表(违规)"
|
"description": "调用 getClassesDashboardStats 获取班级统计(P0-4 已修复)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "dashboard",
|
"from": "dashboard",
|
||||||
"to": "users",
|
"to": "users",
|
||||||
"type": "violation",
|
"type": "data-access",
|
||||||
"description": "直接查询 sessions/users/usersToRoles/roles 表(违规)"
|
"description": "调用 getUsersDashboardStats 获取用户/会话/角色统计(P0-4 已修复)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "dashboard",
|
"from": "dashboard",
|
||||||
"to": "textbooks",
|
"to": "textbooks",
|
||||||
"type": "violation",
|
"type": "data-access",
|
||||||
"description": "直接查询 textbooks/chapters 表(违规)"
|
"description": "调用 getTextbooksDashboardStats 获取教材/章节统计(P0-4 已修复)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "dashboard",
|
"from": "dashboard",
|
||||||
"to": "questions",
|
"to": "questions",
|
||||||
"type": "violation",
|
"type": "data-access",
|
||||||
"description": "直接查询 questions 表(违规)"
|
"description": "调用 getQuestionsDashboardStats 获取题目统计(P0-4 已修复)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "messaging",
|
"from": "messaging",
|
||||||
@@ -10714,7 +10879,10 @@
|
|||||||
"file": "src/modules/classes/data-access.ts",
|
"file": "src/modules/classes/data-access.ts",
|
||||||
"lines": 2104,
|
"lines": 2104,
|
||||||
"problem": "混入 homework/scheduling/grades 逻辑,严重违反模块职责单一原则",
|
"problem": "混入 homework/scheduling/grades 逻辑,严重违反模块职责单一原则",
|
||||||
"suggestion": "按职责拆分为 class-query/schedule/homework-insights/grade-query"
|
"suggestion": "按职责拆分为 class-query/schedule/homework-insights/grade-query",
|
||||||
|
"status": "resolved",
|
||||||
|
"resolvedAt": "2026-06-17",
|
||||||
|
"resolution": "拆分为 5 个文件:data-access.ts(656行,核心CRUD+邀请码+教师班级管理) + data-access-stats.ts(604行,作业统计) + data-access-schedule.ts(230行,课表) + data-access-students.ts(280行,学生查询) + data-access-admin.ts(441行,管理员班级管理),所有文件均 ≤800 行,data-access.ts 通过 re-export 保持向后兼容"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "P0-2",
|
"id": "P0-2",
|
||||||
@@ -10723,7 +10891,10 @@
|
|||||||
"file": "src/modules/homework/data-access.ts",
|
"file": "src/modules/homework/data-access.ts",
|
||||||
"lines": 1038,
|
"lines": 1038,
|
||||||
"problem": "混入排名计算业务逻辑",
|
"problem": "混入排名计算业务逻辑",
|
||||||
"suggestion": "分离排名逻辑到独立文件(如 data-access-ranking.ts)"
|
"suggestion": "分离排名逻辑到独立文件(如 data-access-ranking.ts)",
|
||||||
|
"status": "resolved",
|
||||||
|
"resolvedAt": "2026-06-17",
|
||||||
|
"resolution": "拆分为 data-access.ts(596行) + stats-service.ts(346行),统计函数(getTeacherGradeTrends/getHomeworkAssignmentAnalytics/getStudentDashboardGrades)迁移至 stats-service.ts,data-access.ts 通过 re-export 保持向后兼容"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "P0-3",
|
"id": "P0-3",
|
||||||
@@ -10739,7 +10910,9 @@
|
|||||||
"title": "dashboard 跨模块直接查询 11 张表",
|
"title": "dashboard 跨模块直接查询 11 张表",
|
||||||
"file": "src/modules/dashboard/data-access.ts",
|
"file": "src/modules/dashboard/data-access.ts",
|
||||||
"problem": "getAdminDashboardData 直查 sessions/users/classes/textbooks/chapters/questions/exams/homeworkAssignments/homeworkSubmissions/usersToRoles/roles,严重违反模块封装",
|
"problem": "getAdminDashboardData 直查 sessions/users/classes/textbooks/chapters/questions/exams/homeworkAssignments/homeworkSubmissions/usersToRoles/roles,严重违反模块封装",
|
||||||
"suggestion": "改为通过各模块 data-access 获取数据"
|
"suggestion": "改为通过各模块 data-access 获取数据",
|
||||||
|
"status": "fixed",
|
||||||
|
"fixedBy": "新增 getUsersDashboardStats/getClassesDashboardStats/getTextbooksDashboardStats/getQuestionsDashboardStats/getExamsDashboardStats/getHomeworkDashboardStats,dashboard 改为并行调用各模块 stats 函数"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "P0-5",
|
"id": "P0-5",
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
**当前关键风险项(v3):**
|
**当前关键风险项(v3):**
|
||||||
|
|
||||||
1. **`classes/data-access.ts` 严重超标** — 2104 行,超出 1000 行硬上限 2 倍,混入 homework/scheduling/grades 三个业务领域逻辑,维护风险极高
|
1. ~~**`classes/data-access.ts` 严重超标**~~ — ✅ 已修复(2026-06-17 拆分为 5 个文件,均 ≤800 行)
|
||||||
2. **`shared/lib` ↔ `auth` 循环依赖** — audit-logger/change-logger/auth-guard → @/auth → shared/lib/* 形成循环,影响构建稳定性
|
2. **`shared/lib` ↔ `auth` 循环依赖** — audit-logger/change-logger/auth-guard → @/auth → shared/lib/* 形成循环,影响构建稳定性
|
||||||
3. **dashboard 跨模块直接查询 11 张表** — getAdminDashboardData 直查 sessions/users/classes/textbooks 等 11 张表,严重违反模块封装
|
3. **dashboard 跨模块直接查询 11 张表** — getAdminDashboardData 直查 sessions/users/classes/textbooks 等 11 张表,严重违反模块封装
|
||||||
4. **messaging 绕过 notifications 直接写通知** — messaging/actions.ts 直接调用 createNotification,导致用户通知偏好失效
|
4. **messaging 绕过 notifications 直接写通知** — messaging/actions.ts 直接调用 createNotification,导致用户通知偏好失效
|
||||||
@@ -268,8 +268,8 @@
|
|||||||
|
|
||||||
| 序号 | 问题 | 文件/位置 | 严重程度 | 说明 |
|
| 序号 | 问题 | 文件/位置 | 严重程度 | 说明 |
|
||||||
|------|------|----------|---------|------|
|
|------|------|----------|---------|------|
|
||||||
| 1 | 文件超 1000 行硬上限 | `classes/data-access.ts` (2104 行) | 🔴 严重 | 混入 homework/scheduling/grades 三个业务领域逻辑,超出硬上限 2 倍 |
|
| 1 | ~~文件超 1000 行硬上限~~ | ~~`classes/data-access.ts` (2104 行)~~ ✅ | ~~🔴 严重~~ | ~~已修复:拆分为 5 个文件(data-access 656行 + stats 604行 + schedule 230行 + students 280行 + admin 441行)~~ |
|
||||||
| 2 | 文件超 1000 行硬上限 | `homework/data-access.ts` (1038 行) | 🔴 严重 | 混入排名计算业务逻辑,超出硬上限 |
|
| 2 | ~~文件超 1000 行硬上限~~ | ~~`homework/data-access.ts` (1038 行)~~ ✅ | ~~🔴 严重~~ | ~~已修复:拆分为 data-access.ts(596行) + stats-service.ts(346行)~~ |
|
||||||
| 3 | 文件超 1000 行硬上限 | `shared/db/schema.ts` (1111 行) | 🟡 需改进 | 54 张表混合,可接受但需按业务域分节 |
|
| 3 | 文件超 1000 行硬上限 | `shared/db/schema.ts` (1111 行) | 🟡 需改进 | 54 张表混合,可接受但需按业务域分节 |
|
||||||
| 4 | 循环依赖 | `shared/lib` ↔ `@/auth` | 🔴 严重 | audit-logger/change-logger/auth-guard → @/auth → shared/lib/* 形成循环 |
|
| 4 | 循环依赖 | `shared/lib` ↔ `@/auth` | 🔴 严重 | audit-logger/change-logger/auth-guard → @/auth → shared/lib/* 形成循环 |
|
||||||
| 5 | dashboard 跨模块直查 11 张表 | `dashboard/data-access.ts` | 🔴 严重 | getAdminDashboardData 直查 sessions/users/classes 等 11 张表,违反模块封装 |
|
| 5 | dashboard 跨模块直查 11 张表 | `dashboard/data-access.ts` | 🔴 严重 | getAdminDashboardData 直查 sessions/users/classes 等 11 张表,违反模块封装 |
|
||||||
@@ -302,8 +302,8 @@
|
|||||||
### 4.4 解耦优先级
|
### 4.4 解耦优先级
|
||||||
|
|
||||||
**立即执行(P0):**
|
**立即执行(P0):**
|
||||||
1. 拆分 `classes/data-access.ts`(2104 行 → 按职责拆 3-4 个文件)
|
1. ~~拆分 `classes/data-access.ts`(2104 行 → 按职责拆 3-4 个文件)~~ ✅ 已完成(拆为 5 个文件,均 ≤800 行)
|
||||||
2. 拆分 `homework/data-access.ts`(1038 行 → 分离排名逻辑)
|
2. ~~拆分 `homework/data-access.ts`(1038 行 → 分离排名逻辑)~~ ✅ 已完成
|
||||||
3. 修复 shared/lib ↔ auth 循环依赖
|
3. 修复 shared/lib ↔ auth 循环依赖
|
||||||
4. dashboard 改为通过各模块 data-access 获取数据
|
4. dashboard 改为通过各模块 data-access 获取数据
|
||||||
5. messaging 写通知改为通过 notifications dispatcher
|
5. messaging 写通知改为通过 notifications dispatcher
|
||||||
|
|||||||
@@ -30,8 +30,8 @@
|
|||||||
|
|
||||||
| 文件 | 行数 | 问题 |
|
| 文件 | 行数 | 问题 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `classes/data-access.ts` | 2104 | 混入 homework/scheduling/grades 逻辑 |
|
| ~~`classes/data-access.ts`~~ | ~~2104~~ → 656 | ~~混入 homework/scheduling/grades 逻辑~~ ✅ 已拆分为 5 个文件 |
|
||||||
| `homework/data-access.ts` | 1038 | 混入排名计算业务逻辑 |
|
| ~~`homework/data-access.ts`~~ | ~~1038~~ → 596 | ~~混入排名计算业务逻辑~~ ✅ 已拆分 |
|
||||||
| `shared/db/schema.ts` | 1111 | 54 张表混合(可接受,但需分节) |
|
| `shared/db/schema.ts` | 1111 | 54 张表混合(可接受,但需分节) |
|
||||||
|
|
||||||
### 2. 循环依赖
|
### 2. 循环依赖
|
||||||
@@ -114,8 +114,8 @@ NextAuth 配置 + 密码安全 DB 操作 + 角色规范化 + IP 解析 + 回调
|
|||||||
## 五、解耦优先级
|
## 五、解耦优先级
|
||||||
|
|
||||||
### 立即执行(P0)
|
### 立即执行(P0)
|
||||||
1. 拆分 `classes/data-access.ts`(2104 行 → 按职责拆 3-4 个文件)
|
1. ~~拆分 `classes/data-access.ts`(2104 行 → 按职责拆 3-4 个文件)~~ ✅ 已完成(拆为 5 个文件,均 ≤800 行)
|
||||||
2. 拆分 `homework/data-access.ts`(1038 行 → 分离排名逻辑)
|
2. ~~拆分 `homework/data-access.ts`(1038 行 → 分离排名逻辑)~~ ✅ 已完成
|
||||||
3. 修复 shared/lib ↔ auth 循环依赖
|
3. 修复 shared/lib ↔ auth 循环依赖
|
||||||
4. dashboard 改为通过各模块 data-access 获取数据
|
4. dashboard 改为通过各模块 data-access 获取数据
|
||||||
5. messaging 写通知改为通过 notifications dispatcher
|
5. messaging 写通知改为通过 notifications dispatcher
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ app/ ──▶ modules/ ──▶ shared/
|
|||||||
|
|
||||||
### P0 严重问题(必须立即修复)
|
### P0 严重问题(必须立即修复)
|
||||||
|
|
||||||
#### P0-1 `classes/data-access.ts` 2104 行,超硬上限 2.1 倍
|
#### P0-1 `classes/data-access.ts` 2104 行,超硬上限 2.1 倍 ✅ 已修复
|
||||||
|
|
||||||
**问题**:
|
**问题**:
|
||||||
- 文件行数 2104,远超 1000 行硬上限
|
- 文件行数 2104,远超 1000 行硬上限
|
||||||
@@ -58,18 +58,21 @@ app/ ──▶ modules/ ──▶ shared/
|
|||||||
**解耦方案**:
|
**解耦方案**:
|
||||||
```
|
```
|
||||||
src/modules/classes/
|
src/modules/classes/
|
||||||
├── data-access.ts # 班级核心 CRUD(目标 ≤500 行)
|
├── data-access.ts # 班级核心 CRUD(656 行)
|
||||||
├── data-access-stats.ts # 班级统计查询(getHomeworkStats 等)
|
├── data-access-stats.ts # 班级统计查询(604 行)
|
||||||
├── data-access-schedule.ts # 班级课表查询(getClassSchedule 等)
|
├── data-access-schedule.ts # 班级课表查询(230 行)
|
||||||
└── data-access-grades.ts # 班级成绩汇总(getClassGradeSummary 等)
|
├── data-access-students.ts # 学生相关查询(280 行)
|
||||||
|
└── data-access-admin.ts # 管理员班级管理(441 行)
|
||||||
```
|
```
|
||||||
|
|
||||||
**迁移步骤**:
|
**迁移步骤**:
|
||||||
1. 创建 3 个新文件,按职责迁移对应函数
|
1. ~~创建 3 个新文件,按职责迁移对应函数~~ ✅ 已创建 4 个新文件
|
||||||
2. 在 `data-access.ts` 中 re-export 以保持向后兼容
|
2. ~~在 `data-access.ts` 中 re-export 以保持向后兼容~~ ✅ 已完成
|
||||||
3. 逐步更新调用方 import 路径
|
3. 逐步更新调用方 import 路径
|
||||||
4. 最终移除 re-export,强制使用新路径
|
4. 最终移除 re-export,强制使用新路径
|
||||||
|
|
||||||
|
**完成状态**:2026-06-17 已完成拆分,所有文件均 ≤800 行,通过 re-export 保持向后兼容
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### P0-2 `homework/data-access.ts` 1038 行,混入排名计算
|
#### P0-2 `homework/data-access.ts` 1038 行,混入排名计算
|
||||||
@@ -380,9 +383,9 @@ src/shared/lib/ai/
|
|||||||
| 1 | P0-3 修复循环依赖 | shared/lib + auth.ts | 低 |
|
| 1 | P0-3 修复循环依赖 | shared/lib + auth.ts | 低 |
|
||||||
| 2 | P0-5 messaging 改用 dispatcher | messaging + notifications | 低 |
|
| 2 | P0-5 messaging 改用 dispatcher | messaging + notifications | 低 |
|
||||||
| 3 | P0-6 统一 classSchedule 写入口 | classes + scheduling | 中 |
|
| 3 | P0-6 统一 classSchedule 写入口 | classes + scheduling | 中 |
|
||||||
| 4 | P0-2 拆分 homework/data-access | homework | 中 |
|
| 4 | ~~P0-2 拆分 homework/data-access~~ ✅ | homework | 中 |
|
||||||
| 5 | P0-4 dashboard 改用模块 data-access | dashboard + 11 个模块 | 高 |
|
| 5 | P0-4 dashboard 改用模块 data-access | dashboard + 11 个模块 | 高 |
|
||||||
| 6 | P0-1 拆分 classes/data-access | classes + 多个调用方 | 高 |
|
| 6 | ~~P0-1 拆分 classes/data-access~~ ✅ | classes + 多个调用方 | 高 |
|
||||||
|
|
||||||
### 第二阶段:P1 修复(建议 2-4 周)
|
### 第二阶段:P1 修复(建议 2-4 周)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
| 模块 | 行数(最大文件) | 职责单一性 | 耦合度 | 严重度 |
|
| 模块 | 行数(最大文件) | 职责单一性 | 耦合度 | 严重度 |
|
||||||
|------|----------------|-----------|--------|--------|
|
|------|----------------|-----------|--------|--------|
|
||||||
| school | 325 | ✅ 良好 | ✅ 低 | 🟢 合格 |
|
| school | 325 | ✅ 良好 | ✅ 低 | 🟢 合格 |
|
||||||
| classes | **2104** | ❌ 严重违反 | ❌ 严重 | 🔴 严重 |
|
| classes | ~~2104~~ → 656 | ✅ 已修复 | ❌ 严重 | 🟡 需改进 |
|
||||||
| scheduling | 310(算法)/ 302(actions) | ✅ 算法独立 | ⚠️ 中 | 🟡 需改进 |
|
| scheduling | 310(算法)/ 302(actions) | ✅ 算法独立 | ⚠️ 中 | 🟡 需改进 |
|
||||||
| attendance | 271 | ✅ 良好 | ⚠️ 中 | 🟢 合格 |
|
| attendance | 271 | ✅ 良好 | ⚠️ 中 | 🟢 合格 |
|
||||||
| users | 291(import-export) | ❌ 违反 | ❌ 高 | 🟠 较严重 |
|
| users | 291(import-export) | ❌ 违反 | ❌ 高 | 🟠 较严重 |
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
| announcements | 242 | ⚠️ 部分违反 | ✅ 低 | 🟡 需改进 |
|
| announcements | 242 | ⚠️ 部分违反 | ✅ 低 | 🟡 需改进 |
|
||||||
|
|
||||||
**核心结论**:
|
**核心结论**:
|
||||||
1. `classes` 模块是全项目耦合最严重的模块,单文件 2104 行远超 1000 行硬性上限,混入了 schedule、homework、grades 三个业务领域的逻辑。
|
1. ~~`classes` 模块是全项目耦合最严重的模块,单文件 2104 行远超 1000 行硬性上限,混入了 schedule、homework、grades 三个业务领域的逻辑。~~ ✅ 已修复(2026-06-17 拆分为 5 个文件,均 ≤800 行)
|
||||||
2. `users/import-export.ts` 违反单一职责,同时处理导入、导出、用户创建、班级注册四类逻辑。
|
2. `users/import-export.ts` 违反单一职责,同时处理导入、导出、用户创建、班级注册四类逻辑。
|
||||||
3. `scheduling/auto-scheduler.ts` 是算法独立化的**优秀范例**,纯函数、无 DB 访问、可独立测试。
|
3. `scheduling/auto-scheduler.ts` 是算法独立化的**优秀范例**,纯函数、无 DB 访问、可独立测试。
|
||||||
4. `announcements` 和 `audit` 模块的 data-access 层不完整,写操作或导出逻辑泄漏到 actions 层。
|
4. `announcements` 和 `audit` 模块的 data-access 层不完整,写操作或导出逻辑泄漏到 actions 层。
|
||||||
@@ -49,11 +49,11 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2.2 classes 模块 — 🔴 严重
|
### 2.2 classes 模块 — 🟡 需改进(文件拆分已修复,跨模块耦合仍存在)
|
||||||
|
|
||||||
**文件清单**:actions.ts (765 行) / data-access.ts (**2104 行**) / types.ts (201 行)
|
**文件清单**:actions.ts (765 行) / data-access.ts (656 行) / data-access-stats.ts (604 行) / data-access-schedule.ts (230 行) / data-access-students.ts (280 行) / data-access-admin.ts (441 行) / types.ts (201 行)
|
||||||
|
|
||||||
> ⚠️ `data-access.ts` 达 2104 行,**超出 1000 行硬性上限 2 倍**,违反项目代码质量规则。
|
> ✅ `data-access.ts` 已于 2026-06-17 拆分为 5 个文件,所有文件均 ≤800 行,通过 re-export 保持向后兼容。
|
||||||
|
|
||||||
#### 2.2.1 职责混乱 — 混入三个外部业务领域
|
#### 2.2.1 职责混乱 — 混入三个外部业务领域
|
||||||
|
|
||||||
|
|||||||
441
src/modules/classes/data-access-admin.ts
Normal file
441
src/modules/classes/data-access-admin.ts
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { and, asc, eq, inArray, or, sql } from "drizzle-orm"
|
||||||
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
classes,
|
||||||
|
classEnrollments,
|
||||||
|
classSubjectTeachers,
|
||||||
|
grades,
|
||||||
|
schools,
|
||||||
|
subjects,
|
||||||
|
roles,
|
||||||
|
users,
|
||||||
|
usersToRoles,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||||
|
import type {
|
||||||
|
AdminClassListItem,
|
||||||
|
ClassSubject,
|
||||||
|
ClassSubjectTeacherAssignment,
|
||||||
|
CreateTeacherClassInput,
|
||||||
|
TeacherOption,
|
||||||
|
UpdateTeacherClassInput,
|
||||||
|
} from "./types"
|
||||||
|
import {
|
||||||
|
compareClassLike,
|
||||||
|
generateUniqueInvitationCode,
|
||||||
|
isDuplicateInvitationCodeError,
|
||||||
|
} from "./data-access"
|
||||||
|
|
||||||
|
export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
|
||||||
|
const [rows, subjectRows] = await Promise.all([
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: classes.id,
|
||||||
|
schoolName: classes.schoolName,
|
||||||
|
schoolId: classes.schoolId,
|
||||||
|
name: classes.name,
|
||||||
|
grade: classes.grade,
|
||||||
|
gradeId: classes.gradeId,
|
||||||
|
homeroom: classes.homeroom,
|
||||||
|
room: classes.room,
|
||||||
|
invitationCode: classes.invitationCode,
|
||||||
|
teacherId: users.id,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
|
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
||||||
|
createdAt: classes.createdAt,
|
||||||
|
updatedAt: classes.updatedAt,
|
||||||
|
})
|
||||||
|
.from(classes)
|
||||||
|
.innerJoin(users, eq(users.id, classes.teacherId))
|
||||||
|
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||||
|
.groupBy(
|
||||||
|
classes.id,
|
||||||
|
classes.schoolName,
|
||||||
|
classes.schoolId,
|
||||||
|
classes.name,
|
||||||
|
classes.grade,
|
||||||
|
classes.gradeId,
|
||||||
|
classes.homeroom,
|
||||||
|
classes.room,
|
||||||
|
classes.invitationCode,
|
||||||
|
users.id,
|
||||||
|
users.name,
|
||||||
|
users.email,
|
||||||
|
classes.createdAt,
|
||||||
|
classes.updatedAt
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
asc(classes.schoolName),
|
||||||
|
asc(classes.grade),
|
||||||
|
asc(classes.name),
|
||||||
|
asc(classes.homeroom),
|
||||||
|
asc(classes.room)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: classes.id,
|
||||||
|
schoolName: sql<string | null>`NULL`.as("schoolName"),
|
||||||
|
schoolId: sql<string | null>`NULL`.as("schoolId"),
|
||||||
|
name: classes.name,
|
||||||
|
grade: classes.grade,
|
||||||
|
gradeId: sql<string | null>`NULL`.as("gradeId"),
|
||||||
|
homeroom: classes.homeroom,
|
||||||
|
room: classes.room,
|
||||||
|
invitationCode: sql<string | null>`NULL`.as("invitationCode"),
|
||||||
|
teacherId: users.id,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
|
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
||||||
|
createdAt: classes.createdAt,
|
||||||
|
updatedAt: classes.updatedAt,
|
||||||
|
})
|
||||||
|
.from(classes)
|
||||||
|
.innerJoin(users, eq(users.id, classes.teacherId))
|
||||||
|
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||||
|
.groupBy(
|
||||||
|
classes.id,
|
||||||
|
classes.name,
|
||||||
|
classes.grade,
|
||||||
|
classes.homeroom,
|
||||||
|
classes.room,
|
||||||
|
users.id,
|
||||||
|
users.name,
|
||||||
|
users.email,
|
||||||
|
classes.createdAt,
|
||||||
|
classes.updatedAt
|
||||||
|
)
|
||||||
|
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
classId: classSubjectTeachers.classId,
|
||||||
|
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(subjects.name)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||||
|
for (const r of subjectRows) {
|
||||||
|
const subject = r.subject as ClassSubject
|
||||||
|
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
|
||||||
|
const teacher =
|
||||||
|
typeof r.teacherId === "string" && r.teacherId.length > 0
|
||||||
|
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
|
||||||
|
: null
|
||||||
|
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
|
||||||
|
bySubject.set(subject, teacher)
|
||||||
|
subjectsByClassId.set(r.classId, bySubject)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = rows.map((r) => {
|
||||||
|
const bySubject = subjectsByClassId.get(r.id)
|
||||||
|
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
||||||
|
subject,
|
||||||
|
teacher: bySubject?.get(subject) ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
schoolName: r.schoolName,
|
||||||
|
schoolId: r.schoolId,
|
||||||
|
name: r.name,
|
||||||
|
grade: r.grade,
|
||||||
|
gradeId: r.gradeId,
|
||||||
|
homeroom: r.homeroom,
|
||||||
|
room: r.room,
|
||||||
|
invitationCode: r.invitationCode ?? null,
|
||||||
|
teacher: {
|
||||||
|
id: r.teacherId,
|
||||||
|
name: r.teacherName ?? "Unnamed",
|
||||||
|
email: r.teacherEmail,
|
||||||
|
},
|
||||||
|
subjectTeachers,
|
||||||
|
studentCount: Number(r.studentCount ?? 0),
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
list.sort(compareClassLike)
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getGradeManagedClasses = cache(async (userId: string): Promise<AdminClassListItem[]> => {
|
||||||
|
const managedGradeIds = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
|
||||||
|
|
||||||
|
if (managedGradeIds.length === 0) return []
|
||||||
|
|
||||||
|
const gradeIds = managedGradeIds.map((g) => g.id)
|
||||||
|
|
||||||
|
const [rows, subjectRows] = await Promise.all([
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: classes.id,
|
||||||
|
schoolName: classes.schoolName,
|
||||||
|
schoolId: classes.schoolId,
|
||||||
|
name: classes.name,
|
||||||
|
grade: classes.grade,
|
||||||
|
gradeId: classes.gradeId,
|
||||||
|
homeroom: classes.homeroom,
|
||||||
|
room: classes.room,
|
||||||
|
invitationCode: classes.invitationCode,
|
||||||
|
teacherId: users.id,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
|
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
||||||
|
createdAt: classes.createdAt,
|
||||||
|
updatedAt: classes.updatedAt,
|
||||||
|
})
|
||||||
|
.from(classes)
|
||||||
|
.innerJoin(users, eq(users.id, classes.teacherId))
|
||||||
|
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||||
|
.where(inArray(classes.gradeId, gradeIds))
|
||||||
|
.groupBy(
|
||||||
|
classes.id,
|
||||||
|
classes.schoolName,
|
||||||
|
classes.schoolId,
|
||||||
|
classes.name,
|
||||||
|
classes.grade,
|
||||||
|
classes.gradeId,
|
||||||
|
classes.homeroom,
|
||||||
|
classes.room,
|
||||||
|
classes.invitationCode,
|
||||||
|
users.id,
|
||||||
|
users.name,
|
||||||
|
users.email,
|
||||||
|
classes.createdAt,
|
||||||
|
classes.updatedAt
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
asc(classes.schoolName),
|
||||||
|
asc(classes.grade),
|
||||||
|
asc(classes.name),
|
||||||
|
asc(classes.homeroom),
|
||||||
|
asc(classes.room)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
classId: classSubjectTeachers.classId,
|
||||||
|
subject: subjects.name,
|
||||||
|
teacherId: users.id,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
|
})
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||||
|
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
|
||||||
|
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
||||||
|
.where(inArray(classes.gradeId, gradeIds))
|
||||||
|
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||||
|
for (const r of subjectRows) {
|
||||||
|
const subject = r.subject as ClassSubject
|
||||||
|
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
|
||||||
|
const teacher =
|
||||||
|
typeof r.teacherId === "string" && r.teacherId.length > 0
|
||||||
|
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
|
||||||
|
: null
|
||||||
|
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
|
||||||
|
bySubject.set(subject, teacher)
|
||||||
|
subjectsByClassId.set(r.classId, bySubject)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = rows.map((r) => {
|
||||||
|
const bySubject = subjectsByClassId.get(r.id)
|
||||||
|
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
||||||
|
subject,
|
||||||
|
teacher: bySubject?.get(subject) ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
schoolName: r.schoolName,
|
||||||
|
schoolId: r.schoolId,
|
||||||
|
name: r.name,
|
||||||
|
grade: r.grade,
|
||||||
|
gradeId: r.gradeId,
|
||||||
|
homeroom: r.homeroom,
|
||||||
|
room: r.room,
|
||||||
|
invitationCode: r.invitationCode ?? null,
|
||||||
|
teacher: {
|
||||||
|
id: r.teacherId,
|
||||||
|
name: r.teacherName ?? "Unnamed",
|
||||||
|
email: r.teacherEmail,
|
||||||
|
},
|
||||||
|
subjectTeachers,
|
||||||
|
studentCount: Number(r.studentCount ?? 0),
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
list.sort(compareClassLike)
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getManagedGrades = cache(async (userId: string) => {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: grades.id,
|
||||||
|
name: grades.name,
|
||||||
|
schoolId: grades.schoolId,
|
||||||
|
schoolName: schools.name,
|
||||||
|
})
|
||||||
|
.from(grades)
|
||||||
|
.innerJoin(schools, eq(schools.id, grades.schoolId))
|
||||||
|
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
|
||||||
|
.orderBy(asc(schools.name), asc(grades.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function createAdminClass(data: CreateTeacherClassInput & { teacherId: string }): Promise<string> {
|
||||||
|
const id = createId()
|
||||||
|
|
||||||
|
const schoolName = data.schoolName?.trim() || null
|
||||||
|
const schoolId = data.schoolId?.trim() || null
|
||||||
|
const name = data.name.trim()
|
||||||
|
const grade = data.grade.trim()
|
||||||
|
const gradeId = data.gradeId?.trim() || null
|
||||||
|
const homeroom = data.homeroom?.trim() || null
|
||||||
|
const room = data.room?.trim() || null
|
||||||
|
const teacherId = data.teacherId.trim()
|
||||||
|
|
||||||
|
if (!name) throw new Error("Name is required")
|
||||||
|
if (!grade) throw new Error("Grade is required")
|
||||||
|
if (!teacherId) throw new Error("Teacher is required")
|
||||||
|
|
||||||
|
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, 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,
|
||||||
|
schoolName,
|
||||||
|
schoolId,
|
||||||
|
name,
|
||||||
|
grade,
|
||||||
|
gradeId,
|
||||||
|
homeroom,
|
||||||
|
room,
|
||||||
|
invitationCode,
|
||||||
|
teacherId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const values = DEFAULT_CLASS_SUBJECTS
|
||||||
|
.filter((name) => idByName.has(name))
|
||||||
|
.map((name) => ({
|
||||||
|
classId: id,
|
||||||
|
subjectId: idByName.get(name)!,
|
||||||
|
teacherId: null,
|
||||||
|
}))
|
||||||
|
await tx.insert(classSubjectTeachers).values(values)
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
} catch (err) {
|
||||||
|
if (isDuplicateInvitationCodeError(err)) continue
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Failed to create class")
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminClass(
|
||||||
|
classId: string,
|
||||||
|
data: UpdateTeacherClassInput & { teacherId?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
const id = classId.trim()
|
||||||
|
if (!id) throw new Error("Missing class id")
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.id, id))
|
||||||
|
.limit(1)
|
||||||
|
if (!existing) throw new Error("Class not found")
|
||||||
|
|
||||||
|
const update: Partial<typeof classes.$inferSelect> = {}
|
||||||
|
if (data.schoolName !== undefined) update.schoolName = data.schoolName?.trim() || null
|
||||||
|
if (data.schoolId !== undefined) update.schoolId = data.schoolId?.trim() || null
|
||||||
|
if (typeof data.name === "string") update.name = data.name.trim()
|
||||||
|
if (typeof data.grade === "string") update.grade = data.grade.trim()
|
||||||
|
if (data.gradeId !== undefined) update.gradeId = data.gradeId?.trim() || null
|
||||||
|
if (data.homeroom !== undefined) update.homeroom = data.homeroom?.trim() || null
|
||||||
|
if (data.room !== undefined) update.room = data.room?.trim() || null
|
||||||
|
|
||||||
|
if (typeof data.teacherId === "string") {
|
||||||
|
const nextTeacherId = data.teacherId.trim()
|
||||||
|
if (!nextTeacherId) throw new Error("Teacher is required")
|
||||||
|
|
||||||
|
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, nextTeacherId), eq(roles.name, "teacher")))
|
||||||
|
.limit(1)
|
||||||
|
if (!teacher) throw new Error("Teacher not found")
|
||||||
|
|
||||||
|
update.teacherId = nextTeacherId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(update).length === 0) return
|
||||||
|
|
||||||
|
await db.update(classes).set(update).where(eq(classes.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminClass(classId: string): Promise<void> {
|
||||||
|
const id = classId.trim()
|
||||||
|
if (!id) throw new Error("Missing class id")
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.id, id))
|
||||||
|
.limit(1)
|
||||||
|
if (!existing) throw new Error("Class not found")
|
||||||
|
|
||||||
|
await db.delete(classes).where(eq(classes.id, id))
|
||||||
|
}
|
||||||
230
src/modules/classes/data-access-schedule.ts
Normal file
230
src/modules/classes/data-access-schedule.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { and, asc, eq, inArray, type SQL } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
classes,
|
||||||
|
classEnrollments,
|
||||||
|
classSchedule,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
import {
|
||||||
|
insertClassScheduleItem,
|
||||||
|
updateClassScheduleItemById,
|
||||||
|
deleteClassScheduleItemById,
|
||||||
|
} from "@/modules/scheduling/data-access"
|
||||||
|
import type {
|
||||||
|
ClassScheduleItem,
|
||||||
|
CreateClassScheduleItemInput,
|
||||||
|
StudentScheduleItem,
|
||||||
|
UpdateClassScheduleItemInput,
|
||||||
|
} from "./types"
|
||||||
|
import {
|
||||||
|
getAccessibleClassIdsForTeacher,
|
||||||
|
getSessionTeacherId,
|
||||||
|
getTeacherIdForMutations,
|
||||||
|
} from "./data-access"
|
||||||
|
|
||||||
|
export const getStudentSchedule = cache(async (studentId: string): Promise<StudentScheduleItem[]> => {
|
||||||
|
const id = studentId.trim()
|
||||||
|
if (!id) return []
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: classSchedule.id,
|
||||||
|
classId: classSchedule.classId,
|
||||||
|
className: classes.name,
|
||||||
|
weekday: classSchedule.weekday,
|
||||||
|
startTime: classSchedule.startTime,
|
||||||
|
endTime: classSchedule.endTime,
|
||||||
|
course: classSchedule.course,
|
||||||
|
location: classSchedule.location,
|
||||||
|
})
|
||||||
|
.from(classEnrollments)
|
||||||
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
|
.innerJoin(classSchedule, eq(classSchedule.classId, classes.id))
|
||||||
|
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||||
|
.orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime))
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
classId: r.classId,
|
||||||
|
className: r.className,
|
||||||
|
weekday: r.weekday as StudentScheduleItem["weekday"],
|
||||||
|
startTime: r.startTime,
|
||||||
|
endTime: r.endTime,
|
||||||
|
course: r.course,
|
||||||
|
location: r.location,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getClassSchedule = cache(
|
||||||
|
async (params?: { classId?: string; teacherId?: string }): Promise<ClassScheduleItem[]> => {
|
||||||
|
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||||
|
if (!teacherId) return []
|
||||||
|
|
||||||
|
const classId = params?.classId?.trim()
|
||||||
|
|
||||||
|
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
|
||||||
|
.select({
|
||||||
|
id: classSchedule.id,
|
||||||
|
classId: classSchedule.classId,
|
||||||
|
weekday: classSchedule.weekday,
|
||||||
|
startTime: classSchedule.startTime,
|
||||||
|
endTime: classSchedule.endTime,
|
||||||
|
course: classSchedule.course,
|
||||||
|
location: classSchedule.location,
|
||||||
|
})
|
||||||
|
.from(classSchedule)
|
||||||
|
.innerJoin(classes, eq(classes.id, classSchedule.classId))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime))
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
classId: r.classId,
|
||||||
|
weekday: r.weekday as ClassScheduleItem["weekday"],
|
||||||
|
startTime: r.startTime,
|
||||||
|
endTime: r.endTime,
|
||||||
|
course: r.course,
|
||||||
|
location: r.location,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const isTimeHHMM = (v: string) => /^\d{2}:\d{2}$/.test(v)
|
||||||
|
|
||||||
|
export async function createClassScheduleItem(data: CreateClassScheduleItemInput): Promise<string> {
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
|
||||||
|
const classId = data.classId.trim()
|
||||||
|
const course = data.course.trim()
|
||||||
|
const startTime = data.startTime.trim()
|
||||||
|
const endTime = data.endTime.trim()
|
||||||
|
const location = data.location?.trim() || null
|
||||||
|
const weekday = data.weekday
|
||||||
|
|
||||||
|
if (!classId) throw new Error("Class is required")
|
||||||
|
if (!course) throw new Error("Course is required")
|
||||||
|
if (!isTimeHHMM(startTime) || !isTimeHHMM(endTime)) throw new Error("Invalid time format")
|
||||||
|
if (startTime >= endTime) throw new Error("Start time must be earlier than end time")
|
||||||
|
if (weekday < 1 || weekday > 7) throw new Error("Invalid weekday")
|
||||||
|
|
||||||
|
const [owned] = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!owned) throw new Error("Class not found")
|
||||||
|
|
||||||
|
// Delegate DB write to scheduling module (unified write entry point)
|
||||||
|
return insertClassScheduleItem({
|
||||||
|
classId,
|
||||||
|
weekday,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
course,
|
||||||
|
location,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateClassScheduleItem(scheduleId: string, data: UpdateClassScheduleItemInput): Promise<void> {
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
const id = scheduleId.trim()
|
||||||
|
if (!id) throw new Error("Missing schedule id")
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({
|
||||||
|
id: classSchedule.id,
|
||||||
|
classId: classSchedule.classId,
|
||||||
|
startTime: classSchedule.startTime,
|
||||||
|
endTime: classSchedule.endTime,
|
||||||
|
})
|
||||||
|
.from(classSchedule)
|
||||||
|
.innerJoin(classes, eq(classes.id, classSchedule.classId))
|
||||||
|
.where(and(eq(classSchedule.id, id), eq(classes.teacherId, teacherId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Schedule item not found")
|
||||||
|
|
||||||
|
const update: Partial<typeof classSchedule.$inferSelect> = {}
|
||||||
|
|
||||||
|
if (typeof data.classId === "string") {
|
||||||
|
const nextClassId = data.classId.trim()
|
||||||
|
if (!nextClassId) throw new Error("Class is required")
|
||||||
|
|
||||||
|
const [ownedNext] = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(and(eq(classes.id, nextClassId), eq(classes.teacherId, teacherId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!ownedNext) throw new Error("Class not found")
|
||||||
|
update.classId = nextClassId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.weekday === "number") {
|
||||||
|
if (data.weekday < 1 || data.weekday > 7) throw new Error("Invalid weekday")
|
||||||
|
update.weekday = data.weekday
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.course === "string") {
|
||||||
|
const course = data.course.trim()
|
||||||
|
if (!course) throw new Error("Course is required")
|
||||||
|
update.course = course
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStart = typeof data.startTime === "string" ? data.startTime.trim() : undefined
|
||||||
|
const nextEnd = typeof data.endTime === "string" ? data.endTime.trim() : undefined
|
||||||
|
if (nextStart !== undefined) {
|
||||||
|
if (!isTimeHHMM(nextStart)) throw new Error("Invalid time format")
|
||||||
|
update.startTime = nextStart
|
||||||
|
}
|
||||||
|
if (nextEnd !== undefined) {
|
||||||
|
if (!isTimeHHMM(nextEnd)) throw new Error("Invalid time format")
|
||||||
|
update.endTime = nextEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.startTime !== undefined || update.endTime !== undefined) {
|
||||||
|
const mergedStart = update.startTime ?? existing.startTime
|
||||||
|
const mergedEnd = update.endTime ?? existing.endTime
|
||||||
|
if (typeof mergedStart === "string" && typeof mergedEnd === "string" && mergedStart >= mergedEnd) {
|
||||||
|
throw new Error("Start time must be earlier than end time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.location !== undefined) {
|
||||||
|
update.location = data.location?.trim() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(update).length === 0) return
|
||||||
|
|
||||||
|
// Delegate DB write to scheduling module (unified write entry point)
|
||||||
|
await updateClassScheduleItemById(id, update)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteClassScheduleItem(scheduleId: string): Promise<void> {
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
const id = scheduleId.trim()
|
||||||
|
if (!id) throw new Error("Missing schedule id")
|
||||||
|
|
||||||
|
const [owned] = await db
|
||||||
|
.select({ id: classSchedule.id })
|
||||||
|
.from(classSchedule)
|
||||||
|
.innerJoin(classes, eq(classes.id, classSchedule.classId))
|
||||||
|
.where(and(eq(classSchedule.id, id), eq(classes.teacherId, teacherId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!owned) throw new Error("Schedule item not found")
|
||||||
|
|
||||||
|
// Delegate DB write to scheduling module (unified write entry point)
|
||||||
|
await deleteClassScheduleItemById(id)
|
||||||
|
}
|
||||||
604
src/modules/classes/data-access-stats.ts
Normal file
604
src/modules/classes/data-access-stats.ts
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { and, asc, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
classes,
|
||||||
|
classEnrollments,
|
||||||
|
grades,
|
||||||
|
homeworkAssignmentQuestions,
|
||||||
|
homeworkAssignmentTargets,
|
||||||
|
homeworkAssignments,
|
||||||
|
homeworkSubmissions,
|
||||||
|
schools,
|
||||||
|
subjects,
|
||||||
|
exams,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
import type {
|
||||||
|
ClassHomeworkInsights,
|
||||||
|
ClassHomeworkAssignmentStats,
|
||||||
|
GradeHomeworkClassSummary,
|
||||||
|
GradeHomeworkInsights,
|
||||||
|
ScoreStats,
|
||||||
|
} from "./types"
|
||||||
|
import {
|
||||||
|
getAccessibleClassIdsForTeacher,
|
||||||
|
getSessionTeacherId,
|
||||||
|
getTeacherSubjectIdsForClass,
|
||||||
|
} from "./data-access"
|
||||||
|
|
||||||
|
const median = (sorted: number[]): number | null => {
|
||||||
|
if (sorted.length === 0) return null
|
||||||
|
const mid = Math.floor(sorted.length / 2)
|
||||||
|
if (sorted.length % 2 === 1) return sorted[mid] ?? null
|
||||||
|
const a = sorted[mid - 1]
|
||||||
|
const b = sorted[mid]
|
||||||
|
if (typeof a !== "number" || typeof b !== "number") return null
|
||||||
|
return (a + b) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const toScoreStats = (scores: number[]): ScoreStats => {
|
||||||
|
if (scores.length === 0) return { count: 0, avg: null, median: null, min: null, max: null }
|
||||||
|
const sorted = [...scores].sort((a, b) => a - b)
|
||||||
|
const sum = sorted.reduce((acc, v) => acc + v, 0)
|
||||||
|
return {
|
||||||
|
count: sorted.length,
|
||||||
|
avg: sum / sorted.length,
|
||||||
|
median: median(sorted),
|
||||||
|
min: sorted[0] ?? null,
|
||||||
|
max: sorted[sorted.length - 1] ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getClassHomeworkInsights = cache(
|
||||||
|
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
|
||||||
|
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({
|
||||||
|
id: classes.id,
|
||||||
|
name: classes.name,
|
||||||
|
grade: classes.grade,
|
||||||
|
homeroom: classes.homeroom,
|
||||||
|
room: classes.room,
|
||||||
|
invitationCode: classes.invitationCode,
|
||||||
|
teacherId: classes.teacherId,
|
||||||
|
})
|
||||||
|
.from(classes)
|
||||||
|
.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({
|
||||||
|
studentId: classEnrollments.studentId,
|
||||||
|
status: classEnrollments.status,
|
||||||
|
})
|
||||||
|
.from(classEnrollments)
|
||||||
|
.innerJoin(classes, eq(classes.id, classEnrollments.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: {
|
||||||
|
id: classRow.id,
|
||||||
|
name: classRow.name,
|
||||||
|
grade: classRow.grade,
|
||||||
|
homeroom: classRow.homeroom,
|
||||||
|
room: classRow.room,
|
||||||
|
invitationCode: classRow.invitationCode ?? null,
|
||||||
|
},
|
||||||
|
studentCounts: { total: 0, active: 0, inactive: 0 },
|
||||||
|
assignments: [],
|
||||||
|
latest: null,
|
||||||
|
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignmentIdRows = await db
|
||||||
|
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
|
||||||
|
|
||||||
|
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
|
||||||
|
if (assignmentIds.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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
title: homeworkAssignments.title,
|
||||||
|
status: homeworkAssignments.status,
|
||||||
|
createdAt: homeworkAssignments.createdAt,
|
||||||
|
dueAt: homeworkAssignments.dueAt,
|
||||||
|
subjectId: exams.subjectId,
|
||||||
|
subjectName: subjects.name
|
||||||
|
})
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||||
|
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||||
|
.where(and(...assignmentConditions))
|
||||||
|
.orderBy(desc(homeworkAssignments.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
const usedAssignmentIds = assignments.map((a) => a.id)
|
||||||
|
if (usedAssignmentIds.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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxScoreRows = await db
|
||||||
|
.select({
|
||||||
|
assignmentId: homeworkAssignmentQuestions.assignmentId,
|
||||||
|
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
|
||||||
|
})
|
||||||
|
.from(homeworkAssignmentQuestions)
|
||||||
|
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
|
||||||
|
.groupBy(homeworkAssignmentQuestions.assignmentId)
|
||||||
|
|
||||||
|
const maxScoreByAssignmentId = new Map<string, number>()
|
||||||
|
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0))
|
||||||
|
|
||||||
|
const targetCountRows = await db
|
||||||
|
.select({
|
||||||
|
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||||
|
targetCount: sql<number>`COUNT(*)`,
|
||||||
|
})
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds),
|
||||||
|
inArray(homeworkAssignmentTargets.studentId, studentIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(homeworkAssignmentTargets.assignmentId)
|
||||||
|
|
||||||
|
const targetCountByAssignmentId = new Map<string, number>()
|
||||||
|
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
|
||||||
|
|
||||||
|
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(homeworkSubmissions.assignmentId, usedAssignmentIds),
|
||||||
|
inArray(homeworkSubmissions.studentId, studentIds)
|
||||||
|
),
|
||||||
|
orderBy: [desc(homeworkSubmissions.createdAt)],
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestByKey = new Map<string, (typeof submissions)[number]>()
|
||||||
|
for (const s of submissions) {
|
||||||
|
const key = `${s.assignmentId}:${s.studentId}`
|
||||||
|
if (!latestByKey.has(key)) latestByKey.set(key, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allScored: number[] = []
|
||||||
|
const nowMs = Date.now()
|
||||||
|
|
||||||
|
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
|
||||||
|
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
|
||||||
|
let submittedCount = 0
|
||||||
|
let gradedCount = 0
|
||||||
|
const scores: number[] = []
|
||||||
|
const dueMs = a.dueAt ? a.dueAt.getTime() : null
|
||||||
|
|
||||||
|
for (const studentId of studentIds) {
|
||||||
|
const s = latestByKey.get(`${a.id}:${studentId}`)
|
||||||
|
if (!s) continue
|
||||||
|
|
||||||
|
const status = (s.status ?? "started") as string
|
||||||
|
if (status === "submitted" || status === "graded") submittedCount += 1
|
||||||
|
if (status === "graded" || typeof s.score === "number") gradedCount += 1
|
||||||
|
if (typeof s.score === "number") scores.push(s.score)
|
||||||
|
}
|
||||||
|
|
||||||
|
allScored.push(...scores)
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignmentId: a.id,
|
||||||
|
title: a.title,
|
||||||
|
status: (a.status as string) ?? "draft",
|
||||||
|
subject: a.subjectName,
|
||||||
|
createdAt: a.createdAt.toISOString(),
|
||||||
|
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||||
|
isActive: dueMs === null || dueMs >= nowMs,
|
||||||
|
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
|
||||||
|
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
|
||||||
|
targetCount,
|
||||||
|
submittedCount,
|
||||||
|
gradedCount,
|
||||||
|
scoreStats: toScoreStats(scores),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const overallScores = toScoreStats(allScored)
|
||||||
|
const latest = stats[0] ?? null
|
||||||
|
|
||||||
|
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: stats,
|
||||||
|
latest,
|
||||||
|
overallScores,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const avg = (values: number[]): number | null => {
|
||||||
|
if (values.length === 0) return null
|
||||||
|
const sum = values.reduce((acc, v) => acc + v, 0)
|
||||||
|
return sum / values.length
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGradeHomeworkInsights = cache(
|
||||||
|
async (params: { gradeId: string; limit?: number }): Promise<GradeHomeworkInsights | null> => {
|
||||||
|
const gradeId = params.gradeId.trim()
|
||||||
|
if (!gradeId) return null
|
||||||
|
|
||||||
|
const [gradeRow] = await db
|
||||||
|
.select({
|
||||||
|
id: grades.id,
|
||||||
|
name: grades.name,
|
||||||
|
schoolId: schools.id,
|
||||||
|
schoolName: schools.name,
|
||||||
|
})
|
||||||
|
.from(grades)
|
||||||
|
.innerJoin(schools, eq(schools.id, grades.schoolId))
|
||||||
|
.where(eq(grades.id, gradeId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!gradeRow) return null
|
||||||
|
|
||||||
|
const classRows = await db
|
||||||
|
.select({
|
||||||
|
id: classes.id,
|
||||||
|
name: classes.name,
|
||||||
|
grade: classes.grade,
|
||||||
|
homeroom: classes.homeroom,
|
||||||
|
room: classes.room,
|
||||||
|
})
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.gradeId, gradeId))
|
||||||
|
.orderBy(asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
|
|
||||||
|
const classIds = classRows.map((r) => r.id)
|
||||||
|
if (classIds.length === 0) {
|
||||||
|
return {
|
||||||
|
grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } },
|
||||||
|
classCount: 0,
|
||||||
|
studentCounts: { total: 0, active: 0, inactive: 0 },
|
||||||
|
assignments: [],
|
||||||
|
latest: null,
|
||||||
|
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
|
||||||
|
classes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrollmentRows = await db
|
||||||
|
.select({
|
||||||
|
classId: classEnrollments.classId,
|
||||||
|
studentId: classEnrollments.studentId,
|
||||||
|
status: classEnrollments.status,
|
||||||
|
})
|
||||||
|
.from(classEnrollments)
|
||||||
|
.where(inArray(classEnrollments.classId, classIds))
|
||||||
|
|
||||||
|
const studentActiveById = new Map<string, boolean>()
|
||||||
|
const studentsByClassId = new Map<string, { all: Set<string>; active: Set<string> }>()
|
||||||
|
|
||||||
|
for (const e of enrollmentRows) {
|
||||||
|
const prev = studentActiveById.get(e.studentId) ?? false
|
||||||
|
const next = prev || e.status === "active"
|
||||||
|
studentActiveById.set(e.studentId, next)
|
||||||
|
|
||||||
|
const bucket = studentsByClassId.get(e.classId) ?? { all: new Set<string>(), active: new Set<string>() }
|
||||||
|
bucket.all.add(e.studentId)
|
||||||
|
if (e.status === "active") bucket.active.add(e.studentId)
|
||||||
|
studentsByClassId.set(e.classId, bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentIds = Array.from(studentActiveById.keys())
|
||||||
|
const activeCount = Array.from(studentActiveById.values()).filter(Boolean).length
|
||||||
|
const inactiveCount = studentIds.length - activeCount
|
||||||
|
|
||||||
|
if (studentIds.length === 0) {
|
||||||
|
const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => ({
|
||||||
|
class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room },
|
||||||
|
studentCounts: { total: 0, active: 0, inactive: 0 },
|
||||||
|
latestAvg: null,
|
||||||
|
prevAvg: null,
|
||||||
|
deltaAvg: null,
|
||||||
|
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } },
|
||||||
|
classCount: classRows.length,
|
||||||
|
studentCounts: { total: 0, active: 0, inactive: 0 },
|
||||||
|
assignments: [],
|
||||||
|
latest: null,
|
||||||
|
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
|
||||||
|
classes: summaries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignmentIdRows = await db
|
||||||
|
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
|
||||||
|
|
||||||
|
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
|
||||||
|
if (assignmentIds.length === 0) {
|
||||||
|
const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => {
|
||||||
|
const bucket = studentsByClassId.get(c.id) ?? { all: new Set<string>(), active: new Set<string>() }
|
||||||
|
return {
|
||||||
|
class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room },
|
||||||
|
studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size },
|
||||||
|
latestAvg: null,
|
||||||
|
prevAvg: null,
|
||||||
|
deltaAvg: null,
|
||||||
|
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } },
|
||||||
|
classCount: classRows.length,
|
||||||
|
studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount },
|
||||||
|
assignments: [],
|
||||||
|
latest: null,
|
||||||
|
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
|
||||||
|
classes: summaries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
||||||
|
const assignments = await db.query.homeworkAssignments.findMany({
|
||||||
|
where: inArray(homeworkAssignments.id, assignmentIds),
|
||||||
|
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
const usedAssignmentIds = assignments.map((a) => a.id)
|
||||||
|
if (usedAssignmentIds.length === 0) {
|
||||||
|
const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => {
|
||||||
|
const bucket = studentsByClassId.get(c.id) ?? { all: new Set<string>(), active: new Set<string>() }
|
||||||
|
return {
|
||||||
|
class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room },
|
||||||
|
studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size },
|
||||||
|
latestAvg: null,
|
||||||
|
prevAvg: null,
|
||||||
|
deltaAvg: null,
|
||||||
|
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } },
|
||||||
|
classCount: classRows.length,
|
||||||
|
studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount },
|
||||||
|
assignments: [],
|
||||||
|
latest: null,
|
||||||
|
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
|
||||||
|
classes: summaries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxScoreRows = await db
|
||||||
|
.select({
|
||||||
|
assignmentId: homeworkAssignmentQuestions.assignmentId,
|
||||||
|
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
|
||||||
|
})
|
||||||
|
.from(homeworkAssignmentQuestions)
|
||||||
|
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
|
||||||
|
.groupBy(homeworkAssignmentQuestions.assignmentId)
|
||||||
|
|
||||||
|
const maxScoreByAssignmentId = new Map<string, number>()
|
||||||
|
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0))
|
||||||
|
|
||||||
|
const targetCountRows = await db
|
||||||
|
.select({
|
||||||
|
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||||
|
targetCount: sql<number>`COUNT(*)`,
|
||||||
|
})
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds),
|
||||||
|
inArray(homeworkAssignmentTargets.studentId, studentIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(homeworkAssignmentTargets.assignmentId)
|
||||||
|
|
||||||
|
const targetCountByAssignmentId = new Map<string, number>()
|
||||||
|
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
|
||||||
|
|
||||||
|
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(homeworkSubmissions.assignmentId, usedAssignmentIds),
|
||||||
|
inArray(homeworkSubmissions.studentId, studentIds)
|
||||||
|
),
|
||||||
|
orderBy: [desc(homeworkSubmissions.createdAt)],
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestByKey = new Map<string, (typeof submissions)[number]>()
|
||||||
|
for (const s of submissions) {
|
||||||
|
const key = `${s.assignmentId}:${s.studentId}`
|
||||||
|
if (!latestByKey.has(key)) latestByKey.set(key, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allScored: number[] = []
|
||||||
|
const nowMs = Date.now()
|
||||||
|
|
||||||
|
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
|
||||||
|
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
|
||||||
|
let submittedCount = 0
|
||||||
|
let gradedCount = 0
|
||||||
|
const scores: number[] = []
|
||||||
|
const dueMs = a.dueAt ? a.dueAt.getTime() : null
|
||||||
|
|
||||||
|
for (const studentId of studentIds) {
|
||||||
|
const s = latestByKey.get(`${a.id}:${studentId}`)
|
||||||
|
if (!s) continue
|
||||||
|
|
||||||
|
const status = (s.status ?? "started") as string
|
||||||
|
if (status === "submitted" || status === "graded") submittedCount += 1
|
||||||
|
if (status === "graded" || typeof s.score === "number") gradedCount += 1
|
||||||
|
if (typeof s.score === "number") scores.push(s.score)
|
||||||
|
}
|
||||||
|
|
||||||
|
allScored.push(...scores)
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignmentId: a.id,
|
||||||
|
title: a.title,
|
||||||
|
status: (a.status as string) ?? "draft",
|
||||||
|
createdAt: a.createdAt.toISOString(),
|
||||||
|
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||||
|
isActive: dueMs === null || dueMs >= nowMs,
|
||||||
|
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
|
||||||
|
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
|
||||||
|
targetCount,
|
||||||
|
submittedCount,
|
||||||
|
gradedCount,
|
||||||
|
scoreStats: toScoreStats(scores),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const overallScores = toScoreStats(allScored)
|
||||||
|
const latest = stats[0] ?? null
|
||||||
|
const latestAssignmentId = stats[0]?.assignmentId ?? null
|
||||||
|
const prevAssignmentId = stats[1]?.assignmentId ?? null
|
||||||
|
|
||||||
|
const classSummaries: GradeHomeworkClassSummary[] = classRows.map((c) => {
|
||||||
|
const bucket = studentsByClassId.get(c.id) ?? { all: new Set<string>(), active: new Set<string>() }
|
||||||
|
const classStudentIds = Array.from(bucket.all)
|
||||||
|
|
||||||
|
const latestScores: number[] = []
|
||||||
|
const prevScores: number[] = []
|
||||||
|
const overallClassScores: number[] = []
|
||||||
|
|
||||||
|
if (latestAssignmentId) {
|
||||||
|
for (const studentId of classStudentIds) {
|
||||||
|
const s = latestByKey.get(`${latestAssignmentId}:${studentId}`)
|
||||||
|
if (typeof s?.score === "number") latestScores.push(s.score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevAssignmentId) {
|
||||||
|
for (const studentId of classStudentIds) {
|
||||||
|
const s = latestByKey.get(`${prevAssignmentId}:${studentId}`)
|
||||||
|
if (typeof s?.score === "number") prevScores.push(s.score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const assignmentId of usedAssignmentIds) {
|
||||||
|
for (const studentId of classStudentIds) {
|
||||||
|
const s = latestByKey.get(`${assignmentId}:${studentId}`)
|
||||||
|
if (typeof s?.score === "number") overallClassScores.push(s.score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestAvg = avg(latestScores)
|
||||||
|
const prevAvg = avg(prevScores)
|
||||||
|
|
||||||
|
return {
|
||||||
|
class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room },
|
||||||
|
studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size },
|
||||||
|
latestAvg,
|
||||||
|
prevAvg,
|
||||||
|
deltaAvg: typeof latestAvg === "number" && typeof prevAvg === "number" ? latestAvg - prevAvg : null,
|
||||||
|
overallScores: toScoreStats(overallClassScores),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
classSummaries.sort((a, b) => (b.latestAvg ?? -Infinity) - (a.latestAvg ?? -Infinity))
|
||||||
|
|
||||||
|
return {
|
||||||
|
grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } },
|
||||||
|
classCount: classRows.length,
|
||||||
|
studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount },
|
||||||
|
assignments: stats,
|
||||||
|
latest,
|
||||||
|
overallScores,
|
||||||
|
classes: classSummaries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ClassesDashboardStats = {
|
||||||
|
classCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getClassesDashboardStats = cache(async (): Promise<ClassesDashboardStats> => {
|
||||||
|
const [row] = await db.select({ value: count() }).from(classes)
|
||||||
|
return { classCount: Number(row?.value ?? 0) }
|
||||||
|
})
|
||||||
280
src/modules/classes/data-access-students.ts
Normal file
280
src/modules/classes/data-access-students.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
classes,
|
||||||
|
classEnrollments,
|
||||||
|
homeworkAssignmentTargets,
|
||||||
|
homeworkAssignments,
|
||||||
|
homeworkSubmissions,
|
||||||
|
subjects,
|
||||||
|
exams,
|
||||||
|
users,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
import type {
|
||||||
|
ClassStudent,
|
||||||
|
StudentEnrolledClass,
|
||||||
|
} from "./types"
|
||||||
|
import {
|
||||||
|
compareClassLike,
|
||||||
|
getAccessibleClassIdsForTeacher,
|
||||||
|
getSessionTeacherId,
|
||||||
|
getTeacherSubjectIdsForClass,
|
||||||
|
} from "./data-access"
|
||||||
|
|
||||||
|
export const getStudentsSubjectScores = cache(
|
||||||
|
async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => {
|
||||||
|
if (studentIds.length === 0) return new Map()
|
||||||
|
|
||||||
|
// 1. Find assignments targeted at these students
|
||||||
|
const assignmentTargets = await db
|
||||||
|
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
|
||||||
|
|
||||||
|
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
|
||||||
|
if (assignmentIds.length === 0) return new Map()
|
||||||
|
|
||||||
|
// 2. Get assignment details including subject from linked exam
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
id: homeworkAssignments.id,
|
||||||
|
createdAt: homeworkAssignments.createdAt,
|
||||||
|
subjectId: exams.subjectId,
|
||||||
|
subjectName: subjects.name
|
||||||
|
})
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||||
|
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||||
|
.where(and(
|
||||||
|
inArray(homeworkAssignments.id, assignmentIds),
|
||||||
|
eq(homeworkAssignments.status, "published")
|
||||||
|
))
|
||||||
|
.orderBy(desc(homeworkAssignments.createdAt))
|
||||||
|
|
||||||
|
// 3. Filter subjects (exclude PE, Music, Art)
|
||||||
|
const excludeSubjects = ["体育", "音乐", "美术"]
|
||||||
|
const subjectAssignments = new Map<string, string>() // subject -> assignmentId (latest)
|
||||||
|
|
||||||
|
for (const a of assignments) {
|
||||||
|
if (!a.subjectName) continue
|
||||||
|
if (excludeSubjects.includes(a.subjectName)) continue
|
||||||
|
if (!subjectAssignments.has(a.subjectName)) {
|
||||||
|
subjectAssignments.set(a.subjectName, a.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAssignmentIds = Array.from(subjectAssignments.values())
|
||||||
|
if (targetAssignmentIds.length === 0) return new Map()
|
||||||
|
|
||||||
|
// 4. Get submissions for these assignments
|
||||||
|
const submissions = await db
|
||||||
|
.select({
|
||||||
|
studentId: homeworkSubmissions.studentId,
|
||||||
|
assignmentId: homeworkSubmissions.assignmentId,
|
||||||
|
score: homeworkSubmissions.score,
|
||||||
|
createdAt: homeworkSubmissions.createdAt,
|
||||||
|
})
|
||||||
|
.from(homeworkSubmissions)
|
||||||
|
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
|
||||||
|
.orderBy(desc(homeworkSubmissions.createdAt))
|
||||||
|
|
||||||
|
// 5. Map back to subject scores per student
|
||||||
|
const studentScores = new Map<string, Record<string, number | null>>()
|
||||||
|
|
||||||
|
// Create reverse map for assignment -> subject
|
||||||
|
const assignmentSubjectMap = new Map<string, string>()
|
||||||
|
for (const [subject, id] of subjectAssignments.entries()) {
|
||||||
|
assignmentSubjectMap.set(id, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of submissions) {
|
||||||
|
const subject = assignmentSubjectMap.get(s.assignmentId)
|
||||||
|
if (!subject) continue
|
||||||
|
|
||||||
|
if (!studentScores.has(s.studentId)) {
|
||||||
|
studentScores.set(s.studentId, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores = studentScores.get(s.studentId)!
|
||||||
|
// Only set if not already set (since we ordered by desc createdAt, first one is latest)
|
||||||
|
if (scores[subject] === undefined) {
|
||||||
|
scores[subject] = s.score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return studentScores
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const getClassStudentSubjectScoresV2 = cache(
|
||||||
|
async (params: { classId: string; teacherId?: string }): Promise<Map<string, Record<string, number | null>>> => {
|
||||||
|
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)
|
||||||
|
.where(and(
|
||||||
|
eq(classEnrollments.classId, classId),
|
||||||
|
eq(classEnrollments.status, "active")
|
||||||
|
))
|
||||||
|
|
||||||
|
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<string, Record<string, number | null>>()
|
||||||
|
for (const [studentId, scores] of studentScores.entries()) {
|
||||||
|
const nextScores: Record<string, number | null> = {}
|
||||||
|
for (const [subject, score] of Object.entries(scores)) {
|
||||||
|
if (allowed.has(subject)) nextScores[subject] = score
|
||||||
|
}
|
||||||
|
filtered.set(studentId, nextScores)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => {
|
||||||
|
const id = studentId.trim()
|
||||||
|
if (!id) return []
|
||||||
|
|
||||||
|
const rows = await (async () => {
|
||||||
|
try {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: classes.id,
|
||||||
|
schoolName: classes.schoolName,
|
||||||
|
name: classes.name,
|
||||||
|
grade: classes.grade,
|
||||||
|
homeroom: classes.homeroom,
|
||||||
|
room: classes.room,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
|
})
|
||||||
|
.from(classEnrollments)
|
||||||
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
|
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||||
|
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||||
|
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
|
} catch {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: classes.id,
|
||||||
|
schoolName: sql<string | null>`NULL`.as("schoolName"),
|
||||||
|
name: classes.name,
|
||||||
|
grade: classes.grade,
|
||||||
|
homeroom: classes.homeroom,
|
||||||
|
room: classes.room,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
|
})
|
||||||
|
.from(classEnrollments)
|
||||||
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
|
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||||
|
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||||
|
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const list = rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
schoolName: r.schoolName,
|
||||||
|
name: r.name,
|
||||||
|
grade: r.grade,
|
||||||
|
homeroom: r.homeroom,
|
||||||
|
room: r.room,
|
||||||
|
teacherName: r.teacherName,
|
||||||
|
teacherEmail: r.teacherEmail,
|
||||||
|
}))
|
||||||
|
|
||||||
|
list.sort(compareClassLike)
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getClassStudents = cache(
|
||||||
|
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
|
||||||
|
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 accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||||
|
if (accessibleIds.length === 0) return []
|
||||||
|
|
||||||
|
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
|
||||||
|
|
||||||
|
if (classId) {
|
||||||
|
conditions.push(eq(classes.id, classId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "active" || status === "inactive") {
|
||||||
|
conditions.push(eq(classEnrollments.status, status))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q && q.length > 0) {
|
||||||
|
const needle = `%${q}%`
|
||||||
|
conditions.push(
|
||||||
|
sql`(LOWER(COALESCE(${users.name}, '')) LIKE ${needle} OR LOWER(${users.email}) LIKE ${needle})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
email: users.email,
|
||||||
|
image: users.image,
|
||||||
|
gender: users.gender,
|
||||||
|
classId: classes.id,
|
||||||
|
className: classes.name,
|
||||||
|
status: classEnrollments.status,
|
||||||
|
joinedAt: classEnrollments.createdAt,
|
||||||
|
})
|
||||||
|
.from(classEnrollments)
|
||||||
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
|
.innerJoin(users, eq(users.id, classEnrollments.studentId))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(users.name), asc(users.email))
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name ?? "Unnamed",
|
||||||
|
email: r.email,
|
||||||
|
image: r.image,
|
||||||
|
gender: r.gender,
|
||||||
|
classId: r.classId,
|
||||||
|
className: r.className,
|
||||||
|
status: r.status,
|
||||||
|
joinedAt: r.joinedAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,205 +1,47 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { count, desc, eq, gt, inArray, and } from "drizzle-orm"
|
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { getClassesDashboardStats } from "@/modules/classes/data-access"
|
||||||
import {
|
import { getExamsDashboardStats } from "@/modules/exams/data-access"
|
||||||
chapters,
|
import { getHomeworkDashboardStats } from "@/modules/homework/stats-service"
|
||||||
classes,
|
import { getQuestionsDashboardStats } from "@/modules/questions/data-access"
|
||||||
exams,
|
import { getTextbooksDashboardStats } from "@/modules/textbooks/data-access"
|
||||||
homeworkAssignments,
|
import { getUsersDashboardStats } from "@/modules/users/data-access"
|
||||||
homeworkSubmissions,
|
|
||||||
questions,
|
|
||||||
roles,
|
|
||||||
sessions,
|
|
||||||
textbooks,
|
|
||||||
users,
|
|
||||||
usersToRoles,
|
|
||||||
} from "@/shared/db/schema"
|
|
||||||
import type { AdminDashboardData } from "./types"
|
|
||||||
import type { DataScope } from "@/shared/types/permissions"
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
|
import type { AdminDashboardData } from "./types"
|
||||||
|
|
||||||
export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<AdminDashboardData> => {
|
export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<AdminDashboardData> => {
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
// Build scope-based conditions for exams
|
|
||||||
const examConditions = []
|
|
||||||
const homeworkConditions = []
|
|
||||||
const submissionConditions = []
|
|
||||||
|
|
||||||
if (scope && scope.type !== "all") {
|
|
||||||
if (scope.type === "owned") {
|
|
||||||
examConditions.push(eq(exams.creatorId, scope.userId))
|
|
||||||
homeworkConditions.push(eq(homeworkAssignments.creatorId, scope.userId))
|
|
||||||
const ownedAssignmentIds = db
|
|
||||||
.select({ id: homeworkAssignments.id })
|
|
||||||
.from(homeworkAssignments)
|
|
||||||
.where(eq(homeworkAssignments.creatorId, scope.userId))
|
|
||||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, ownedAssignmentIds))
|
|
||||||
}
|
|
||||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
|
||||||
examConditions.push(inArray(exams.gradeId, scope.gradeIds))
|
|
||||||
const gradeExamIds = db
|
|
||||||
.select({ id: exams.id })
|
|
||||||
.from(exams)
|
|
||||||
.where(inArray(exams.gradeId, scope.gradeIds))
|
|
||||||
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
|
||||||
const gradeAssignmentIds = db
|
|
||||||
.select({ id: homeworkAssignments.id })
|
|
||||||
.from(homeworkAssignments)
|
|
||||||
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
|
||||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
|
||||||
}
|
|
||||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
|
||||||
const teacherGradeIds = await db
|
|
||||||
.selectDistinct({ gradeId: classes.gradeId })
|
|
||||||
.from(classes)
|
|
||||||
.where(inArray(classes.id, scope.classIds))
|
|
||||||
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
|
|
||||||
if (gradeIds.length > 0) {
|
|
||||||
examConditions.push(inArray(exams.gradeId, gradeIds))
|
|
||||||
const gradeExamIds = db
|
|
||||||
.select({ id: exams.id })
|
|
||||||
.from(exams)
|
|
||||||
.where(inArray(exams.gradeId, gradeIds))
|
|
||||||
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
|
||||||
const gradeAssignmentIds = db
|
|
||||||
.select({ id: homeworkAssignments.id })
|
|
||||||
.from(homeworkAssignments)
|
|
||||||
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
|
||||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [
|
const [
|
||||||
activeSessionsRow,
|
usersStats,
|
||||||
userCountRow,
|
classesStats,
|
||||||
userRoleCountRows,
|
textbooksStats,
|
||||||
classCountRow,
|
questionsStats,
|
||||||
textbookCountRow,
|
examsStats,
|
||||||
chapterCountRow,
|
homeworkStats,
|
||||||
questionCountRow,
|
|
||||||
examCountRow,
|
|
||||||
homeworkAssignmentCountRow,
|
|
||||||
homeworkAssignmentPublishedCountRow,
|
|
||||||
homeworkSubmissionCountRow,
|
|
||||||
homeworkSubmissionToGradeCountRow,
|
|
||||||
recentUserRows,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
|
getUsersDashboardStats(),
|
||||||
db.select({ value: count() }).from(users),
|
getClassesDashboardStats(),
|
||||||
db
|
getTextbooksDashboardStats(),
|
||||||
.select({ role: roles.name, value: count() })
|
getQuestionsDashboardStats(),
|
||||||
.from(usersToRoles)
|
getExamsDashboardStats(scope),
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
getHomeworkDashboardStats(scope),
|
||||||
.groupBy(roles.name),
|
|
||||||
db.select({ value: count() }).from(classes),
|
|
||||||
db.select({ value: count() }).from(textbooks),
|
|
||||||
db.select({ value: count() }).from(chapters),
|
|
||||||
db.select({ value: count() }).from(questions),
|
|
||||||
db.select({ value: count() }).from(exams).where(examConditions.length ? and(...examConditions) : undefined),
|
|
||||||
db.select({ value: count() }).from(homeworkAssignments).where(homeworkConditions.length ? and(...homeworkConditions) : undefined),
|
|
||||||
db.select({ value: count() }).from(homeworkAssignments).where(
|
|
||||||
homeworkConditions.length
|
|
||||||
? and(eq(homeworkAssignments.status, "published"), ...homeworkConditions)
|
|
||||||
: eq(homeworkAssignments.status, "published")
|
|
||||||
),
|
|
||||||
db.select({ value: count() }).from(homeworkSubmissions).where(submissionConditions.length ? and(...submissionConditions) : undefined),
|
|
||||||
db.select({ value: count() }).from(homeworkSubmissions).where(
|
|
||||||
submissionConditions.length
|
|
||||||
? and(eq(homeworkSubmissions.status, "submitted"), ...submissionConditions)
|
|
||||||
: eq(homeworkSubmissions.status, "submitted")
|
|
||||||
),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
id: users.id,
|
|
||||||
name: users.name,
|
|
||||||
email: users.email,
|
|
||||||
createdAt: users.createdAt,
|
|
||||||
})
|
|
||||||
.from(users)
|
|
||||||
.orderBy(desc(users.createdAt))
|
|
||||||
.limit(8),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const activeSessionsCount = Number(activeSessionsRow[0]?.value ?? 0)
|
|
||||||
const userCount = Number(userCountRow[0]?.value ?? 0)
|
|
||||||
const classCount = Number(classCountRow[0]?.value ?? 0)
|
|
||||||
const textbookCount = Number(textbookCountRow[0]?.value ?? 0)
|
|
||||||
const chapterCount = Number(chapterCountRow[0]?.value ?? 0)
|
|
||||||
const questionCount = Number(questionCountRow[0]?.value ?? 0)
|
|
||||||
const examCount = Number(examCountRow[0]?.value ?? 0)
|
|
||||||
const homeworkAssignmentCount = Number(homeworkAssignmentCountRow[0]?.value ?? 0)
|
|
||||||
const homeworkAssignmentPublishedCount = Number(homeworkAssignmentPublishedCountRow[0]?.value ?? 0)
|
|
||||||
const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0)
|
|
||||||
const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0)
|
|
||||||
|
|
||||||
const userRoleCounts = userRoleCountRows
|
|
||||||
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
|
|
||||||
.sort((a, b) => b.count - a.count)
|
|
||||||
|
|
||||||
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<string, string[]>()
|
|
||||||
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 {
|
return {
|
||||||
activeSessionsCount,
|
activeSessionsCount: usersStats.activeSessionsCount,
|
||||||
userCount,
|
userCount: usersStats.userCount,
|
||||||
userRoleCounts,
|
userRoleCounts: usersStats.userRoleCounts,
|
||||||
classCount,
|
classCount: classesStats.classCount,
|
||||||
textbookCount,
|
textbookCount: textbooksStats.textbookCount,
|
||||||
chapterCount,
|
chapterCount: textbooksStats.chapterCount,
|
||||||
questionCount,
|
questionCount: questionsStats.questionCount,
|
||||||
examCount,
|
examCount: examsStats.examCount,
|
||||||
homeworkAssignmentCount,
|
homeworkAssignmentCount: homeworkStats.homeworkAssignmentCount,
|
||||||
homeworkAssignmentPublishedCount,
|
homeworkAssignmentPublishedCount: homeworkStats.homeworkAssignmentPublishedCount,
|
||||||
homeworkSubmissionCount,
|
homeworkSubmissionCount: homeworkStats.homeworkSubmissionCount,
|
||||||
homeworkSubmissionToGradeCount,
|
homeworkSubmissionToGradeCount: homeworkStats.homeworkSubmissionToGradeCount,
|
||||||
recentUsers,
|
recentUsers: usersStats.recentUsers,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { exams, examQuestions, questions, subjects, grades, classes } from "@/shared/db/schema"
|
import { exams, examQuestions, questions, subjects, grades, classes } from "@/shared/db/schema"
|
||||||
import { eq, desc, like, and, or, inArray } from "drizzle-orm"
|
import { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
|
|
||||||
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||||
@@ -337,3 +337,37 @@ export const persistAiGeneratedExamDraft = async (input: {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExamsDashboardStats = {
|
||||||
|
examCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<ExamsDashboardStats> => {
|
||||||
|
const conditions = []
|
||||||
|
|
||||||
|
if (scope && scope.type !== "all") {
|
||||||
|
if (scope.type === "owned") {
|
||||||
|
conditions.push(eq(exams.creatorId, scope.userId))
|
||||||
|
}
|
||||||
|
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||||
|
conditions.push(inArray(exams.gradeId, scope.gradeIds))
|
||||||
|
}
|
||||||
|
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||||
|
const teacherGradeIds = await db
|
||||||
|
.selectDistinct({ gradeId: classes.gradeId })
|
||||||
|
.from(classes)
|
||||||
|
.where(inArray(classes.id, scope.classIds))
|
||||||
|
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
|
||||||
|
if (gradeIds.length > 0) {
|
||||||
|
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(exams)
|
||||||
|
.where(conditions.length ? and(...conditions) : undefined)
|
||||||
|
|
||||||
|
return { examCount: Number(row?.value ?? 0) }
|
||||||
|
})
|
||||||
|
|||||||
@@ -26,86 +26,20 @@ import type {
|
|||||||
HomeworkAssignmentStatus,
|
HomeworkAssignmentStatus,
|
||||||
HomeworkSubmissionDetails,
|
HomeworkSubmissionDetails,
|
||||||
HomeworkSubmissionListItem,
|
HomeworkSubmissionListItem,
|
||||||
HomeworkAssignmentAnalytics,
|
|
||||||
HomeworkAssignmentQuestionAnalytics,
|
|
||||||
StudentHomeworkAssignmentListItem,
|
StudentHomeworkAssignmentListItem,
|
||||||
StudentHomeworkProgressStatus,
|
StudentHomeworkProgressStatus,
|
||||||
StudentHomeworkTakeData,
|
StudentHomeworkTakeData,
|
||||||
StudentDashboardGradeProps,
|
|
||||||
StudentHomeworkScoreAnalytics,
|
|
||||||
StudentRanking,
|
|
||||||
TeacherGradeTrendItem,
|
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import type { DataScope } from "@/shared/types/permissions"
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
|
export const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||||
const recentAssignments = await db.query.homeworkAssignments.findMany({
|
|
||||||
where: and(
|
|
||||||
eq(homeworkAssignments.creatorId, teacherId),
|
|
||||||
or(eq(homeworkAssignments.status, "published"), eq(homeworkAssignments.status, "archived"))
|
|
||||||
),
|
|
||||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
|
||||||
limit: limit,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (recentAssignments.length === 0) return []
|
export const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
|
||||||
|
|
||||||
const assignmentIds = recentAssignments.map((a) => a.id)
|
|
||||||
|
|
||||||
const [maxScoreMap, targetCountRows, submissionStats] = await Promise.all([
|
|
||||||
getAssignmentMaxScoreById(assignmentIds),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
assignmentId: homeworkAssignmentTargets.assignmentId,
|
|
||||||
count: count(homeworkAssignmentTargets.studentId),
|
|
||||||
})
|
|
||||||
.from(homeworkAssignmentTargets)
|
|
||||||
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
|
||||||
.groupBy(homeworkAssignmentTargets.assignmentId),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
assignmentId: homeworkSubmissions.assignmentId,
|
|
||||||
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
|
|
||||||
count: count(homeworkSubmissions.id),
|
|
||||||
})
|
|
||||||
.from(homeworkSubmissions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
|
||||||
eq(homeworkSubmissions.status, "graded")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.groupBy(homeworkSubmissions.assignmentId),
|
|
||||||
])
|
|
||||||
|
|
||||||
const targetCountMap = new Map<string, number>()
|
|
||||||
for (const r of targetCountRows) targetCountMap.set(r.assignmentId, r.count)
|
|
||||||
|
|
||||||
const statsMap = new Map<string, { avg: number; count: number }>()
|
|
||||||
for (const r of submissionStats) statsMap.set(r.assignmentId, { avg: Number(r.avgScore), count: Number(r.count) })
|
|
||||||
|
|
||||||
return recentAssignments.map((a) => {
|
|
||||||
const stats = statsMap.get(a.id) ?? { avg: 0, count: 0 }
|
|
||||||
return {
|
|
||||||
id: a.id,
|
|
||||||
title: a.title,
|
|
||||||
averageScore: stats.avg,
|
|
||||||
maxScore: maxScoreMap.get(a.id) ?? 0,
|
|
||||||
submissionCount: stats.count,
|
|
||||||
totalStudents: targetCountMap.get(a.id) ?? 0,
|
|
||||||
createdAt: a.createdAt.toISOString(),
|
|
||||||
}
|
|
||||||
}).reverse() // Reverse to show trend from left (older) to right (newer)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
|
||||||
|
|
||||||
const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
|
|
||||||
if (!isRecord(v)) return null
|
if (!isRecord(v)) return null
|
||||||
return v as HomeworkQuestionContent
|
return v as HomeworkQuestionContent
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => {
|
export const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => {
|
||||||
const ids = assignmentIds.filter((v) => v.trim().length > 0)
|
const ids = assignmentIds.filter((v) => v.trim().length > 0)
|
||||||
if (ids.length === 0) return new Map()
|
if (ids.length === 0) return new Map()
|
||||||
|
|
||||||
@@ -473,152 +407,6 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getHomeworkAssignmentAnalytics = cache(
|
|
||||||
async (assignmentId: string): Promise<HomeworkAssignmentAnalytics | null> => {
|
|
||||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
|
||||||
where: eq(homeworkAssignments.id, assignmentId),
|
|
||||||
with: {
|
|
||||||
sourceExam: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!assignment) return null
|
|
||||||
|
|
||||||
const [targetsRow] = await db
|
|
||||||
.select({ c: count() })
|
|
||||||
.from(homeworkAssignmentTargets)
|
|
||||||
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
|
|
||||||
|
|
||||||
const [submissionsRow] = await db
|
|
||||||
.select({ c: count() })
|
|
||||||
.from(homeworkSubmissions)
|
|
||||||
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
|
|
||||||
|
|
||||||
const [submittedRow] = await db
|
|
||||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
|
||||||
.from(homeworkSubmissions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
|
||||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const [gradedRow] = await db
|
|
||||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
|
||||||
.from(homeworkSubmissions)
|
|
||||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
|
|
||||||
|
|
||||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
|
||||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
|
||||||
with: { question: true },
|
|
||||||
orderBy: (q, { asc }) => [asc(q.order)],
|
|
||||||
})
|
|
||||||
|
|
||||||
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
|
|
||||||
|
|
||||||
for (const aq of assignmentQuestions) {
|
|
||||||
statsByQuestionId.set(aq.questionId, {
|
|
||||||
questionId: aq.questionId,
|
|
||||||
questionType: aq.question.type,
|
|
||||||
questionContent: toQuestionContent(aq.question.content),
|
|
||||||
maxScore: aq.score ?? 0,
|
|
||||||
order: aq.order ?? 0,
|
|
||||||
errorCount: 0,
|
|
||||||
errorRate: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const gradedSubmissionsAll = await db.query.homeworkSubmissions.findMany({
|
|
||||||
where: and(
|
|
||||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
|
||||||
eq(homeworkSubmissions.status, "graded")
|
|
||||||
),
|
|
||||||
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
|
||||||
with: {
|
|
||||||
answers: true,
|
|
||||||
student: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const latestByStudentId = new Map<string, (typeof gradedSubmissionsAll)[number]>()
|
|
||||||
for (const s of gradedSubmissionsAll) {
|
|
||||||
if (!latestByStudentId.has(s.studentId)) latestByStudentId.set(s.studentId, s)
|
|
||||||
}
|
|
||||||
const gradedSubmissions = Array.from(latestByStudentId.values())
|
|
||||||
|
|
||||||
const scoreBySubmissionQuestion = new Map<string, number>()
|
|
||||||
const answerBySubmissionQuestion = new Map<string, unknown>()
|
|
||||||
for (const sub of gradedSubmissions) {
|
|
||||||
for (const ans of sub.answers) {
|
|
||||||
const key = `${sub.id}|${ans.questionId}`
|
|
||||||
if (scoreBySubmissionQuestion.has(key)) continue
|
|
||||||
scoreBySubmissionQuestion.set(key, ans.score ?? 0)
|
|
||||||
const raw = ans.answerContent
|
|
||||||
if (isRecord(raw) && "answer" in raw) {
|
|
||||||
answerBySubmissionQuestion.set(key, raw.answer)
|
|
||||||
} else {
|
|
||||||
answerBySubmissionQuestion.set(key, raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const denom = gradedSubmissions.length
|
|
||||||
if (denom > 0) {
|
|
||||||
for (const q of statsByQuestionId.values()) {
|
|
||||||
if (q.maxScore <= 0) continue
|
|
||||||
let errors = 0
|
|
||||||
const wrongAnswers: Array<{ studentId: string; studentName: string; answerContent: unknown }> = []
|
|
||||||
for (const sub of gradedSubmissions) {
|
|
||||||
const key = `${sub.id}|${q.questionId}`
|
|
||||||
const score = scoreBySubmissionQuestion.get(key) ?? 0
|
|
||||||
if (score < q.maxScore) {
|
|
||||||
errors += 1
|
|
||||||
wrongAnswers.push({
|
|
||||||
studentId: sub.studentId,
|
|
||||||
studentName: sub.student.name || "Unknown",
|
|
||||||
answerContent: answerBySubmissionQuestion.get(key),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
q.errorCount = errors
|
|
||||||
q.errorRate = errors / denom
|
|
||||||
q.wrongAnswers = wrongAnswers.slice(0, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const questions: HomeworkAssignmentQuestionAnalytics[] = Array.from(statsByQuestionId.values())
|
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
|
|
||||||
const analytics: HomeworkAssignmentAnalytics = {
|
|
||||||
assignment: {
|
|
||||||
id: assignment.id,
|
|
||||||
title: assignment.title,
|
|
||||||
description: assignment.description,
|
|
||||||
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
|
|
||||||
sourceExamId: assignment.sourceExamId,
|
|
||||||
sourceExamTitle: assignment.sourceExam.title,
|
|
||||||
structure: assignment.structure as unknown,
|
|
||||||
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
|
||||||
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
|
||||||
allowLate: assignment.allowLate,
|
|
||||||
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
|
||||||
maxAttempts: assignment.maxAttempts,
|
|
||||||
targetCount: targetsRow?.c ?? 0,
|
|
||||||
submissionCount: submissionsRow?.c ?? 0,
|
|
||||||
submittedCount: submittedRow?.c ?? 0,
|
|
||||||
gradedCount: gradedRow?.c ?? 0,
|
|
||||||
createdAt: assignment.createdAt.toISOString(),
|
|
||||||
updatedAt: assignment.updatedAt.toISOString(),
|
|
||||||
},
|
|
||||||
gradedSampleCount: gradedSubmissions.length,
|
|
||||||
questions,
|
|
||||||
}
|
|
||||||
|
|
||||||
return analytics
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
|
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
|
||||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||||
where: eq(homeworkSubmissions.id, submissionId),
|
where: eq(homeworkSubmissions.id, submissionId),
|
||||||
@@ -882,157 +670,12 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
|
// Re-export stats functions for backward compatibility
|
||||||
const id = studentId.trim()
|
// New code should import directly from "./stats-service"
|
||||||
if (!id) return { trend: [], recent: [], ranking: null }
|
export {
|
||||||
|
getTeacherGradeTrends,
|
||||||
const targetAssignmentIdsRows = await db
|
getHomeworkAssignmentAnalytics,
|
||||||
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
getStudentDashboardGrades,
|
||||||
.from(homeworkAssignmentTargets)
|
getHomeworkDashboardStats,
|
||||||
.where(eq(homeworkAssignmentTargets.studentId, id))
|
} from "./stats-service"
|
||||||
|
export type { HomeworkDashboardStats } from "./stats-service"
|
||||||
const targetAssignmentIds = Array.from(new Set(targetAssignmentIdsRows.map((r) => r.assignmentId)))
|
|
||||||
if (targetAssignmentIds.length === 0) return { trend: [], recent: [], ranking: null }
|
|
||||||
|
|
||||||
const gradedSubmissions = await db.query.homeworkSubmissions.findMany({
|
|
||||||
where: and(
|
|
||||||
eq(homeworkSubmissions.studentId, id),
|
|
||||||
inArray(homeworkSubmissions.assignmentId, targetAssignmentIds),
|
|
||||||
eq(homeworkSubmissions.status, "graded")
|
|
||||||
),
|
|
||||||
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
|
||||||
limit: 200,
|
|
||||||
})
|
|
||||||
|
|
||||||
const latestByAssignmentId = new Map<string, (typeof gradedSubmissions)[number]>()
|
|
||||||
for (const s of gradedSubmissions) {
|
|
||||||
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
const unique = Array.from(latestByAssignmentId.values()).sort((a, b) => {
|
|
||||||
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
|
|
||||||
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
|
|
||||||
return aTime - bTime
|
|
||||||
})
|
|
||||||
|
|
||||||
const trendSubmissions = unique.slice(-10)
|
|
||||||
const recentSubmissions = [...trendSubmissions].sort((a, b) => {
|
|
||||||
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
|
|
||||||
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
|
|
||||||
return bTime - aTime
|
|
||||||
})
|
|
||||||
|
|
||||||
const assignmentIds = Array.from(new Set(trendSubmissions.map((s) => s.assignmentId)))
|
|
||||||
const assignments = await db.query.homeworkAssignments.findMany({
|
|
||||||
where: inArray(homeworkAssignments.id, assignmentIds),
|
|
||||||
})
|
|
||||||
const titleByAssignmentId = new Map(assignments.map((a) => [a.id, a.title] as const))
|
|
||||||
const maxScoreByAssignmentId = await getAssignmentMaxScoreById(assignmentIds)
|
|
||||||
|
|
||||||
const toAnalytics = (s: (typeof trendSubmissions)[number]): StudentHomeworkScoreAnalytics => {
|
|
||||||
const maxScore = maxScoreByAssignmentId.get(s.assignmentId) ?? 0
|
|
||||||
const score = s.score ?? 0
|
|
||||||
const percentage = maxScore > 0 ? (score / maxScore) * 100 : 0
|
|
||||||
return {
|
|
||||||
assignmentId: s.assignmentId,
|
|
||||||
assignmentTitle: titleByAssignmentId.get(s.assignmentId) ?? "Untitled",
|
|
||||||
score,
|
|
||||||
maxScore,
|
|
||||||
percentage,
|
|
||||||
submittedAt: (s.submittedAt ?? s.updatedAt).toISOString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const trend = trendSubmissions.map(toAnalytics)
|
|
||||||
const recent = recentSubmissions.map(toAnalytics).slice(0, 5)
|
|
||||||
|
|
||||||
const enrollment = await db.query.classEnrollments.findFirst({
|
|
||||||
where: and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")),
|
|
||||||
orderBy: (e, { asc }) => [asc(e.createdAt)],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!enrollment) return { trend, recent, ranking: null }
|
|
||||||
|
|
||||||
const classStudents = await db
|
|
||||||
.select({ studentId: classEnrollments.studentId })
|
|
||||||
.from(classEnrollments)
|
|
||||||
.where(and(eq(classEnrollments.classId, enrollment.classId), eq(classEnrollments.status, "active")))
|
|
||||||
|
|
||||||
const classStudentIds = Array.from(new Set(classStudents.map((r) => r.studentId)))
|
|
||||||
const classSize = classStudentIds.length
|
|
||||||
if (classSize === 0) return { trend, recent, ranking: null }
|
|
||||||
|
|
||||||
const classAssignmentIdsRows = await db
|
|
||||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
|
||||||
.from(homeworkAssignmentTargets)
|
|
||||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
|
||||||
|
|
||||||
const classAssignmentIds = Array.from(new Set(classAssignmentIdsRows.map((r) => r.assignmentId)))
|
|
||||||
if (classAssignmentIds.length === 0) return { trend, recent, ranking: null }
|
|
||||||
|
|
||||||
const classMaxScoreByAssignmentId = await getAssignmentMaxScoreById(classAssignmentIds)
|
|
||||||
|
|
||||||
const classGradedSubmissions = await db.query.homeworkSubmissions.findMany({
|
|
||||||
where: and(
|
|
||||||
inArray(homeworkSubmissions.studentId, classStudentIds),
|
|
||||||
inArray(homeworkSubmissions.assignmentId, classAssignmentIds),
|
|
||||||
eq(homeworkSubmissions.status, "graded")
|
|
||||||
),
|
|
||||||
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
|
||||||
limit: 5000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const latestByStudentAssignment = new Map<string, (typeof classGradedSubmissions)[number]>()
|
|
||||||
for (const s of classGradedSubmissions) {
|
|
||||||
const key = `${s.studentId}|${s.assignmentId}`
|
|
||||||
if (!latestByStudentAssignment.has(key)) latestByStudentAssignment.set(key, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalsByStudentId = new Map<string, { score: number; maxScore: number }>()
|
|
||||||
for (const sub of latestByStudentAssignment.values()) {
|
|
||||||
const maxScore = classMaxScoreByAssignmentId.get(sub.assignmentId) ?? 0
|
|
||||||
const score = sub.score ?? 0
|
|
||||||
const prev = totalsByStudentId.get(sub.studentId) ?? { score: 0, maxScore: 0 }
|
|
||||||
totalsByStudentId.set(sub.studentId, {
|
|
||||||
score: prev.score + score,
|
|
||||||
maxScore: prev.maxScore + maxScore,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const classUsers = await db
|
|
||||||
.select({ id: users.id, name: users.name })
|
|
||||||
.from(users)
|
|
||||||
.where(inArray(users.id, classStudentIds))
|
|
||||||
|
|
||||||
const nameByStudentId = new Map(classUsers.map((u) => [u.id, u.name ?? "Student"] as const))
|
|
||||||
const myName = nameByStudentId.get(id) ?? "Student"
|
|
||||||
|
|
||||||
const ranked = classStudentIds
|
|
||||||
.map((studentId) => {
|
|
||||||
const totals = totalsByStudentId.get(studentId) ?? { score: 0, maxScore: 0 }
|
|
||||||
const percentage = totals.maxScore > 0 ? (totals.score / totals.maxScore) * 100 : 0
|
|
||||||
return { studentId, percentage, totals }
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (b.percentage !== a.percentage) return b.percentage - a.percentage
|
|
||||||
return a.studentId.localeCompare(b.studentId)
|
|
||||||
})
|
|
||||||
|
|
||||||
const myIndex = ranked.findIndex((r) => r.studentId === id)
|
|
||||||
if (myIndex < 0) return { trend, recent, ranking: null }
|
|
||||||
|
|
||||||
const myTotals = ranked[myIndex]?.totals ?? { score: 0, maxScore: 0 }
|
|
||||||
const myPercentage = myTotals.maxScore > 0 ? (myTotals.score / myTotals.maxScore) * 100 : 0
|
|
||||||
|
|
||||||
const ranking: StudentRanking = {
|
|
||||||
studentId: id,
|
|
||||||
studentName: myName,
|
|
||||||
rank: myIndex + 1,
|
|
||||||
classSize,
|
|
||||||
totalScore: myTotals.score,
|
|
||||||
totalMaxScore: myTotals.maxScore,
|
|
||||||
percentage: myPercentage,
|
|
||||||
}
|
|
||||||
|
|
||||||
return { trend, recent, ranking }
|
|
||||||
})
|
|
||||||
|
|||||||
483
src/modules/homework/stats-service.ts
Normal file
483
src/modules/homework/stats-service.ts
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { and, count, desc, eq, inArray, or, sql } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
classEnrollments,
|
||||||
|
classes,
|
||||||
|
exams,
|
||||||
|
homeworkAssignmentQuestions,
|
||||||
|
homeworkAssignmentTargets,
|
||||||
|
homeworkAssignments,
|
||||||
|
homeworkSubmissions,
|
||||||
|
users,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HomeworkAssignmentAnalytics,
|
||||||
|
HomeworkAssignmentQuestionAnalytics,
|
||||||
|
HomeworkAssignmentStatus,
|
||||||
|
StudentDashboardGradeProps,
|
||||||
|
StudentHomeworkScoreAnalytics,
|
||||||
|
StudentRanking,
|
||||||
|
TeacherGradeTrendItem,
|
||||||
|
} from "./types"
|
||||||
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
import { getAssignmentMaxScoreById, isRecord, toQuestionContent } from "./data-access"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get grade trend data for a teacher's recent assignments.
|
||||||
|
* Used by the teacher dashboard to visualize class performance over time.
|
||||||
|
*/
|
||||||
|
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
|
||||||
|
const recentAssignments = await db.query.homeworkAssignments.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(homeworkAssignments.creatorId, teacherId),
|
||||||
|
or(eq(homeworkAssignments.status, "published"), eq(homeworkAssignments.status, "archived"))
|
||||||
|
),
|
||||||
|
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||||
|
limit: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (recentAssignments.length === 0) return []
|
||||||
|
|
||||||
|
const assignmentIds = recentAssignments.map((a) => a.id)
|
||||||
|
|
||||||
|
const [maxScoreMap, targetCountRows, submissionStats] = await Promise.all([
|
||||||
|
getAssignmentMaxScoreById(assignmentIds),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||||
|
count: count(homeworkAssignmentTargets.studentId),
|
||||||
|
})
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
||||||
|
.groupBy(homeworkAssignmentTargets.assignmentId),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
assignmentId: homeworkSubmissions.assignmentId,
|
||||||
|
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
|
||||||
|
count: count(homeworkSubmissions.id),
|
||||||
|
})
|
||||||
|
.from(homeworkSubmissions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||||
|
eq(homeworkSubmissions.status, "graded")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(homeworkSubmissions.assignmentId),
|
||||||
|
])
|
||||||
|
|
||||||
|
const targetCountMap = new Map<string, number>()
|
||||||
|
for (const r of targetCountRows) targetCountMap.set(r.assignmentId, r.count)
|
||||||
|
|
||||||
|
const statsMap = new Map<string, { avg: number; count: number }>()
|
||||||
|
for (const r of submissionStats) statsMap.set(r.assignmentId, { avg: Number(r.avgScore), count: Number(r.count) })
|
||||||
|
|
||||||
|
return recentAssignments.map((a) => {
|
||||||
|
const stats = statsMap.get(a.id) ?? { avg: 0, count: 0 }
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
title: a.title,
|
||||||
|
averageScore: stats.avg,
|
||||||
|
maxScore: maxScoreMap.get(a.id) ?? 0,
|
||||||
|
submissionCount: stats.count,
|
||||||
|
totalStudents: targetCountMap.get(a.id) ?? 0,
|
||||||
|
createdAt: a.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
}).reverse() // Reverse to show trend from left (older) to right (newer)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed analytics for a specific homework assignment.
|
||||||
|
* Includes per-question error rates and wrong answer samples.
|
||||||
|
*/
|
||||||
|
export const getHomeworkAssignmentAnalytics = cache(
|
||||||
|
async (assignmentId: string): Promise<HomeworkAssignmentAnalytics | null> => {
|
||||||
|
const assignment = await db.query.homeworkAssignments.findFirst({
|
||||||
|
where: eq(homeworkAssignments.id, assignmentId),
|
||||||
|
with: {
|
||||||
|
sourceExam: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!assignment) return null
|
||||||
|
|
||||||
|
const [targetsRow] = await db
|
||||||
|
.select({ c: count() })
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
|
||||||
|
|
||||||
|
const [submissionsRow] = await db
|
||||||
|
.select({ c: count() })
|
||||||
|
.from(homeworkSubmissions)
|
||||||
|
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
|
||||||
|
|
||||||
|
const [submittedRow] = await db
|
||||||
|
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||||
|
.from(homeworkSubmissions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||||
|
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const [gradedRow] = await db
|
||||||
|
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||||
|
.from(homeworkSubmissions)
|
||||||
|
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
|
||||||
|
|
||||||
|
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
||||||
|
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||||
|
with: { question: true },
|
||||||
|
orderBy: (q, { asc }) => [asc(q.order)],
|
||||||
|
})
|
||||||
|
|
||||||
|
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
|
||||||
|
|
||||||
|
for (const aq of assignmentQuestions) {
|
||||||
|
statsByQuestionId.set(aq.questionId, {
|
||||||
|
questionId: aq.questionId,
|
||||||
|
questionType: aq.question.type,
|
||||||
|
questionContent: toQuestionContent(aq.question.content),
|
||||||
|
maxScore: aq.score ?? 0,
|
||||||
|
order: aq.order ?? 0,
|
||||||
|
errorCount: 0,
|
||||||
|
errorRate: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradedSubmissionsAll = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||||
|
eq(homeworkSubmissions.status, "graded")
|
||||||
|
),
|
||||||
|
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
||||||
|
with: {
|
||||||
|
answers: true,
|
||||||
|
student: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestByStudentId = new Map<string, (typeof gradedSubmissionsAll)[number]>()
|
||||||
|
for (const s of gradedSubmissionsAll) {
|
||||||
|
if (!latestByStudentId.has(s.studentId)) latestByStudentId.set(s.studentId, s)
|
||||||
|
}
|
||||||
|
const gradedSubmissions = Array.from(latestByStudentId.values())
|
||||||
|
|
||||||
|
const scoreBySubmissionQuestion = new Map<string, number>()
|
||||||
|
const answerBySubmissionQuestion = new Map<string, unknown>()
|
||||||
|
for (const sub of gradedSubmissions) {
|
||||||
|
for (const ans of sub.answers) {
|
||||||
|
const key = `${sub.id}|${ans.questionId}`
|
||||||
|
if (scoreBySubmissionQuestion.has(key)) continue
|
||||||
|
scoreBySubmissionQuestion.set(key, ans.score ?? 0)
|
||||||
|
const raw = ans.answerContent
|
||||||
|
if (isRecord(raw) && "answer" in raw) {
|
||||||
|
answerBySubmissionQuestion.set(key, raw.answer)
|
||||||
|
} else {
|
||||||
|
answerBySubmissionQuestion.set(key, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const denom = gradedSubmissions.length
|
||||||
|
if (denom > 0) {
|
||||||
|
for (const q of statsByQuestionId.values()) {
|
||||||
|
if (q.maxScore <= 0) continue
|
||||||
|
let errors = 0
|
||||||
|
const wrongAnswers: Array<{ studentId: string; studentName: string; answerContent: unknown }> = []
|
||||||
|
for (const sub of gradedSubmissions) {
|
||||||
|
const key = `${sub.id}|${q.questionId}`
|
||||||
|
const score = scoreBySubmissionQuestion.get(key) ?? 0
|
||||||
|
if (score < q.maxScore) {
|
||||||
|
errors += 1
|
||||||
|
wrongAnswers.push({
|
||||||
|
studentId: sub.studentId,
|
||||||
|
studentName: sub.student.name || "Unknown",
|
||||||
|
answerContent: answerBySubmissionQuestion.get(key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q.errorCount = errors
|
||||||
|
q.errorRate = errors / denom
|
||||||
|
q.wrongAnswers = wrongAnswers.slice(0, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions: HomeworkAssignmentQuestionAnalytics[] = Array.from(statsByQuestionId.values())
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
const analytics: HomeworkAssignmentAnalytics = {
|
||||||
|
assignment: {
|
||||||
|
id: assignment.id,
|
||||||
|
title: assignment.title,
|
||||||
|
description: assignment.description,
|
||||||
|
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
|
||||||
|
sourceExamId: assignment.sourceExamId,
|
||||||
|
sourceExamTitle: assignment.sourceExam.title,
|
||||||
|
structure: assignment.structure as unknown,
|
||||||
|
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
||||||
|
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
||||||
|
allowLate: assignment.allowLate,
|
||||||
|
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
||||||
|
maxAttempts: assignment.maxAttempts,
|
||||||
|
targetCount: targetsRow?.c ?? 0,
|
||||||
|
submissionCount: submissionsRow?.c ?? 0,
|
||||||
|
submittedCount: submittedRow?.c ?? 0,
|
||||||
|
gradedCount: gradedRow?.c ?? 0,
|
||||||
|
createdAt: assignment.createdAt.toISOString(),
|
||||||
|
updatedAt: assignment.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
gradedSampleCount: gradedSubmissions.length,
|
||||||
|
questions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return analytics
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get student dashboard grade data including trend, recent scores, and class ranking.
|
||||||
|
* The ranking calculation queries all classmates' graded submissions and computes
|
||||||
|
* relative position by total percentage score.
|
||||||
|
*/
|
||||||
|
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
|
||||||
|
const id = studentId.trim()
|
||||||
|
if (!id) return { trend: [], recent: [], ranking: null }
|
||||||
|
|
||||||
|
const targetAssignmentIdsRows = await db
|
||||||
|
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(eq(homeworkAssignmentTargets.studentId, id))
|
||||||
|
|
||||||
|
const targetAssignmentIds = Array.from(new Set(targetAssignmentIdsRows.map((r) => r.assignmentId)))
|
||||||
|
if (targetAssignmentIds.length === 0) return { trend: [], recent: [], ranking: null }
|
||||||
|
|
||||||
|
const gradedSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(homeworkSubmissions.studentId, id),
|
||||||
|
inArray(homeworkSubmissions.assignmentId, targetAssignmentIds),
|
||||||
|
eq(homeworkSubmissions.status, "graded")
|
||||||
|
),
|
||||||
|
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
||||||
|
limit: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestByAssignmentId = new Map<string, (typeof gradedSubmissions)[number]>()
|
||||||
|
for (const s of gradedSubmissions) {
|
||||||
|
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = Array.from(latestByAssignmentId.values()).sort((a, b) => {
|
||||||
|
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
|
||||||
|
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
|
||||||
|
return aTime - bTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const trendSubmissions = unique.slice(-10)
|
||||||
|
const recentSubmissions = [...trendSubmissions].sort((a, b) => {
|
||||||
|
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
|
||||||
|
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
|
||||||
|
return bTime - aTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignmentIds = Array.from(new Set(trendSubmissions.map((s) => s.assignmentId)))
|
||||||
|
const assignments = await db.query.homeworkAssignments.findMany({
|
||||||
|
where: inArray(homeworkAssignments.id, assignmentIds),
|
||||||
|
})
|
||||||
|
const titleByAssignmentId = new Map(assignments.map((a) => [a.id, a.title] as const))
|
||||||
|
const maxScoreByAssignmentId = await getAssignmentMaxScoreById(assignmentIds)
|
||||||
|
|
||||||
|
const toAnalytics = (s: (typeof trendSubmissions)[number]): StudentHomeworkScoreAnalytics => {
|
||||||
|
const maxScore = maxScoreByAssignmentId.get(s.assignmentId) ?? 0
|
||||||
|
const score = s.score ?? 0
|
||||||
|
const percentage = maxScore > 0 ? (score / maxScore) * 100 : 0
|
||||||
|
return {
|
||||||
|
assignmentId: s.assignmentId,
|
||||||
|
assignmentTitle: titleByAssignmentId.get(s.assignmentId) ?? "Untitled",
|
||||||
|
score,
|
||||||
|
maxScore,
|
||||||
|
percentage,
|
||||||
|
submittedAt: (s.submittedAt ?? s.updatedAt).toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trend = trendSubmissions.map(toAnalytics)
|
||||||
|
const recent = recentSubmissions.map(toAnalytics).slice(0, 5)
|
||||||
|
|
||||||
|
const enrollment = await db.query.classEnrollments.findFirst({
|
||||||
|
where: and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")),
|
||||||
|
orderBy: (e, { asc }) => [asc(e.createdAt)],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!enrollment) return { trend, recent, ranking: null }
|
||||||
|
|
||||||
|
const classStudents = await db
|
||||||
|
.select({ studentId: classEnrollments.studentId })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.where(and(eq(classEnrollments.classId, enrollment.classId), eq(classEnrollments.status, "active")))
|
||||||
|
|
||||||
|
const classStudentIds = Array.from(new Set(classStudents.map((r) => r.studentId)))
|
||||||
|
const classSize = classStudentIds.length
|
||||||
|
if (classSize === 0) return { trend, recent, ranking: null }
|
||||||
|
|
||||||
|
const classAssignmentIdsRows = await db
|
||||||
|
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||||
|
|
||||||
|
const classAssignmentIds = Array.from(new Set(classAssignmentIdsRows.map((r) => r.assignmentId)))
|
||||||
|
if (classAssignmentIds.length === 0) return { trend, recent, ranking: null }
|
||||||
|
|
||||||
|
const classMaxScoreByAssignmentId = await getAssignmentMaxScoreById(classAssignmentIds)
|
||||||
|
|
||||||
|
const classGradedSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(homeworkSubmissions.studentId, classStudentIds),
|
||||||
|
inArray(homeworkSubmissions.assignmentId, classAssignmentIds),
|
||||||
|
eq(homeworkSubmissions.status, "graded")
|
||||||
|
),
|
||||||
|
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
||||||
|
limit: 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestByStudentAssignment = new Map<string, (typeof classGradedSubmissions)[number]>()
|
||||||
|
for (const s of classGradedSubmissions) {
|
||||||
|
const key = `${s.studentId}|${s.assignmentId}`
|
||||||
|
if (!latestByStudentAssignment.has(key)) latestByStudentAssignment.set(key, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalsByStudentId = new Map<string, { score: number; maxScore: number }>()
|
||||||
|
for (const sub of latestByStudentAssignment.values()) {
|
||||||
|
const maxScore = classMaxScoreByAssignmentId.get(sub.assignmentId) ?? 0
|
||||||
|
const score = sub.score ?? 0
|
||||||
|
const prev = totalsByStudentId.get(sub.studentId) ?? { score: 0, maxScore: 0 }
|
||||||
|
totalsByStudentId.set(sub.studentId, {
|
||||||
|
score: prev.score + score,
|
||||||
|
maxScore: prev.maxScore + maxScore,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const classUsers = await db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, classStudentIds))
|
||||||
|
|
||||||
|
const nameByStudentId = new Map(classUsers.map((u) => [u.id, u.name ?? "Student"] as const))
|
||||||
|
const myName = nameByStudentId.get(id) ?? "Student"
|
||||||
|
|
||||||
|
const ranked = classStudentIds
|
||||||
|
.map((studentId) => {
|
||||||
|
const totals = totalsByStudentId.get(studentId) ?? { score: 0, maxScore: 0 }
|
||||||
|
const percentage = totals.maxScore > 0 ? (totals.score / totals.maxScore) * 100 : 0
|
||||||
|
return { studentId, percentage, totals }
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.percentage !== a.percentage) return b.percentage - a.percentage
|
||||||
|
return a.studentId.localeCompare(b.studentId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const myIndex = ranked.findIndex((r) => r.studentId === id)
|
||||||
|
if (myIndex < 0) return { trend, recent, ranking: null }
|
||||||
|
|
||||||
|
const myTotals = ranked[myIndex]?.totals ?? { score: 0, maxScore: 0 }
|
||||||
|
const myPercentage = myTotals.maxScore > 0 ? (myTotals.score / myTotals.maxScore) * 100 : 0
|
||||||
|
|
||||||
|
const ranking: StudentRanking = {
|
||||||
|
studentId: id,
|
||||||
|
studentName: myName,
|
||||||
|
rank: myIndex + 1,
|
||||||
|
classSize,
|
||||||
|
totalScore: myTotals.score,
|
||||||
|
totalMaxScore: myTotals.maxScore,
|
||||||
|
percentage: myPercentage,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { trend, recent, ranking }
|
||||||
|
})
|
||||||
|
|
||||||
|
export type HomeworkDashboardStats = {
|
||||||
|
homeworkAssignmentCount: number
|
||||||
|
homeworkAssignmentPublishedCount: number
|
||||||
|
homeworkSubmissionCount: number
|
||||||
|
homeworkSubmissionToGradeCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHomeworkDashboardStats = cache(async (scope?: DataScope): Promise<HomeworkDashboardStats> => {
|
||||||
|
const homeworkConditions = []
|
||||||
|
const submissionConditions = []
|
||||||
|
|
||||||
|
if (scope && scope.type !== "all") {
|
||||||
|
if (scope.type === "owned") {
|
||||||
|
homeworkConditions.push(eq(homeworkAssignments.creatorId, scope.userId))
|
||||||
|
const ownedAssignmentIds = db
|
||||||
|
.select({ id: homeworkAssignments.id })
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.where(eq(homeworkAssignments.creatorId, scope.userId))
|
||||||
|
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, ownedAssignmentIds))
|
||||||
|
}
|
||||||
|
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||||
|
const gradeExamIds = db
|
||||||
|
.select({ id: exams.id })
|
||||||
|
.from(exams)
|
||||||
|
.where(inArray(exams.gradeId, scope.gradeIds))
|
||||||
|
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||||
|
const gradeAssignmentIds = db
|
||||||
|
.select({ id: homeworkAssignments.id })
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||||
|
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
||||||
|
}
|
||||||
|
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||||
|
const teacherGradeIds = await db
|
||||||
|
.selectDistinct({ gradeId: classes.gradeId })
|
||||||
|
.from(classes)
|
||||||
|
.where(inArray(classes.id, scope.classIds))
|
||||||
|
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
|
||||||
|
if (gradeIds.length > 0) {
|
||||||
|
const gradeExamIds = db
|
||||||
|
.select({ id: exams.id })
|
||||||
|
.from(exams)
|
||||||
|
.where(inArray(exams.gradeId, gradeIds))
|
||||||
|
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||||
|
const gradeAssignmentIds = db
|
||||||
|
.select({ id: homeworkAssignments.id })
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||||
|
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
homeworkAssignmentCountRow,
|
||||||
|
homeworkAssignmentPublishedCountRow,
|
||||||
|
homeworkSubmissionCountRow,
|
||||||
|
homeworkSubmissionToGradeCountRow,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.select({ value: count() }).from(homeworkAssignments).where(homeworkConditions.length ? and(...homeworkConditions) : undefined),
|
||||||
|
db.select({ value: count() }).from(homeworkAssignments).where(
|
||||||
|
homeworkConditions.length
|
||||||
|
? and(eq(homeworkAssignments.status, "published"), ...homeworkConditions)
|
||||||
|
: eq(homeworkAssignments.status, "published")
|
||||||
|
),
|
||||||
|
db.select({ value: count() }).from(homeworkSubmissions).where(submissionConditions.length ? and(...submissionConditions) : undefined),
|
||||||
|
db.select({ value: count() }).from(homeworkSubmissions).where(
|
||||||
|
submissionConditions.length
|
||||||
|
? and(eq(homeworkSubmissions.status, "submitted"), ...submissionConditions)
|
||||||
|
: eq(homeworkSubmissions.status, "submitted")
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
homeworkAssignmentCount: Number(homeworkAssignmentCountRow[0]?.value ?? 0),
|
||||||
|
homeworkAssignmentPublishedCount: Number(homeworkAssignmentPublishedCountRow[0]?.value ?? 0),
|
||||||
|
homeworkSubmissionCount: Number(homeworkSubmissionCountRow[0]?.value ?? 0),
|
||||||
|
homeworkSubmissionToGradeCount: Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -127,3 +127,12 @@ export const getQuestions = cache(async ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type QuestionsDashboardStats = {
|
||||||
|
questionCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getQuestionsDashboardStats = cache(async (): Promise<QuestionsDashboardStats> => {
|
||||||
|
const [row] = await db.select({ value: count() }).from(questions)
|
||||||
|
return { questionCount: Number(row?.value ?? 0) }
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { and, asc, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm"
|
import { and, asc, count, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
@@ -426,3 +426,19 @@ export async function reorderChapters(chapterId: string, newIndex: number, paren
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TextbooksDashboardStats = {
|
||||||
|
textbookCount: number
|
||||||
|
chapterCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTextbooksDashboardStats = cache(async (): Promise<TextbooksDashboardStats> => {
|
||||||
|
const [textbookCountRow, chapterCountRow] = await Promise.all([
|
||||||
|
db.select({ value: count() }).from(textbooks),
|
||||||
|
db.select({ value: count() }).from(chapters),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
textbookCount: Number(textbookCountRow[0]?.value ?? 0),
|
||||||
|
chapterCount: Number(chapterCountRow[0]?.value ?? 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { eq } from "drizzle-orm"
|
import { count, desc, eq, gt, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { roles, users, usersToRoles } from "@/shared/db/schema"
|
import { roles, sessions, users, usersToRoles } from "@/shared/db/schema"
|
||||||
|
|
||||||
export type UserProfile = {
|
export type UserProfile = {
|
||||||
id: string
|
id: string
|
||||||
@@ -69,3 +69,84 @@ export const getUserProfile = cache(async (userId: string): Promise<UserProfile
|
|||||||
updatedAt: user.updatedAt,
|
updatedAt: user.updatedAt,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export type UsersDashboardStats = {
|
||||||
|
userCount: number
|
||||||
|
activeSessionsCount: number
|
||||||
|
userRoleCounts: Array<{ role: string; count: number }>
|
||||||
|
recentUsers: Array<{
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
role: string | null
|
||||||
|
createdAt: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUsersDashboardStats = cache(async (): Promise<UsersDashboardStats> => {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const [userCountRow, activeSessionsRow, userRoleCountRows, recentUserRows] = await Promise.all([
|
||||||
|
db.select({ value: count() }).from(users),
|
||||||
|
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
|
||||||
|
db
|
||||||
|
.select({ role: roles.name, value: count() })
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.groupBy(roles.name),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
email: users.email,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.orderBy(desc(users.createdAt))
|
||||||
|
.limit(8),
|
||||||
|
])
|
||||||
|
|
||||||
|
const userCount = Number(userCountRow[0]?.value ?? 0)
|
||||||
|
const activeSessionsCount = Number(activeSessionsRow[0]?.value ?? 0)
|
||||||
|
|
||||||
|
const userRoleCounts = userRoleCountRows
|
||||||
|
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
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<string, string[]>()
|
||||||
|
for (const row of recentRoleRows) {
|
||||||
|
const list = rolesByUserId.get(row.userId) ?? []
|
||||||
|
list.push(row.roleName)
|
||||||
|
rolesByUserId.set(row.userId, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
userCount,
|
||||||
|
activeSessionsCount,
|
||||||
|
userRoleCounts,
|
||||||
|
recentUsers,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user