diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index 096e5b0..93ffd5f 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -38,4 +38,9 @@ - 每次修改后运行 `npm run lint` 和 `npx tsc --noEmit` 确保零错误 - Server Action 必须使用 `requirePermission()` 进行权限校验 - 前端组件禁止使用 `role === "xxx"` 硬编码,统一使用 `usePermission().hasPermission()` -- 单文件不超过 300 行 +- 单文件行数遵循企业级规范: + - 配置文件、常量文件、类型定义文件:无限制 + - React 组件:建议 ≤ 500 行(复杂表单/大型表格可放宽至 800 行) + - Server Actions / Data Access 模块:建议 ≤ 800 行 + - 超过建议行数时应考虑拆分(如 data-access 拆分为多个按职责划分的文件) + - 硬性上限:任何文件不超过 1000 行,超过必须拆分 diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 6838324..da7640d 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -218,7 +218,7 @@ #### `Permissions` (常量对象) - 文件:`types/permissions.ts` -- 定义:47 个权限常量(`exam:create`, `homework:grade`, `audit_log:read`, `announcement:manage`, `file:upload`, `file:read`, `file:delete`, `grade_record:manage`, `grade_record:read`, `course_plan:manage`, `course_plan:read`, `attendance:manage`, `attendance:read`, `message:send`, `message:read`, `message:delete`, `schedule:auto`, `schedule:adjust` 等) +- 定义:54 个权限常量(`exam:create`, `homework:grade`, `audit_log:read`, `announcement:manage`, `file:upload`, `file:read`, `file:delete`, `grade_record:manage`, `grade_record:read`, `course_plan:manage`, `course_plan:read`, `attendance:manage`, `attendance:read`, `message:send`, `message:read`, `message:delete`, `schedule:auto`, `schedule:adjust`, `elective:manage`, `elective:read`, `elective:select`, `exam:proctor`, `exam:proctor_read`, `diagnostic:manage`, `diagnostic:read` 等) - 被使用:auth-guard.ts, 所有模块的 actions.ts, 前端组件 #### `ROLE_PERMISSIONS` (常量对象) @@ -228,6 +228,7 @@ - 考勤权限:admin/teacher 含 `ATTENDANCE_MANAGE`+`ATTENDANCE_READ`;student/parent/grade_head/teaching_head 含 `ATTENDANCE_READ` - 消息权限:admin/teacher/grade_head/teaching_head 含 `MESSAGE_SEND`+`MESSAGE_READ`+`MESSAGE_DELETE`;student/parent 含 `MESSAGE_SEND`+`MESSAGE_READ` - 排课权限:admin 含 `SCHEDULE_AUTO`+`SCHEDULE_ADJUST`;teacher/student/parent/grade_head/teaching_head 无排课权限 +- 学情诊断权限:admin/teacher/grade_head 含 `DIAGNOSTIC_MANAGE`+`DIAGNOSTIC_READ`;teaching_head/student 含 `DIAGNOSTIC_READ` - 被使用:`resolvePermissions`, auth.ts (JWT callback) #### `db` (Drizzle 实例) @@ -399,6 +400,8 @@ | `schedulingRules` | id, classId, maxDailyHours, maxContinuousHours, lunchBreakStart, lunchBreakEnd, morningStart, afternoonEnd, avoidBackToBack, balancedSubjects, createdAt, updatedAt | scheduling | | `scheduleChanges` | id, originalScheduleId, classId, originalTeacherId, substituteTeacherId, originalDate, newDate, newStartTime, newEndTime, reason, status, requestedBy, approvedBy, createdAt, updatedAt | scheduling | | `passwordSecurity` | id, userId, failedLoginAttempts, lockedUntil, passwordChangedAt, mustChangePassword, lastPasswordChange, createdAt, updatedAt | auth, settings | +| `knowledgePointMastery` | id, studentId, knowledgePointId, masteryLevel, totalQuestions, correctQuestions, lastAssessedAt, createdAt, updatedAt | diagnostic | +| `learningDiagnosticReports` | id, studentId, generatedBy, reportType, period, summary, strengths, weaknesses, recommendations, overallScore, status, createdAt, updatedAt | diagnostic | --- @@ -1420,8 +1423,10 @@ - teacher 角色菜单包含 "Course Plans" 项(icon: CalendarRange, href: /teacher/course-plans, permission: Permissions.COURSE_PLAN_READ) - teacher 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /teacher/attendance, permission: Permissions.ATTENDANCE_MANAGE),含子项 Records (/teacher/attendance)、Take Attendance (/teacher/attendance/sheet, permission: ATTENDANCE_MANAGE)、Statistics (/teacher/attendance/stats, permission: ATTENDANCE_READ) - teacher 角色菜单包含 "Schedule Changes" 项(icon: CalendarClock, href: /teacher/schedule-changes, permission: Permissions.SCHEDULE_ADJUST) +- teacher 角色菜单包含 "Diagnostic" 项(icon: Stethoscope, href: /teacher/diagnostic, permission: Permissions.DIAGNOSTIC_READ) - student 角色菜单包含 "My Grades" 项(icon: GraduationCap, href: /student/grades, permission: Permissions.GRADE_RECORD_READ) - student 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /student/attendance, permission: Permissions.ATTENDANCE_READ) +- student 角色菜单包含 "Diagnostic" 项(icon: Stethoscope, href: /student/diagnostic, permission: Permissions.DIAGNOSTIC_READ) - parent 角色菜单包含 "Dashboard" 项(icon: LayoutDashboard, href: /parent/dashboard,无 permission 字段,仅需登录) - parent 角色菜单包含 "Grades" 项(icon: GraduationCap, href: /parent/grades, permission: Permissions.GRADE_RECORD_READ) - parent 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /parent/attendance, permission: Permissions.ATTENDANCE_READ) @@ -2578,31 +2583,299 @@ --- +## 模块:proctoring + +`src/modules/proctoring` + +考试监考模块:监考模式考试实时监控、防作弊事件采集、教师监考面板、学生端防作弊监控、考试模式配置。 + +> 权限:教师监考面板使用 `requirePermission(EXAM_PROCTOR)`;学生端上报事件使用 `requireAuth()`(学生上报自己的事件,不需要管理权限)。前端组件使用 `usePermission().hasPermission(EXAM_PROCTOR)` 控制权限。 + +### Server Actions (`actions.ts`) + +| Action | 权限 | 用途 | +|--------|------|------| +| `recordProctoringEventAction` | requireAuth() | 学生端上报监考事件(含 submission 归属校验) | +| `getProctoringDashboardAction` | EXAM_PROCTOR | 获取监考面板数据(摘要+学生状态+最近事件) | + +### Data Access (`data-access.ts`) + +`import "server-only"` + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `recordProctoringEvent` | `(input: RecordProctoringEventInput) => Promise` | actions.recordProctoringEventAction, API /api/proctoring/event | +| `getProctoringEvents` | `(examId, filters?) => Promise` | 待扩展 | +| `getProctoringEventsBySubmission` | `(submissionId) => Promise` | 待扩展 | +| `getExamProctoringSummary` | `(examId) => Promise` | actions.getProctoringDashboardAction, teacher/exams/[id]/proctoring/page.tsx | +| `getStudentProctoringStatuses` | `(examId) => Promise` | actions.getProctoringDashboardAction, teacher/exams/[id]/proctoring/page.tsx | +| `getExamForProctoring` | `(examId) => Promise<{id,title,examMode,config} | null>` | actions.getProctoringDashboardAction, teacher/exams/[id]/proctoring/page.tsx | +| `getRecentProctoringEvents` | `(examId, limit?) => Promise` | actions.getProctoringDashboardAction, teacher/exams/[id]/proctoring/page.tsx | + +### Types (`types.ts`) + +| 类型 | 定义 | +|------|------| +| `ProctoringEventType` | `"tab_switch" \| "window_blur" \| "copy_attempt" \| "paste_attempt" \| "right_click" \| "devtools_open" \| "fullscreen_exit" \| "idle_timeout"` | +| `ExamMode` | `"homework" \| "timed" \| "proctored"` | +| `ProctoringEvent` | `{ id, submissionId, studentId, examId, eventType, eventDetail?, occurredAt, createdAt }` | +| `ProctoringEventWithDetails` | `ProctoringEvent & { studentName, examTitle }` | +| `ExamProctoringSummary` | `{ examId, examTitle, examMode, totalStudents, startedStudents, submittedStudents, totalEvents, abnormalStudents, eventsByType }` | +| `StudentProctoringStatus` | `{ studentId, studentName, submissionId, submissionStatus, eventCount, lastEventAt, isAbnormal, eventsByType }` | +| `ProctoringDashboardData` | `{ summary, students, recentEvents }` | +| `ExamModeConfig` | `{ examMode, durationMinutes, shuffleQuestions, allowLateStart, lateStartGraceMinutes, antiCheatEnabled }` | +| `PROCTORING_EVENT_LABELS` | 事件类型中文标签常量 | +| `EXAM_MODE_LABELS` | 考试模式中文标签常量 | +| `ABNORMAL_EVENT_THRESHOLD` | 异常学生事件数阈值(3) | + +### Components (`components/`) + +| 组件 | 用途 | +|------|------| +| `proctoring-dashboard.tsx` | 教师监考面板(实时学生状态、异常事件统计、异常学生高亮、10 秒轮询、usePermission 权限控制) | +| `anti-cheat-monitor.tsx` | 学生端防作弊监控(visibilitychange/blur/copy/paste/contextmenu/keydown/fullscreenchange 监听、空闲超时检测、强制全屏、警告提示、事件上报) | +| `exam-mode-config.tsx` | 考试模式配置(react-hook-form Controller,作业/限时/监考模式选择,限时设置时长,监考设置防作弊选项) | + +### API Routes + +| 路由 | 方法 | 权限 | 用途 | +|------|------|------|------| +| `/api/proctoring/event` | POST | requireAuth() | 接收学生端上报的监考事件(含 submission 归属校验) | + +--- + +## 模块:diagnostic + +`src/modules/diagnostic` + +学情诊断报告模块:基于知识点掌握度(`knowledgePointMastery` 表)生成个人/班级诊断报告,掌握度雷达图(学生 vs 班级平均),强项/弱项分析,知识点掌握度热力图,需重点关注学生列表,报告发布/删除管理。 + +> 权限:所有 Server Actions 使用 `requirePermission()` 校验。生成/发布/删除报告使用 `requirePermission(DIAGNOSTIC_MANAGE)`,查询/详情使用 `requirePermission(DIAGNOSTIC_READ)`。admin/teacher/grade_head 角色拥有 DIAGNOSTIC_MANAGE+DIAGNOSTIC_READ,teaching_head/student 角色仅有 DIAGNOSTIC_READ。前端组件使用 `usePermission().hasPermission(DIAGNOSTIC_MANAGE)` 控制生成/发布/删除按钮可见性(无 `role === "xxx"` 硬编码)。页面路由通过 `getAuthContext()` 进行 DataScope 二次校验:`class_members` 仅查自己,`children` 仅查子女,`class_taught` 必须包含 classId。 + +### Server Actions (`actions.ts`) + +`"use server"` + +| Action | 权限 | 用途 | +|--------|------|------| +| `generateStudentReportAction` | DIAGNOSTIC_MANAGE | 生成学生个人诊断报告(formData: studentId, period) | +| `generateClassReportAction` | DIAGNOSTIC_MANAGE | 生成班级诊断报告(formData: classId, period) | +| `publishReportAction` | DIAGNOSTIC_MANAGE | 发布诊断报告(formData: id,status → published) | +| `deleteReportAction` | DIAGNOSTIC_MANAGE | 删除诊断报告(formData: id) | +| `getDiagnosticReportsAction` | DIAGNOSTIC_READ | 查询诊断报告列表(params: DiagnosticReportQueryParams) | +| `getDiagnosticReportByIdAction` | DIAGNOSTIC_READ | 获取诊断报告详情(id) | + +### Data Access (`data-access.ts` + `data-access-reports.ts`) + +`import "server-only"`(两个文件均以此开头) + +#### `data-access.ts`(掌握度相关) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getStudentMastery` | `(studentId: string) => Promise` | getStudentMasterySummary, teacher/diagnostic/student/[studentId] | +| `getStudentMasterySummary` | `(studentId: string) => Promise` | generateDiagnosticReport, teacher/diagnostic/student/[studentId], student/diagnostic | +| `updateMasteryFromSubmission` | `(submissionId: string) => Promise` | 待扩展(作业/考试提交后触发,onDuplicateKeyUpdate upsert) | +| `getClassMasterySummary` | `(classId: string) => Promise` | generateClassDiagnosticReport, teacher/diagnostic/class/[classId] | +| `getKnowledgePointStats` | `(classId?: string, gradeId?: string) => Promise` | teacher/diagnostic/student/[studentId](班级平均对比) | + +#### `data-access-reports.ts`(报告相关) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `generateDiagnosticReport` | `(studentId, period, generatedBy) => Promise` | generateStudentReportAction | +| `generateClassDiagnosticReport` | `(classId, period, generatedBy) => Promise` | generateClassReportAction | +| `getDiagnosticReports` | `(filters: DiagnosticReportQueryParams) => Promise` | getDiagnosticReportsAction, teacher/diagnostic, teacher/diagnostic/student/[studentId], student/diagnostic | +| `getDiagnosticReportById` | `(id: string) => Promise` | getDiagnosticReportByIdAction | +| `publishDiagnosticReport` | `(id: string) => Promise` | publishReportAction | +| `deleteDiagnosticReport` | `(id: string) => Promise` | deleteReportAction | + +### Types (`types.ts`) + +| Type | 定义 | +|------|------| +| `DiagnosticReportType` | `"individual" \| "class" \| "grade"` | +| `DiagnosticReportStatus` | `"draft" \| "published" \| "archived"` | +| `KnowledgePointMastery` | `{ id, studentId, knowledgePointId, masteryLevel(0-100), totalQuestions, correctQuestions, lastAssessedAt, createdAt, updatedAt }` | +| `MasteryWithKnowledgePoint` | `KnowledgePointMastery & { knowledgePointName, knowledgePointDescription }` | +| `StudentMasterySummary` | `{ studentId, studentName, averageMastery, totalKnowledgePoints, strengths(≥80), weaknesses(<60), allMastery }` | +| `DiagnosticReport` | `{ id, studentId, generatedBy, reportType, period, summary, strengths[], weaknesses[], recommendations[], overallScore, status, createdAt, updatedAt }` | +| `DiagnosticReportWithDetails` | `DiagnosticReport & { studentName, generatedByName }` | +| `ClassMasterySummary` | `{ classId, className, studentCount, averageMastery, knowledgePointStats[], studentsNeedingAttention[] }` | +| `KnowledgePointStat` | `{ knowledgePointId, knowledgePointName, averageMastery, masteredCount(≥80), notMasteredCount(<60), totalStudents }` | +| `DiagnosticReportQueryParams` | `{ studentId?, reportType?, status?, period? }` | +| `MasteryRadarPoint` | `{ knowledgePoint, student(0-100), classAverage?(0-100) }` | + +### Components (`components/`) + +所有组件以 `"use client"` 开头。 + +| 组件 | 用途 | +|------|------| +| `mastery-radar-chart.tsx` | 知识点掌握度雷达图(recharts RadarChart,学生 vs 班级平均对比,无数据时显示 EmptyState) | +| `student-diagnostic-view.tsx` | 学生诊断视图(概览卡片、雷达图、强项/弱项列表、生成报告表单[DIAGNOSTIC_MANAGE]、最新报告与建议展示) | +| `class-diagnostic-view.tsx` | 班级诊断视图(概览卡片、知识点掌握度热力图[绿≥80/黄60-79/橙40-59/红<40]、知识点排名表、需重点关注学生表[链接到学生视图]、生成班级报告表单[DIAGNOSTIC_MANAGE]) | +| `report-list.tsx` | 诊断报告列表(reportType/status 过滤器[URL searchParams]、报告表格、发布/删除操作[DIAGNOSTIC_MANAGE]、确认对话框) | + +### 数据库表 + +| 表 | 用途 | +|----|------| +| `knowledgePointMastery` | 知识点掌握度记录(复合主键 studentId+knowledgePointId,onDuplicateKeyUpdate upsert) | +| `learningDiagnosticReports` | 学情诊断报告(reportType: individual/class/grade;status: draft/published/archived) | + +### 页面路由 + +| 路由 | 组件 | 权限 | DataScope 校验 | +|------|------|------|----------------| +| `/teacher/diagnostic` | ReportList | diagnostic:read | class_members 仅查看自己报告 | +| `/teacher/diagnostic/student/[studentId]` | StudentDiagnosticView | diagnostic:read | class_members 仅自己,children 仅子女 | +| `/teacher/diagnostic/class/[classId]` | ClassDiagnosticView | diagnostic:read | class_taught 必须包含 classId,class_members/children → notFound | +| `/student/diagnostic` | StudentDiagnosticView | diagnostic:read | class_members 仅查自己(ctx.userId) | + +--- + +## 模块:elective + +`src/modules/elective` + +选课管理模块:选修课程 CRUD、选课开放/关闭、学生选课/退课、抽签模式批量录取(runLottery)、FCFS 即时录取、DataScope 行级过滤(admin 全部、teacher 所教、grade_head 所管年级、student 可选课程)。 + +> 权限:管理操作使用 `requirePermission(ELECTIVE_MANAGE)`;读取使用 `requirePermission(ELECTIVE_READ)`;学生选课/退课使用 `requirePermission(ELECTIVE_SELECT)`。前端组件使用 `usePermission().hasPermission()` 控制权限。`getStudentSelectionsAction` 对 class_members/children 进行 DataScope 二次校验。 + +### Server Actions (`actions.ts`) + +| Action | 权限 | 用途 | +|--------|------|------| +| `createElectiveCourseAction` | ELECTIVE_MANAGE | 创建选修课程(formData: name, subjectId?, teacherId, gradeId?, description?, capacity?, classroom?, schedule?, startDate?, endDate?, selectionStartAt?, selectionEndAt?, selectionMode?, credit?) | +| `updateElectiveCourseAction` | ELECTIVE_MANAGE | 更新选修课程(id + formData) | +| `deleteElectiveCourseAction` | ELECTIVE_MANAGE | 删除选修课程(formData: courseId) | +| `openSelectionAction` | ELECTIVE_MANAGE | 开放选课(formData: courseId) | +| `closeSelectionAction` | ELECTIVE_MANAGE | 关闭选课(formData: courseId) | +| `runLotteryAction` | ELECTIVE_MANAGE | 执行抽签录取(formData: courseId),返回 {enrolled, waitlist} | +| `selectCourseAction` | ELECTIVE_SELECT | 学生选课(formData: courseId, priority?) | +| `dropCourseAction` | ELECTIVE_SELECT | 学生退课(formData: courseId) | +| `getElectiveCoursesAction` | ELECTIVE_READ | 查询选修课程列表(按 DataScope 过滤,传 currentUserId) | +| `getStudentSelectionsAction` | ELECTIVE_READ | 查询学生选课记录(DataScope 二次校验:class_members 仅自己,children 仅子女) | +| `getAvailableCoursesAction` | ELECTIVE_SELECT | 获取学生可选课程(status=open 且匹配年级) | + +### Data Access + +#### `data-access.ts` (`import "server-only"`) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getElectiveCourses` | `(params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }) => Promise` | getElectiveCoursesAction, admin/elective, teacher/elective | +| `getElectiveCourseById` | `(id: string) => Promise` | updateElectiveCourseAction, admin/elective/[id]/edit | +| `createElectiveCourse` | `(data: CreateElectiveCourseInput, teacherId: string) => Promise` | createElectiveCourseAction | +| `updateElectiveCourse` | `(id: string, data: Partial) => Promise` | updateElectiveCourseAction | +| `deleteElectiveCourse` | `(id: string) => Promise` | deleteElectiveCourseAction | +| `openSelection` | `(courseId: string) => Promise` | openSelectionAction | +| `closeSelection` | `(courseId: string) => Promise` | closeSelectionAction | +| `getSubjectOptions` | `() => Promise<{id, name}[]>` | admin/elective/create, admin/elective/[id]/edit | + +> `buildScopeFilter(scope, userId)` 内部函数:owned/class_taught 按 `teacherId` 过滤,grade_managed 按 `gradeIds` 过滤,class_members/children 返回 null(学生通过 `getAvailableCoursesForStudent` 获取可选课程)。 + +#### `data-access-selections.ts` (`import "server-only"`) + +> 从 data-access.ts 拆分以遵守单文件 ≤300 行规则。 + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getCourseSelections` | `(courseId: string) => Promise` | 待扩展 | +| `getStudentSelections` | `(studentId: string) => Promise` | getStudentSelectionsAction, student/elective | +| `getStudentGradeId` | `(studentId: string) => Promise` | getAvailableCoursesForStudent | +| `getAvailableCoursesForStudent` | `(studentId: string, gradeId?: string \| null) => Promise` | getAvailableCoursesAction, student/elective | + +#### `data-access-operations.ts` (`import "server-only"`) + +> 从 data-access.ts 拆分以遵守单文件 ≤300 行规则。包含选课/退课/抽签的写操作。 + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `runLottery` | `(courseId: string) => Promise<{enrolled: number, waitlist: number}>` | runLotteryAction | +| `selectCourse` | `(courseId: string, studentId: string, priority?: number) => Promise<{status: CourseSelectionStatus, message: string}>` | selectCourseAction | +| `dropCourse` | `(courseId: string, studentId: string) => Promise` | dropCourseAction | + +### Schema (`schema.ts`) + +| Schema | 用途 | +|--------|------| +| `ElectiveCourseStatusEnum` | 课程状态枚举(draft/open/closed/cancelled) | +| `ElectiveSelectionModeEnum` | 选课模式枚举(fcfs/lottery) | +| `CourseSelectionStatusEnum` | 选课状态枚举(selected/enrolled/waitlist/dropped/rejected) | +| `CreateElectiveCourseSchema` | 创建课程校验(name 必填,teacherId 必填,capacity 1-500 默认 30,selectionMode 默认 fcfs,credit 默认 1.0) | +| `UpdateElectiveCourseSchema` | 更新课程校验(所有字段可选,含 status) | +| `SelectCourseSchema` | 选课校验(courseId 必填,priority 1-10 可选) | +| `DropCourseSchema` | 退课校验(courseId 必填) | +| `RunLotterySchema` | 抽签校验(courseId 必填) | + +### 类型/接口 (`types.ts`) + +| 类型 | 定义 | +|------|------| +| `ElectiveCourseStatus` | `"draft" \| "open" \| "closed" \| "cancelled"` | +| `ElectiveSelectionMode` | `"fcfs" \| "lottery"` | +| `CourseSelectionStatus` | `"selected" \| "enrolled" \| "waitlist" \| "dropped" \| "rejected"` | +| `ElectiveCourse` | 课程完整类型(id, name, subjectId?, teacherId, gradeId?, description?, capacity, enrolledCount, classroom?, schedule?, startDate?, endDate?, selectionStartAt?, selectionEndAt?, status, selectionMode, credit, createdAt, updatedAt) | +| `ElectiveCourseWithDetails` | `ElectiveCourse & { teacherName?, subjectName?, gradeName? }` | +| `CourseSelection` | 选课记录类型(id, courseId, studentId, status, priority?, selectedAt, enrolledAt?, droppedAt?, lotteryRank?, createdAt, updatedAt) | +| `CourseSelectionWithDetails` | `CourseSelection & { courseName?, studentName?, courseCapacity?, courseEnrolledCount?, courseStatus? }` | +| `GetElectiveCoursesParams` | 查询参数(status?, gradeId?, subjectId?, teacherId?) | +| `ELECTIVE_STATUS_LABELS` | 课程状态标签常量 | +| `ELECTIVE_STATUS_COLORS` | 课程状态颜色常量(Badge variant) | +| `SELECTION_MODE_LABELS` | 选课模式标签常量 | +| `COURSE_SELECTION_STATUS_LABELS` | 选课状态标签常量 | +| `COURSE_SELECTION_STATUS_COLORS` | 选课状态颜色常量(Badge variant) | + +### 导出组件 (`components/`) + +| 组件文件 | 功能 | +|---------|------| +| `elective-course-list.tsx` | 课程卡片列表(管理员/教师视图,含编辑/开放/关闭/抽签/删除操作按钮,usePermission 权限控制) | +| `elective-course-form.tsx` | 课程创建/编辑表单(name, subjectId, teacherId, gradeId, description, capacity, classroom, schedule, dates, selectionMode, credit) | +| `student-selection-view.tsx` | 学生选课视图(可选课程列表 + 我的选课记录,含选课/退课按钮) | + +### 路由页面 + +| 路由 | 组件 | 权限 | 说明 | +|------|------|------|------| +| `/admin/elective` | ElectiveCourseList | elective:manage | 管理员选修课程列表(scope=all) | +| `/admin/elective/create` | ElectiveCourseForm | elective:manage | 创建选修课程 | +| `/admin/elective/[id]/edit` | ElectiveCourseForm (edit) | elective:manage | 编辑选修课程 | +| `/teacher/elective` | ElectiveCourseList (teacher) | elective:manage | 教师选修课程列表(scope=class_taught/owned,按 teacherId 过滤) | +| `/student/elective` | StudentSelectionView | elective:select | 学生选课页面(可选课程 + 我的选课) | + +--- + ## 模块间依赖矩阵 -| ↓ 使用 → | shared | auth | exams | homework | questions | textbooks | classes | school | dashboard | layout | settings | users | audit | announcements | files | grades | course-plans | parent | messaging | attendance | scheduling | -|----------|--------|------|-------|----------|-----------|-----------|---------|--------|-----------|--------|----------|-------|-------|---------------|-------|-------|-------------|--------|-----------|------------|-----------| -| **shared** | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **auth** | db,schema,permissions,login-logger | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **exams** | db,auth-guard,types,ai | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **homework** | db,auth-guard,types | auth | data-access.getExams | - | - | - | schema | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **questions** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **textbooks** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **classes** | db,auth-guard,types | auth | - | homework-insights | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **school** | db,auth-guard,types,audit-logger | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **dashboard** | db,types | auth | - | data-access.getTeacherGradeTrends,getStudentDashboardGrades | - | - | data-access.getTeacherClasses,getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **layout** | hooks.usePermission | auth(useSession) | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | notification-dropdown | - | - | -| **settings** | db,auth-guard,ai,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **users** | db,auth-guard(requireAuth,requirePermission),types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **audit** | db,auth-guard.requirePermission,types.permissions | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **announcements** | db,auth-guard,types | auth | - | - | - | - | - | - | data-access.getGrades | - | - | - | - | - | - | - | - | - | - | - | - | -| **files** | db,auth-guard(requireAuth,requirePermission),types,lib/file-storage | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **grades** | db,auth-guard,types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **course-plans** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getAdminClasses,getStaffOptions | data-access.getAcademicYears | - | - | - | - | - | - | - | - | - | - | - | - | -| **parent** | db,auth-guard(requireAuth),types | auth | - | data-access.getStudentHomeworkAssignments,getStudentDashboardGrades | - | - | data-access.getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | data-access.getStudentGradeSummary | - | - | - | - | - | -| **messaging** | db,auth-guard(requirePermission,requireAuth),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **attendance** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getTeacherClasses,getAdminClasses | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **scheduling** | db,auth-guard(requirePermission,getAuthContext),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| ↓ 使用 → | shared | auth | exams | homework | questions | textbooks | classes | school | dashboard | layout | settings | users | audit | announcements | files | grades | course-plans | parent | messaging | attendance | scheduling | proctoring | diagnostic | elective | +|----------|--------|------|-------|----------|-----------|-----------|---------|--------|-----------|--------|----------|-------|-------|---------------|-------|-------|-------------|--------|-----------|------------|-----------|------------|------------|----------| +| **shared** | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **auth** | db,schema,permissions,login-logger | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **exams** | db,auth-guard,types,ai | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **homework** | db,auth-guard,types | auth | data-access.getExams | - | - | - | schema | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **questions** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **textbooks** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **classes** | db,auth-guard,types | auth | - | homework-insights | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **school** | db,auth-guard,types,audit-logger | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **dashboard** | db,types | auth | - | data-access.getTeacherGradeTrends,getStudentDashboardGrades | - | - | data-access.getTeacherClasses,getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **layout** | hooks.usePermission | auth(useSession) | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | notification-dropdown | - | - | - | - | +| **settings** | db,auth-guard,ai,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **users** | db,auth-guard(requireAuth,requirePermission),types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **audit** | db,auth-guard.requirePermission,types.permissions | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **announcements** | db,auth-guard,types | auth | - | - | - | - | - | - | data-access.getGrades | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **files** | db,auth-guard(requireAuth,requirePermission),types,lib/file-storage | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **grades** | db,auth-guard,types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **course-plans** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getAdminClasses,getStaffOptions | data-access.getAcademicYears | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **parent** | db,auth-guard(requireAuth),types | auth | - | data-access.getStudentHomeworkAssignments,getStudentDashboardGrades | - | - | data-access.getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | data-access.getStudentGradeSummary | - | - | - | - | - | - | +| **messaging** | db,auth-guard(requirePermission,requireAuth),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **attendance** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getTeacherClasses,getAdminClasses | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **scheduling** | db,auth-guard(requirePermission,getAuthContext),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **proctoring** | db,auth-guard(requirePermission,requireAuth),types,components.ui,hooks.usePermission | auth | schema.exams,examSubmissions,examProctoringEvents | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **diagnostic** | db,auth-guard(requirePermission,getAuthContext),types,hooks.usePermission,components.ui | auth | schema.examSubmissions,submissionAnswers,questionsToKnowledgePoints | - | - | - | schema.classes,classEnrollments | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **elective** | db,auth-guard.requirePermission,types,types.DataScope,hooks.usePermission,components.ui | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | --- @@ -2616,6 +2889,8 @@ 5. 在 exams/actions.ts 中作为 `creatorId` 写入 `exams` 表 6. 在 homework/actions.ts 中作为 `creatorId` 写入 `homeworkAssignments` 表 7. 在 classes/data-access.ts 中查询 `getTeacherClasses(teacherId)` 和 `getGradeManagedClasses(userId)` +8. 在 elective/actions.ts 中作为 `teacherId` 默认值写入 `electiveCourses` 表(createElectiveCourseAction),作为 `studentId` 查询学生选课(selectCourseAction/dropCourseAction/getStudentSelectionsAction) +9. 在 elective/data-access.ts 中 `getElectiveCourses({ scope, currentUserId })` 按 `teacherId` 过滤(class_taught/owned scope) ### `examId` 1. 由 `exams/actions.ts` 的 `createExamAction` 产生,通过 CUID2 生成,写入 `exams` 表 @@ -2634,13 +2909,13 @@ 6. 在 `auth-guard.ts` 中通过 `classSubjectTeachers` 查询教师关联的 classIds,构建 `DataScope.class_taught` ### `permission` -1. 由 `shared/types/permissions.ts` 的 `Permissions` 常量定义(47 个权限点,含 `AUDIT_LOG_READ`、`ANNOUNCEMENT_MANAGE`、`FILE_UPLOAD`、`FILE_READ`、`FILE_DELETE`、`GRADE_RECORD_MANAGE`、`GRADE_RECORD_READ`、`COURSE_PLAN_MANAGE`、`COURSE_PLAN_READ`、`ATTENDANCE_MANAGE`、`ATTENDANCE_READ`、`MESSAGE_SEND`、`MESSAGE_READ`、`MESSAGE_DELETE`、`SCHEDULE_AUTO`、`SCHEDULE_ADJUST` 等) -2. 在 `shared/lib/permissions.ts` 中通过 `ROLE_PERMISSIONS` 映射角色到权限列表(admin 角色包含 `AUDIT_LOG_READ`、`COURSE_PLAN_MANAGE`+`COURSE_PLAN_READ`;admin/teacher 含 `FILE_UPLOAD/READ/DELETE` 及 `GRADE_RECORD_MANAGE/READ`;teacher/student/grade_head/teaching_head 含 `COURSE_PLAN_READ`;student/parent 含 `FILE_READ` 及 `GRADE_RECORD_READ`;admin/teacher 含 `ATTENDANCE_MANAGE`+`ATTENDANCE_READ`,student/parent/grade_head/teaching_head 含 `ATTENDANCE_READ`;admin/teacher/parent/grade_head/teaching_head 含 `MESSAGE_SEND/READ/DELETE`;student 含 `MESSAGE_READ/DELETE` 但无 `MESSAGE_SEND`;admin 含 `SCHEDULE_AUTO`+`SCHEDULE_ADJUST`,teacher/student/parent/grade_head/teaching_head 无排课权限) +1. 由 `shared/types/permissions.ts` 的 `Permissions` 常量定义(57 个权限点,含 `AUDIT_LOG_READ`、`ANNOUNCEMENT_MANAGE`、`FILE_UPLOAD`、`FILE_READ`、`FILE_DELETE`、`GRADE_RECORD_MANAGE`、`GRADE_RECORD_READ`、`COURSE_PLAN_MANAGE`、`COURSE_PLAN_READ`、`ATTENDANCE_MANAGE`、`ATTENDANCE_READ`、`MESSAGE_SEND`、`MESSAGE_READ`、`MESSAGE_DELETE`、`SCHEDULE_AUTO`、`SCHEDULE_ADJUST`、`DIAGNOSTIC_MANAGE`、`DIAGNOSTIC_READ`、`ELECTIVE_MANAGE`、`ELECTIVE_READ`、`ELECTIVE_SELECT` 等) +2. 在 `shared/lib/permissions.ts` 中通过 `ROLE_PERMISSIONS` 映射角色到权限列表(admin 角色包含 `AUDIT_LOG_READ`、`COURSE_PLAN_MANAGE`+`COURSE_PLAN_READ`;admin/teacher 含 `FILE_UPLOAD/READ/DELETE` 及 `GRADE_RECORD_MANAGE/READ`;teacher/student/grade_head/teaching_head 含 `COURSE_PLAN_READ`;student/parent 含 `FILE_READ` 及 `GRADE_RECORD_READ`;admin/teacher 含 `ATTENDANCE_MANAGE`+`ATTENDANCE_READ`,student/parent/grade_head/teaching_head 含 `ATTENDANCE_READ`;admin/teacher/parent/grade_head/teaching_head 含 `MESSAGE_SEND/READ/DELETE`;student 含 `MESSAGE_READ/DELETE` 但无 `MESSAGE_SEND`;admin 含 `SCHEDULE_AUTO`+`SCHEDULE_ADJUST`,teacher/student/parent/grade_head/teaching_head 无排课权限;admin/teacher/grade_head 含 `DIAGNOSTIC_MANAGE`+`DIAGNOSTIC_READ`,teaching_head/student 含 `DIAGNOSTIC_READ`;admin/teacher 含 `ELECTIVE_MANAGE`+`ELECTIVE_READ`,student 含 `ELECTIVE_SELECT`+`ELECTIVE_READ`,grade_head/teaching_head 含 `ELECTIVE_READ`) 3. 在 `auth.ts` JWT callback 中通过 `resolvePermissions(roleNames)` 合并多角色权限,存入 JWT 4. 在 `proxy.ts` middleware 中通过 `token.permissions` 检查路由访问权限 -5. 在 `shared/lib/auth-guard.ts` 中通过 `requirePermission(permission)` 在 Server Action 层断言权限(如 audit-logs 页面使用 `requirePermission(AUDIT_LOG_READ)`;`DELETE /api/files/[id]` 使用 `requirePermission(FILE_DELETE)`;messaging/actions.ts 使用 `requirePermission(MESSAGE_SEND/READ/DELETE)`;attendance/actions.ts 使用 `requirePermission(ATTENDANCE_MANAGE/READ)`;users/actions.ts 的 `importUsersAction`/`exportUsersAction`/`downloadUserTemplateAction` 使用 `requirePermission(USER_MANAGE)`;grades/actions.ts 的 `exportGradesAction` 使用 `requirePermission(GRADE_RECORD_READ)`;`POST /api/import` 使用 `requirePermission(USER_MANAGE)`;scheduling/actions.ts 使用 `requirePermission(SCHEDULE_AUTO/SCHEDULE_ADJUST)`) +5. 在 `shared/lib/auth-guard.ts` 中通过 `requirePermission(permission)` 在 Server Action 层断言权限(如 audit-logs 页面使用 `requirePermission(AUDIT_LOG_READ)`;`DELETE /api/files/[id]` 使用 `requirePermission(FILE_DELETE)`;messaging/actions.ts 使用 `requirePermission(MESSAGE_SEND/READ/DELETE)`;attendance/actions.ts 使用 `requirePermission(ATTENDANCE_MANAGE/READ)`;users/actions.ts 的 `importUsersAction`/`exportUsersAction`/`downloadUserTemplateAction` 使用 `requirePermission(USER_MANAGE)`;grades/actions.ts 的 `exportGradesAction` 使用 `requirePermission(GRADE_RECORD_READ)`;`POST /api/import` 使用 `requirePermission(USER_MANAGE)`;scheduling/actions.ts 使用 `requirePermission(SCHEDULE_AUTO/SCHEDULE_ADJUST)`;diagnostic/actions.ts 使用 `requirePermission(DIAGNOSTIC_MANAGE/READ)`;elective/actions.ts 使用 `requirePermission(ELECTIVE_MANAGE/READ/SELECT)`) 6. 在 `shared/hooks/use-permission.ts` 中通过 `hasPermission(permission)` 在客户端组件中条件渲染 -7. 在 `layout/config/navigation.ts` 中作为 `NavItem.permission` 字段过滤侧边栏菜单(Audit Logs 菜单项使用 `Permissions.AUDIT_LOG_READ`;Messages 菜单项使用 `Permissions.MESSAGE_READ`;Attendance 菜单项 teacher 使用 `Permissions.ATTENDANCE_MANAGE`,student/parent 使用 `Permissions.ATTENDANCE_READ`;Import Users 菜单项使用 `Permissions.USER_MANAGE`;Scheduling 菜单项 admin 使用 `Permissions.SCHEDULE_ADJUST`/`SCHEDULE_AUTO`;teacher Schedule Changes 菜单项使用 `Permissions.SCHEDULE_ADJUST`) +7. 在 `layout/config/navigation.ts` 中作为 `NavItem.permission` 字段过滤侧边栏菜单(Audit Logs 菜单项使用 `Permissions.AUDIT_LOG_READ`;Messages 菜单项使用 `Permissions.MESSAGE_READ`;Attendance 菜单项 teacher 使用 `Permissions.ATTENDANCE_MANAGE`,student/parent 使用 `Permissions.ATTENDANCE_READ`;Import Users 菜单项使用 `Permissions.USER_MANAGE`;Scheduling 菜单项 admin 使用 `Permissions.SCHEDULE_ADJUST`/`SCHEDULE_AUTO`;teacher Schedule Changes 菜单项使用 `Permissions.SCHEDULE_ADJUST`;teacher/student Diagnostic 菜单项使用 `Permissions.DIAGNOSTIC_READ`;Electives 菜单项 admin/teacher 使用 `Permissions.ELECTIVE_MANAGE`,student 使用 `Permissions.ELECTIVE_SELECT`) ### `DataScope` 1. 由 `auth-guard.ts` 的 `resolveDataScope(userId, roles)` 根据用户角色和 DB 关系动态计算 @@ -2652,6 +2927,8 @@ 7. 在 `parent/children/[studentId]/page.tsx` 中通过 `ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)` 二次校验家长拥有该子女 8. 传递到 `attendance/data-access.getAttendanceRecords({ scope })` 进行行级过滤(class_taught 按教师班级过滤,children 按子女过滤,class_members 仅查自己,all 查全部) 9. 在 `attendance/actions.ts` 的 `getStudentAttendanceAction` 中对 class_members/children 进行 DataScope 二次校验 +10. 传递到 `elective/data-access.getElectiveCourses({ scope, currentUserId })` 进行行级过滤(owned/class_taught 按 `teacherId` 过滤,grade_managed 按 `gradeIds` 过滤,class_members/children 返回 null 由 `getAvailableCoursesForStudent` 处理) +11. 在 `elective/actions.ts` 的 `getStudentSelectionsAction` 中对 class_members/children 进行 DataScope 二次校验 --- @@ -2761,6 +3038,7 @@ |------|------|------|------|------| | `/teacher/exams` | ExamDataTable | server | exam:read | 考试列表(dataAccess: exams/data-access.getExams) | | `/teacher/exams/[id]/build` | ExamAssemblyPanel | client | exam:update | 组卷页面(components: assembly/*, actions: updateExamAction) | +| `/teacher/exams/[id]/proctoring` | ProctoringDashboard | server+client | exam:proctor | 教师监考面板(dataAccess: proctoring/data-access.getExamForProctoring,getExamProctoringSummary,getStudentProctoringStatuses,getRecentProctoringEvents;权限:requirePermission(EXAM_PROCTOR);组件:proctoring/components/proctoring-dashboard.tsx,10 秒轮询刷新) | | `/teacher/exams/grading` | ExamGradingList | server | exam:read | 考试批改列表 | | `/teacher/exams/grading/[submissionId]` | ExamGradingView | client | exam:read | 考试批改页面 | | `/teacher/classes/my/[id]` | ClassDetail | server | class:read | 班级详情(dataAccess: classes/data-access.getClassDetails) | @@ -2822,6 +3100,25 @@ | `/settings` | 角色分发设置页 | server | auth_required | 根据权限渲染 Admin/Teacher/Student 设置视图(含 General/Appearance/Security/Notifications tab,Security tab 含 PasswordChangeForm,Notifications tab 含 NotificationPreferencesForm;dataAccess: messaging/notification-preferences.getNotificationPreferences) | | `/settings/security` | SecuritySettingsPage | server | auth_required | 安全设置独立页面(PasswordChangeForm + 安全提示;权限:requireAuth()) | +### diagnostic/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/teacher/diagnostic` | ReportList | client | diagnostic:read | 学情诊断报告列表(reportType/status 过滤器[URL searchParams];dataAccess: diagnostic/data-access-reports.getDiagnosticReports;actions: publishReportAction, deleteReportAction[DIAGNOSTIC_MANAGE];权限:requirePermission(DIAGNOSTIC_READ);DataScope.class_members 仅查看自己报告) | +| `/teacher/diagnostic/student/[studentId]` | StudentDiagnosticView | client | diagnostic:read | 学生学情诊断视图(概览卡片+雷达图+强项/弱项+生成报告[DIAGNOSTIC_MANAGE]+最新报告;dataAccess: getStudentMasterySummary, getKnowledgePointStats[班级平均对比], getDiagnosticReports;actions: generateStudentReportAction;权限:getAuthContext + DataScope 二次校验,class_members 仅自己,children 仅子女) | +| `/teacher/diagnostic/class/[classId]` | ClassDiagnosticView | client | diagnostic:read | 班级学情诊断视图(概览+知识点热力图+排名表+需重点关注学生+生成班级报告[DIAGNOSTIC_MANAGE];dataAccess: getClassMasterySummary;actions: generateClassReportAction;权限:getAuthContext + DataScope 校验,class_taught 必须包含 classId,class_members/children → notFound) | +| `/student/diagnostic` | StudentDiagnosticView | client | diagnostic:read | 学生本人学情诊断视图(概览+雷达图+强项/弱项+最新报告;dataAccess: getStudentMasterySummary(ctx.userId), getDiagnosticReports(studentId=ctx.userId);权限:requirePermission(DIAGNOSTIC_READ),DataScope.class_members 仅查自己) | + +### elective/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/admin/elective` | ElectiveCourseList | server | elective:manage | 管理员选修课程列表(dataAccess: getElectiveCourses(scope=all);actions: deleteElectiveCourseAction, openSelectionAction, closeSelectionAction, runLotteryAction;权限:requirePermission(ELECTIVE_MANAGE)) | +| `/admin/elective/create` | ElectiveCourseForm | client | elective:manage | 创建选修课程(actions: createElectiveCourseAction;dataAccess: getSubjectOptions;权限:requirePermission(ELECTIVE_MANAGE)) | +| `/admin/elective/[id]/edit` | ElectiveCourseForm (edit) | client | elective:manage | 编辑选修课程(actions: updateElectiveCourseAction;dataAccess: getElectiveCourseById, getSubjectOptions;权限:requirePermission(ELECTIVE_MANAGE)) | +| `/teacher/elective` | ElectiveCourseList (teacher) | server | elective:manage | 教师选修课程列表(dataAccess: getElectiveCourses(scope=class_taught/owned, currentUserId);actions: deleteElectiveCourseAction, openSelectionAction, closeSelectionAction, runLotteryAction;权限:requirePermission(ELECTIVE_MANAGE);按 teacherId 过滤) | +| `/student/elective` | StudentSelectionView | server | elective:select | 学生选课页面(dataAccess: getAvailableCoursesForStudent, getStudentSelections;actions: selectCourseAction, dropCourseAction;权限:requirePermission(ELECTIVE_SELECT)) | + ### API 路由(含速率限制) | 路由 | 方法 | 限流规则 | 说明 | diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index e5a8273..e15c02a 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -65,15 +65,20 @@ "MESSAGE_READ": "message:read", "MESSAGE_DELETE": "message:delete", "SCHEDULE_AUTO": "schedule:auto", - "SCHEDULE_ADJUST": "schedule:adjust" + "SCHEDULE_ADJUST": "schedule:adjust", + "DIAGNOSTIC_MANAGE": "diagnostic:manage", + "DIAGNOSTIC_READ": "diagnostic:read", + "ELECTIVE_MANAGE": "elective:manage", + "ELECTIVE_READ": "elective:read", + "ELECTIVE_SELECT": "elective:select" }, "rolePermissions": { - "admin": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","TEXTBOOK_DELETE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_DELETE","CLASS_ENROLL","CLASS_SCHEDULE","SCHOOL_MANAGE","GRADE_MANAGE","USER_MANAGE","AI_CHAT","AI_CONFIGURE","SETTINGS_ADMIN","AUDIT_LOG_READ","ANNOUNCEMENT_MANAGE","ANNOUNCEMENT_READ","GRADE_RECORD_MANAGE","GRADE_RECORD_READ","FILE_UPLOAD","FILE_READ","FILE_DELETE","COURSE_PLAN_MANAGE","COURSE_PLAN_READ","ATTENDANCE_MANAGE","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","SCHEDULE_AUTO","SCHEDULE_ADJUST"], - "teacher": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","CLASS_ENROLL","CLASS_SCHEDULE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_MANAGE","GRADE_RECORD_READ","FILE_UPLOAD","FILE_READ","FILE_DELETE","COURSE_PLAN_READ","ATTENDANCE_MANAGE","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"], - "student": ["EXAM_READ","HOMEWORK_SUBMIT","QUESTION_READ","TEXTBOOK_READ","CLASS_READ","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","FILE_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_READ","MESSAGE_DELETE"], + "admin": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","TEXTBOOK_DELETE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_DELETE","CLASS_ENROLL","CLASS_SCHEDULE","SCHOOL_MANAGE","GRADE_MANAGE","USER_MANAGE","AI_CHAT","AI_CONFIGURE","SETTINGS_ADMIN","AUDIT_LOG_READ","ANNOUNCEMENT_MANAGE","ANNOUNCEMENT_READ","GRADE_RECORD_MANAGE","GRADE_RECORD_READ","FILE_UPLOAD","FILE_READ","FILE_DELETE","COURSE_PLAN_MANAGE","COURSE_PLAN_READ","ATTENDANCE_MANAGE","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","SCHEDULE_AUTO","SCHEDULE_ADJUST","DIAGNOSTIC_MANAGE","DIAGNOSTIC_READ","ELECTIVE_MANAGE","ELECTIVE_READ"], + "teacher": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","CLASS_ENROLL","CLASS_SCHEDULE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_MANAGE","GRADE_RECORD_READ","FILE_UPLOAD","FILE_READ","FILE_DELETE","COURSE_PLAN_READ","ATTENDANCE_MANAGE","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","DIAGNOSTIC_MANAGE","DIAGNOSTIC_READ","ELECTIVE_MANAGE","ELECTIVE_READ"], + "student": ["EXAM_READ","HOMEWORK_SUBMIT","QUESTION_READ","TEXTBOOK_READ","CLASS_READ","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","FILE_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_READ","MESSAGE_DELETE","DIAGNOSTIC_READ","ELECTIVE_SELECT","ELECTIVE_READ"], "parent": ["EXAM_READ","TEXTBOOK_READ","CLASS_READ","ANNOUNCEMENT_READ","GRADE_RECORD_READ","FILE_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"], - "grade_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_ENROLL","CLASS_SCHEDULE","GRADE_MANAGE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"], - "teaching_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","GRADE_MANAGE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"] + "grade_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_ENROLL","CLASS_SCHEDULE","GRADE_MANAGE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","DIAGNOSTIC_MANAGE","DIAGNOSTIC_READ","ELECTIVE_READ"], + "teaching_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","GRADE_MANAGE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","DIAGNOSTIC_READ","ELECTIVE_READ"] }, "dataScopeTypes": { "all": "管理员:无过滤", @@ -419,7 +424,11 @@ "attendanceRules": {"fields": ["id","classId","lateThresholdMinutes","earlyLeaveThresholdMinutes","enableAutoMark","createdAt","updatedAt"], "usedBy": ["attendance"]}, "schedulingRules": {"fields": ["id","classId","maxDailyHours","maxContinuousHours","lunchBreakStart","lunchBreakEnd","morningStart","afternoonEnd","avoidBackToBack","balancedSubjects","createdAt","updatedAt"], "usedBy": ["scheduling"]}, "scheduleChanges": {"fields": ["id","originalScheduleId","classId","originalTeacherId","substituteTeacherId","originalDate","newDate","newStartTime","newEndTime","reason","status","requestedBy","approvedBy","createdAt","updatedAt"], "usedBy": ["scheduling"]}, - "passwordSecurity": {"fields": ["id","userId","failedLoginAttempts","lockedUntil","passwordChangedAt","mustChangePassword","lastPasswordChange","createdAt","updatedAt"], "usedBy": ["auth","settings"]} + "passwordSecurity": {"fields": ["id","userId","failedLoginAttempts","lockedUntil","passwordChangedAt","mustChangePassword","lastPasswordChange","createdAt","updatedAt"], "usedBy": ["auth","settings"]}, + "knowledgePointMastery": {"fields": ["id","studentId","knowledgePointId","masteryLevel","totalQuestions","correctQuestions","lastAssessedAt","createdAt","updatedAt"], "usedBy": ["diagnostic"], "description": "知识点掌握度记录(复合主键 studentId+knowledgePointId,onDuplicateKeyUpdate upsert)"}, + "learningDiagnosticReports": {"fields": ["id","studentId","generatedBy","reportType","period","summary","strengths","weaknesses","recommendations","overallScore","status","createdAt","updatedAt"], "usedBy": ["diagnostic"], "description": "学情诊断报告(reportType: individual/class/grade;status: draft/published/archived)"}, + "electiveCourses": {"fields": ["id","name","subjectId","teacherId","gradeId","description","capacity","enrolledCount","classroom","schedule","startDate","endDate","selectionStartAt","selectionEndAt","status","selectionMode","credit","createdAt","updatedAt"], "usedBy": ["elective"], "description": "选修课程(status: draft/open/closed/cancelled;selectionMode: fcfs/lottery)"}, + "courseSelections": {"fields": ["id","courseId","studentId","status","priority","selectedAt","enrolledAt","droppedAt","lotteryRank","createdAt","updatedAt"], "usedBy": ["elective"], "description": "选课记录(复合主键 courseId+studentId;status: selected/enrolled/waitlist/dropped/rejected)"} } }, "auth": { @@ -909,7 +918,7 @@ {"name": "NavItem", "type": "type", "definition": "{ title, href, icon?, permission? }", "usedBy": ["NAV_CONFIG", "AppSidebar"]} ], "config": [ - {"name": "NAV_CONFIG", "type": "Record", "note": "每个NavItem含permission字段用于权限过滤。admin角色菜单包含Audit Logs项(icon: ScrollText, href: /admin/audit-logs, permission: AUDIT_LOG_READ),含子项Operation Logs与Login Logs。admin角色菜单的School Management子菜单包含Import Users项(href: /admin/users/import, permission: USER_MANAGE)。admin角色菜单包含Scheduling项(icon: CalendarClock, href: /admin/scheduling/rules, permission: SCHEDULE_ADJUST),含子项Rules(/admin/scheduling/rules, permission: SCHEDULE_ADJUST)、Auto Schedule(/admin/scheduling/auto, permission: SCHEDULE_AUTO)、Change Requests(/admin/scheduling/changes, permission: SCHEDULE_ADJUST)。teacher角色菜单包含Grades项(icon: GraduationCap, permission: GRADE_RECORD_READ),含子项All Grades(/teacher/grades)、Batch Entry(/teacher/grades/entry, permission: GRADE_RECORD_MANAGE)、Statistics(/teacher/grades/stats)。teacher角色菜单包含Schedule Changes项(icon: CalendarClock, href: /teacher/schedule-changes, permission: SCHEDULE_ADJUST)。student角色菜单包含My Grades项(icon: GraduationCap, href: /student/grades, permission: GRADE_RECORD_READ)。parent角色菜单包含Dashboard项(icon: LayoutDashboard, href: /parent/dashboard,无permission字段仅需登录)、Grades项(icon: GraduationCap, href: /parent/grades, permission: GRADE_RECORD_READ)、Announcements项(icon: Megaphone, href: /announcements, permission: ANNOUNCEMENT_READ)"} + {"name": "NAV_CONFIG", "type": "Record", "note": "每个NavItem含permission字段用于权限过滤。admin角色菜单包含Audit Logs项(icon: ScrollText, href: /admin/audit-logs, permission: AUDIT_LOG_READ),含子项Operation Logs与Login Logs。admin角色菜单的School Management子菜单包含Import Users项(href: /admin/users/import, permission: USER_MANAGE)。admin角色菜单包含Scheduling项(icon: CalendarClock, href: /admin/scheduling/rules, permission: SCHEDULE_ADJUST),含子项Rules(/admin/scheduling/rules, permission: SCHEDULE_ADJUST)、Auto Schedule(/admin/scheduling/auto, permission: SCHEDULE_AUTO)、Change Requests(/admin/scheduling/changes, permission: SCHEDULE_ADJUST)。teacher角色菜单包含Grades项(icon: GraduationCap, permission: GRADE_RECORD_READ),含子项All Grades(/teacher/grades)、Batch Entry(/teacher/grades/entry, permission: GRADE_RECORD_MANAGE)、Statistics(/teacher/grades/stats)。teacher角色菜单包含Schedule Changes项(icon: CalendarClock, href: /teacher/schedule-changes, permission: SCHEDULE_ADJUST)。teacher角色菜单包含Diagnostic项(icon: Stethoscope, href: /teacher/diagnostic, permission: DIAGNOSTIC_READ)。student角色菜单包含My Grades项(icon: GraduationCap, href: /student/grades, permission: GRADE_RECORD_READ)。student角色菜单包含Diagnostic项(icon: Stethoscope, href: /student/diagnostic, permission: DIAGNOSTIC_READ)。parent角色菜单包含Dashboard项(icon: LayoutDashboard, href: /parent/dashboard,无permission字段仅需登录)、Grades项(icon: GraduationCap, href: /parent/grades, permission: GRADE_RECORD_READ)、Announcements项(icon: Megaphone, href: /announcements, permission: ANNOUNCEMENT_READ)"} ] } }, @@ -1430,6 +1439,155 @@ { "name": "ScheduleConflictsView", "file": "components/schedule-conflicts-view.tsx", "purpose": "冲突检测视图(班级选择器 + 检测按钮 + 冲突结果列表)" } ] } + }, + "proctoring": { + "path": "src/modules/proctoring", + "description": "考试监考模块:监考模式考试实时监控、防作弊事件采集、教师监考面板、学生端防作弊监控、考试模式配置", + "exports": { + "actions": [ + {"name": "recordProctoringEventAction", "permission": "requireAuth()", "signature": "(prevState: ActionState<{id:string}> | null, formData: FormData) => Promise>", "purpose": "学生端上报监考事件(含 submission 归属校验)", "deps": ["requireAuth", "shared/db", "data-access.recordProctoringEvent"], "usedBy": ["anti-cheat-monitor.tsx"]}, + {"name": "getProctoringDashboardAction", "permission": "EXAM_PROCTOR", "signature": "(examId: string) => Promise>", "purpose": "获取监考面板数据(摘要+学生状态+最近事件)", "deps": ["requirePermission(EXAM_PROCTOR)", "data-access.getExamForProctoring,getExamProctoringSummary,getStudentProctoringStatuses,getRecentProctoringEvents"], "usedBy": ["proctoring-dashboard.tsx"]} + ], + "dataAccess": [ + {"name": "recordProctoringEvent", "signature": "(input: RecordProctoringEventInput) => Promise", "purpose": "记录一条监考事件", "usedBy": ["actions.recordProctoringEventAction", "api/proctoring/event/route.ts"]}, + {"name": "getProctoringEvents", "signature": "(examId: string, filters?: GetProctoringEventsFilters) => Promise", "purpose": "查询考试监考事件(含学生姓名、考试标题)", "usedBy": ["待扩展"]}, + {"name": "getProctoringEventsBySubmission", "signature": "(submissionId: string) => Promise", "purpose": "查询提交的监考事件", "usedBy": ["待扩展"]}, + {"name": "getExamProctoringSummary", "signature": "(examId: string) => Promise", "purpose": "获取考试监考摘要", "usedBy": ["actions.getProctoringDashboardAction", "teacher/exams/[id]/proctoring/page.tsx"]}, + {"name": "getStudentProctoringStatuses", "signature": "(examId: string) => Promise", "purpose": "获取所有学生监考状态", "usedBy": ["actions.getProctoringDashboardAction", "teacher/exams/[id]/proctoring/page.tsx"]}, + {"name": "getExamForProctoring", "signature": "(examId: string) => Promise<{id,title,examMode,config} | null>", "purpose": "获取考试信息(含 examMode 设置)", "usedBy": ["actions.getProctoringDashboardAction", "teacher/exams/[id]/proctoring/page.tsx"]}, + {"name": "getRecentProctoringEvents", "signature": "(examId: string, limit?: number) => Promise", "purpose": "获取最近 N 条监考事件", "usedBy": ["actions.getProctoringDashboardAction", "teacher/exams/[id]/proctoring/page.tsx"]} + ], + "types": [ + {"name": "ProctoringEventType", "type": "type", "definition": "\"tab_switch\" | \"window_blur\" | \"copy_attempt\" | \"paste_attempt\" | \"right_click\" | \"devtools_open\" | \"fullscreen_exit\" | \"idle_timeout\""}, + {"name": "ExamMode", "type": "type", "definition": "\"homework\" | \"timed\" | \"proctored\""}, + {"name": "ProctoringEvent", "type": "interface", "definition": "{ id, submissionId, studentId, examId, eventType, eventDetail?, occurredAt, createdAt }"}, + {"name": "ProctoringEventWithDetails", "type": "interface", "definition": "ProctoringEvent & { studentName, examTitle }"}, + {"name": "ExamProctoringSummary", "type": "interface", "definition": "{ examId, examTitle, examMode, totalStudents, startedStudents, submittedStudents, totalEvents, abnormalStudents, eventsByType }"}, + {"name": "StudentProctoringStatus", "type": "interface", "definition": "{ studentId, studentName, submissionId, submissionStatus, eventCount, lastEventAt, isAbnormal, eventsByType }"}, + {"name": "ProctoringDashboardData", "type": "interface", "definition": "{ summary, students, recentEvents }"}, + {"name": "ExamModeConfig", "type": "interface", "definition": "{ examMode, durationMinutes, shuffleQuestions, allowLateStart, lateStartGraceMinutes, antiCheatEnabled }"}, + {"name": "PROCTORING_EVENT_LABELS", "type": "const", "description": "事件类型中文标签常量"}, + {"name": "EXAM_MODE_LABELS", "type": "const", "description": "考试模式中文标签常量"}, + {"name": "ABNORMAL_EVENT_THRESHOLD", "type": "const", "description": "异常学生事件数阈值(3)"} + ], + "components": [ + {"name": "ProctoringDashboard", "file": "components/proctoring-dashboard.tsx", "purpose": "教师监考面板(实时学生状态、异常事件统计、异常学生高亮、10 秒轮询、usePermission 权限控制)"}, + {"name": "AntiCheatMonitor", "file": "components/anti-cheat-monitor.tsx", "purpose": "学生端防作弊监控(visibilitychange/blur/copy/paste/contextmenu/keydown/fullscreenchange 监听、空闲超时检测、强制全屏、警告提示、事件上报)"}, + {"name": "ExamModeConfig", "file": "components/exam-mode-config.tsx", "purpose": "考试模式配置(react-hook-form Controller,作业/限时/监考模式选择,限时设置时长,监考设置防作弊选项)"} + ] + } + }, + "diagnostic": { + "path": "src/modules/diagnostic", + "description": "学情诊断报告模块:基于知识点掌握度(knowledgePointMastery)生成个人/班级诊断报告,掌握度雷达图(学生 vs 班级平均),强项/弱项分析,知识点掌握度热力图,需重点关注学生列表,报告发布/删除管理", + "exports": { + "dataAccess": [ + { "name": "getStudentMastery", "signature": "(studentId: string) => Promise", "file": "data-access.ts", "purpose": "获取学生在所有知识点的掌握度(含知识点名称,按掌握度降序)", "deps": ["shared.db", "shared.db.schema.knowledgePointMastery", "shared.db.schema.knowledgePoints"], "usedBy": ["data-access.getStudentMasterySummary", "teacher/diagnostic/student/[studentId]/page.tsx"] }, + { "name": "getStudentMasterySummary", "signature": "(studentId: string) => Promise", "file": "data-access.ts", "purpose": "获取学生掌握度摘要(平均掌握度、强项≥80%、弱项<60%)", "deps": ["shared.db", "shared.db.schema.users", "data-access.getStudentMastery"], "usedBy": ["data-access-reports.generateDiagnosticReport", "teacher/diagnostic/student/[studentId]/page.tsx", "student/diagnostic/page.tsx"] }, + { "name": "updateMasteryFromSubmission", "signature": "(submissionId: string) => Promise", "file": "data-access.ts", "purpose": "从提交答案更新掌握度(按知识点聚合正确率,onDuplicateKeyUpdate upsert)", "deps": ["shared.db", "shared.db.schema.examSubmissions", "shared.db.schema.submissionAnswers", "shared.db.schema.questionsToKnowledgePoints", "shared.db.schema.knowledgePointMastery"], "usedBy": ["待扩展(作业/考试提交后触发)"] }, + { "name": "getClassMasterySummary", "signature": "(classId: string) => Promise", "file": "data-access.ts", "purpose": "获取班级掌握度摘要(学生数、平均掌握度、知识点统计、需重点关注学生)", "deps": ["shared.db", "shared.db.schema.classes", "shared.db.schema.classEnrollments", "shared.db.schema.users", "shared.db.schema.knowledgePointMastery", "shared.db.schema.knowledgePoints"], "usedBy": ["data-access-reports.generateClassDiagnosticReport", "teacher/diagnostic/class/[classId]/page.tsx"] }, + { "name": "getKnowledgePointStats", "signature": "(classId?: string, gradeId?: string) => Promise", "file": "data-access.ts", "purpose": "获取知识点统计(按班级或年级聚合平均掌握度、掌握人数、未掌握人数)", "deps": ["shared.db", "shared.db.schema.classEnrollments", "shared.db.schema.users", "shared.db.schema.knowledgePointMastery", "shared.db.schema.knowledgePoints"], "usedBy": ["teacher/diagnostic/student/[studentId]/page.tsx (班级平均对比)"] }, + { "name": "generateDiagnosticReport", "signature": "(studentId: string, period: string, generatedBy: string) => Promise", "file": "data-access-reports.ts", "purpose": "生成个人诊断报告(计算 overallScore、强项/弱项列表、复习建议,status=draft)", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports", "data-access.getStudentMasterySummary", "@paralleldrive/cuid2"], "usedBy": ["actions.generateStudentReportAction"] }, + { "name": "generateClassDiagnosticReport", "signature": "(classId: string, period: string, generatedBy: string) => Promise", "file": "data-access-reports.ts", "purpose": "生成班级诊断报告(聚合班级掌握度,识别薄弱知识点,status=draft,studentId 存生成者 ID)", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports", "data-access.getClassMasterySummary", "@paralleldrive/cuid2"], "usedBy": ["actions.generateClassReportAction"] }, + { "name": "getDiagnosticReports", "signature": "(filters: DiagnosticReportQueryParams) => Promise", "file": "data-access-reports.ts", "purpose": "查询诊断报告列表(可按 studentId/reportType/status/period 过滤,含学生名和生成者名)", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports", "shared.db.schema.users"], "usedBy": ["actions.getDiagnosticReportsAction", "teacher/diagnostic/page.tsx", "teacher/diagnostic/student/[studentId]/page.tsx", "student/diagnostic/page.tsx"] }, + { "name": "getDiagnosticReportById", "signature": "(id: string) => Promise", "file": "data-access-reports.ts", "purpose": "获取报告详情(含学生名和生成者名)", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports", "shared.db.schema.users"], "usedBy": ["actions.getDiagnosticReportByIdAction"] }, + { "name": "publishDiagnosticReport", "signature": "(id: string) => Promise", "file": "data-access-reports.ts", "purpose": "发布诊断报告(status=published)", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports"], "usedBy": ["actions.publishReportAction"] }, + { "name": "deleteDiagnosticReport", "signature": "(id: string) => Promise", "file": "data-access-reports.ts", "purpose": "删除诊断报告", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports"], "usedBy": ["actions.deleteReportAction"] } + ], + "actions": [ + { "name": "generateStudentReportAction", "permission": "DIAGNOSTIC_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "生成学生个人诊断报告(formData: studentId, period)", "deps": ["requirePermission", "data-access-reports.generateDiagnosticReport", "revalidatePath"], "usedBy": ["components/student-diagnostic-view.tsx"] }, + { "name": "generateClassReportAction", "permission": "DIAGNOSTIC_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "生成班级诊断报告(formData: classId, period)", "deps": ["requirePermission", "data-access-reports.generateClassDiagnosticReport", "revalidatePath"], "usedBy": ["components/class-diagnostic-view.tsx"] }, + { "name": "publishReportAction", "permission": "DIAGNOSTIC_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "发布诊断报告(formData: id)", "deps": ["requirePermission", "data-access-reports.publishDiagnosticReport", "revalidatePath"], "usedBy": ["components/report-list.tsx"] }, + { "name": "deleteReportAction", "permission": "DIAGNOSTIC_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "删除诊断报告(formData: id)", "deps": ["requirePermission", "data-access-reports.deleteDiagnosticReport", "revalidatePath"], "usedBy": ["components/report-list.tsx"] }, + { "name": "getDiagnosticReportsAction", "permission": "DIAGNOSTIC_READ", "signature": "(params: DiagnosticReportQueryParams) => Promise>", "file": "actions.ts", "purpose": "查询诊断报告列表(读权限)", "deps": ["requirePermission", "data-access-reports.getDiagnosticReports"], "usedBy": ["待扩展"] }, + { "name": "getDiagnosticReportByIdAction", "permission": "DIAGNOSTIC_READ", "signature": "(id: string) => Promise>", "file": "actions.ts", "purpose": "获取诊断报告详情(读权限)", "deps": ["requirePermission", "data-access-reports.getDiagnosticReportById"], "usedBy": ["待扩展"] } + ], + "types": [ + { "name": "DiagnosticReportType", "type": "type", "file": "types.ts", "definition": "\"individual\" | \"class\" | \"grade\"", "usedBy": ["types.DiagnosticReport.reportType", "actions", "components/report-list.tsx"] }, + { "name": "DiagnosticReportStatus", "type": "type", "file": "types.ts", "definition": "\"draft\" | \"published\" | \"archived\"", "usedBy": ["types.DiagnosticReport.status", "actions", "components/report-list.tsx"] }, + { "name": "KnowledgePointMastery", "type": "interface", "file": "types.ts", "definition": "{ id, studentId, knowledgePointId, masteryLevel(0-100), totalQuestions, correctQuestions, lastAssessedAt, createdAt, updatedAt }", "usedBy": ["data-access", "types.MasteryWithKnowledgePoint"] }, + { "name": "MasteryWithKnowledgePoint", "type": "interface", "file": "types.ts", "definition": "KnowledgePointMastery & { knowledgePointName, knowledgePointDescription }", "usedBy": ["data-access.getStudentMastery", "types.StudentMasterySummary"] }, + { "name": "StudentMasterySummary", "type": "interface", "file": "types.ts", "definition": "{ studentId, studentName, averageMastery, totalKnowledgePoints, strengths(≥80), weaknesses(<60), allMastery }", "usedBy": ["data-access.getStudentMasterySummary", "data-access-reports.generateDiagnosticReport", "components/student-diagnostic-view.tsx"] }, + { "name": "DiagnosticReport", "type": "interface", "file": "types.ts", "definition": "{ id, studentId, generatedBy, reportType, period, summary, strengths[], weaknesses[], recommendations[], overallScore, status, createdAt, updatedAt }", "usedBy": ["data-access-reports", "types.DiagnosticReportWithDetails"] }, + { "name": "DiagnosticReportWithDetails", "type": "interface", "file": "types.ts", "definition": "DiagnosticReport & { studentName, generatedByName }", "usedBy": ["data-access-reports.getDiagnosticReports", "actions", "components/report-list.tsx", "components/student-diagnostic-view.tsx"] }, + { "name": "ClassMasterySummary", "type": "interface", "file": "types.ts", "definition": "{ classId, className, studentCount, averageMastery, knowledgePointStats[], studentsNeedingAttention[] }", "usedBy": ["data-access.getClassMasterySummary", "data-access-reports.generateClassDiagnosticReport", "components/class-diagnostic-view.tsx"] }, + { "name": "KnowledgePointStat", "type": "interface", "file": "types.ts", "definition": "{ knowledgePointId, knowledgePointName, averageMastery, masteredCount(≥80), notMasteredCount(<60), totalStudents }", "usedBy": ["data-access.getKnowledgePointStats", "types.ClassMasterySummary", "components/class-diagnostic-view.tsx"] }, + { "name": "DiagnosticReportQueryParams", "type": "interface", "file": "types.ts", "definition": "{ studentId?, reportType?, status?, period? }", "usedBy": ["data-access-reports.getDiagnosticReports", "actions.getDiagnosticReportsAction"] }, + { "name": "MasteryRadarPoint", "type": "interface", "file": "types.ts", "definition": "{ knowledgePoint, student(0-100), classAverage?(0-100) }", "usedBy": ["components/mastery-radar-chart.tsx", "components/student-diagnostic-view.tsx", "teacher/diagnostic/student/[studentId]/page.tsx"] } + ], + "components": [ + { "name": "MasteryRadarChart", "file": "components/mastery-radar-chart.tsx", "purpose": "知识点掌握度雷达图(recharts RadarChart,学生 vs 班级平均对比,无数据时显示 EmptyState)", "deps": ["recharts", "shared/components/ui/card", "shared/components/ui/chart", "shared/components/ui/empty-state"] }, + { "name": "StudentDiagnosticView", "file": "components/student-diagnostic-view.tsx", "purpose": "学生诊断视图(概览卡片、雷达图、强项/弱项列表、生成报告表单[DIAGNOSTIC_MANAGE]、最新报告与建议展示)", "deps": ["usePermission", "actions.generateStudentReportAction", "components/mastery-radar-chart", "shared/components/ui/*"] }, + { "name": "ClassDiagnosticView", "file": "components/class-diagnostic-view.tsx", "purpose": "班级诊断视图(概览卡片、知识点掌握度热力图[绿/黄/橙/红]、知识点排名表、需重点关注学生表[链接到学生视图]、生成班级报告表单[DIAGNOSTIC_MANAGE])", "deps": ["usePermission", "actions.generateClassReportAction", "shared/components/ui/*"] }, + { "name": "ReportList", "file": "components/report-list.tsx", "purpose": "诊断报告列表(reportType/status 过滤器[URL searchParams]、报告表格、发布/删除操作[DIAGNOSTIC_MANAGE]、确认对话框)", "deps": ["usePermission", "actions.publishReportAction", "actions.deleteReportAction", "shared/components/ui/*"] } + ] + } + }, + "elective": { + "path": "src/modules/elective", + "description": "选课管理模块:选修课程 CRUD、选课开放/关闭、学生选课/退课、抽签模式批量录取、FCFS 即时录取、DataScope 行级过滤(admin 全部、teacher 所教、grade_head 所管年级、student 可选课程)", + "exports": { + "actions": [ + {"name": "createElectiveCourseAction", "permission": "ELECTIVE_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "创建选修课程(formData: name, subjectId?, teacherId, gradeId?, description?, capacity?, classroom?, schedule?, startDate?, endDate?, selectionStartAt?, selectionEndAt?, selectionMode?, credit?)", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access.createElectiveCourse", "revalidatePath"], "usedBy": ["admin/elective/create/page.tsx"]}, + {"name": "updateElectiveCourseAction", "permission": "ELECTIVE_MANAGE", "signature": "(id: string, prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "更新选修课程", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access.getElectiveCourseById", "data-access.updateElectiveCourse", "revalidatePath"], "usedBy": ["admin/elective/[id]/edit/page.tsx"]}, + {"name": "deleteElectiveCourseAction", "permission": "ELECTIVE_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "删除选修课程(formData: courseId)", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access.deleteElectiveCourse", "revalidatePath"], "usedBy": ["components/elective-course-list.tsx"]}, + {"name": "openSelectionAction", "permission": "ELECTIVE_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "开放选课(formData: courseId)", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access.openSelection", "revalidatePath"], "usedBy": ["components/elective-course-list.tsx"]}, + {"name": "closeSelectionAction", "permission": "ELECTIVE_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "关闭选课(formData: courseId)", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access.closeSelection", "revalidatePath"], "usedBy": ["components/elective-course-list.tsx"]}, + {"name": "runLotteryAction", "permission": "ELECTIVE_MANAGE", "signature": "(prevState: ActionState<{enrolled:number,waitlist:number}> | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "执行抽签录取(formData: courseId)", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access-operations.runLottery", "revalidatePath"], "usedBy": ["components/elective-course-list.tsx"]}, + {"name": "selectCourseAction", "permission": "ELECTIVE_SELECT", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "学生选课(formData: courseId, priority?)", "deps": ["requirePermission(ELECTIVE_SELECT)", "data-access-operations.selectCourse", "revalidatePath"], "usedBy": ["components/student-selection-view.tsx"]}, + {"name": "dropCourseAction", "permission": "ELECTIVE_SELECT", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "purpose": "学生退课(formData: courseId)", "deps": ["requirePermission(ELECTIVE_SELECT)", "data-access-operations.dropCourse", "revalidatePath"], "usedBy": ["components/student-selection-view.tsx"]}, + {"name": "getElectiveCoursesAction", "permission": "ELECTIVE_READ", "signature": "(params?: GetElectiveCoursesParams) => Promise>", "file": "actions.ts", "purpose": "查询选修课程列表(按 DataScope 过滤)", "deps": ["requirePermission(ELECTIVE_READ)", "data-access.getElectiveCourses (scope, currentUserId)"], "usedBy": ["admin/elective/page.tsx", "teacher/elective/page.tsx"]}, + {"name": "getStudentSelectionsAction", "permission": "ELECTIVE_READ", "signature": "(studentId: string) => Promise>", "file": "actions.ts", "purpose": "查询学生选课记录(含 DataScope 二次校验:class_members 仅自己,children 仅子女)", "deps": ["requirePermission(ELECTIVE_READ)", "data-access-selections.getStudentSelections"], "usedBy": ["待扩展"]}, + {"name": "getAvailableCoursesAction", "permission": "ELECTIVE_SELECT", "signature": "() => Promise>", "file": "actions.ts", "purpose": "获取学生可选课程(status=open 且匹配年级)", "deps": ["requirePermission(ELECTIVE_SELECT)", "data-access-selections.getAvailableCoursesForStudent"], "usedBy": ["待扩展"]} + ], + "dataAccess": [ + {"name": "getElectiveCourses", "file": "data-access.ts", "signature": "(params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }) => Promise", "purpose": "查询选修课程列表(按 scope 行级过滤:owned/class_taught 按 teacherId,grade_managed 按 gradeIds)", "usedBy": ["actions.getElectiveCoursesAction", "admin/elective/page.tsx", "teacher/elective/page.tsx"]}, + {"name": "getElectiveCourseById", "file": "data-access.ts", "signature": "(id: string) => Promise", "purpose": "获取课程详情", "usedBy": ["actions.updateElectiveCourseAction", "admin/elective/[id]/edit/page.tsx"]}, + {"name": "createElectiveCourse", "file": "data-access.ts", "signature": "(data: CreateElectiveCourseInput, teacherId: string) => Promise", "purpose": "创建选修课程(status=draft, enrolledCount=0)", "usedBy": ["actions.createElectiveCourseAction"]}, + {"name": "updateElectiveCourse", "file": "data-access.ts", "signature": "(id: string, data: Partial) => Promise", "purpose": "更新选修课程字段", "usedBy": ["actions.updateElectiveCourseAction"]}, + {"name": "deleteElectiveCourse", "file": "data-access.ts", "signature": "(id: string) => Promise", "purpose": "删除选修课程", "usedBy": ["actions.deleteElectiveCourseAction"]}, + {"name": "openSelection", "file": "data-access.ts", "signature": "(courseId: string) => Promise", "purpose": "开放选课(status=open)", "usedBy": ["actions.openSelectionAction"]}, + {"name": "closeSelection", "file": "data-access.ts", "signature": "(courseId: string) => Promise", "purpose": "关闭选课(status=closed)", "usedBy": ["actions.closeSelectionAction"]}, + {"name": "getSubjectOptions", "file": "data-access.ts", "signature": "() => Promise<{id, name}[]>", "purpose": "获取学科选项(按 order, name 排序)", "usedBy": ["admin/elective/create/page.tsx", "admin/elective/[id]/edit/page.tsx"]}, + {"name": "getCourseSelections", "file": "data-access-selections.ts", "signature": "(courseId: string) => Promise", "purpose": "查询课程所有选课记录(按 priority, selectedAt 排序)", "usedBy": ["待扩展"]}, + {"name": "getStudentSelections", "file": "data-access-selections.ts", "signature": "(studentId: string) => Promise", "purpose": "查询学生选课记录(按 selectedAt 降序)", "usedBy": ["actions.getStudentSelectionsAction", "student/elective/page.tsx"]}, + {"name": "getStudentGradeId", "file": "data-access-selections.ts", "signature": "(studentId: string) => Promise", "purpose": "获取学生所在年级 ID(通过 classEnrollments active 记录)", "usedBy": ["data-access-selections.getAvailableCoursesForStudent"]}, + {"name": "getAvailableCoursesForStudent", "file": "data-access-selections.ts", "signature": "(studentId: string, gradeId?: string | null) => Promise", "purpose": "获取学生可选课程(status=open 且 gradeId 匹配或为空)", "usedBy": ["actions.getAvailableCoursesAction", "student/elective/page.tsx"]}, + {"name": "runLottery", "file": "data-access-operations.ts", "signature": "(courseId: string) => Promise<{enrolled: number, waitlist: number}>", "purpose": "抽签录取(随机打乱 selected 记录,前 capacity 名 enrolled,其余 waitlist,课程 status=closed)", "usedBy": ["actions.runLotteryAction"]}, + {"name": "selectCourse", "file": "data-access-operations.ts", "signature": "(courseId: string, studentId: string, priority?: number) => Promise<{status: CourseSelectionStatus, message: string}>", "purpose": "学生选课(校验课程状态/时间窗口/重复选课;FCFS 模式即时 enrolled/waitlist,lottery 模式 selected)", "usedBy": ["actions.selectCourseAction"]}, + {"name": "dropCourse", "file": "data-access-operations.ts", "signature": "(courseId: string, studentId: string) => Promise", "purpose": "学生退课(status=dropped;FCFS 模式自动递补 waitlist 首位)", "usedBy": ["actions.dropCourseAction"]} + ], + "types": [ + {"name": "ElectiveCourseStatus", "type": "type", "file": "types.ts", "definition": "\"draft\" | \"open\" | \"closed\" | \"cancelled\""}, + {"name": "ElectiveSelectionMode", "type": "type", "file": "types.ts", "definition": "\"fcfs\" | \"lottery\""}, + {"name": "CourseSelectionStatus", "type": "type", "file": "types.ts", "definition": "\"selected\" | \"enrolled\" | \"waitlist\" | \"dropped\" | \"rejected\""}, + {"name": "ElectiveCourse", "type": "interface", "file": "types.ts", "definition": "{ id, name, subjectId?, teacherId, gradeId?, description?, capacity, enrolledCount, classroom?, schedule?, startDate?, endDate?, selectionStartAt?, selectionEndAt?, status, selectionMode, credit, createdAt, updatedAt }"}, + {"name": "ElectiveCourseWithDetails", "type": "interface", "file": "types.ts", "definition": "ElectiveCourse & { teacherName?, subjectName?, gradeName? }"}, + {"name": "CourseSelection", "type": "interface", "file": "types.ts", "definition": "{ id, courseId, studentId, status, priority?, selectedAt, enrolledAt?, droppedAt?, lotteryRank?, createdAt, updatedAt }"}, + {"name": "CourseSelectionWithDetails", "type": "interface", "file": "types.ts", "definition": "CourseSelection & { courseName?, studentName?, courseCapacity?, courseEnrolledCount?, courseStatus? }"}, + {"name": "GetElectiveCoursesParams", "type": "interface", "file": "types.ts", "definition": "{ status?, gradeId?, subjectId?, teacherId? }"}, + {"name": "ELECTIVE_STATUS_LABELS", "type": "const", "file": "types.ts", "description": "课程状态标签常量"}, + {"name": "ELECTIVE_STATUS_COLORS", "type": "const", "file": "types.ts", "description": "课程状态颜色常量(Badge variant)"}, + {"name": "SELECTION_MODE_LABELS", "type": "const", "file": "types.ts", "description": "选课模式标签常量"}, + {"name": "COURSE_SELECTION_STATUS_LABELS", "type": "const", "file": "types.ts", "description": "选课状态标签常量"}, + {"name": "COURSE_SELECTION_STATUS_COLORS", "type": "const", "file": "types.ts", "description": "选课状态颜色常量(Badge variant)"} + ], + "schemas": [ + {"name": "ElectiveCourseStatusEnum", "file": "schema.ts", "definition": "z.enum([\"draft\",\"open\",\"closed\",\"cancelled\"])"}, + {"name": "ElectiveSelectionModeEnum", "file": "schema.ts", "definition": "z.enum([\"fcfs\",\"lottery\"])"}, + {"name": "CourseSelectionStatusEnum", "file": "schema.ts", "definition": "z.enum([\"selected\",\"enrolled\",\"waitlist\",\"dropped\",\"rejected\"])"}, + {"name": "CreateElectiveCourseSchema", "file": "schema.ts", "purpose": "创建课程校验(name 必填,teacherId 必填,capacity 1-500 默认 30,selectionMode 默认 fcfs,credit 默认 1.0)"}, + {"name": "UpdateElectiveCourseSchema", "file": "schema.ts", "purpose": "更新课程校验(所有字段可选,含 status)"}, + {"name": "SelectCourseSchema", "file": "schema.ts", "purpose": "选课校验(courseId 必填,priority 1-10 可选)"}, + {"name": "DropCourseSchema", "file": "schema.ts", "purpose": "退课校验(courseId 必填)"}, + {"name": "RunLotterySchema", "file": "schema.ts", "purpose": "抽签校验(courseId 必填)"} + ], + "components": [ + {"name": "ElectiveCourseList", "file": "components/elective-course-list.tsx", "purpose": "课程卡片列表(管理员/教师视图,含编辑/开放/关闭/抽签/删除操作按钮,usePermission 控制权限)", "deps": ["usePermission", "actions.deleteElectiveCourseAction", "actions.openSelectionAction", "actions.closeSelectionAction", "actions.runLotteryAction", "shared/components/ui/*"]}, + {"name": "ElectiveCourseForm", "file": "components/elective-course-form.tsx", "purpose": "课程创建/编辑表单(name, subjectId, teacherId, gradeId, description, capacity, classroom, schedule, dates, selectionMode, credit)", "deps": ["react-hook-form", "actions.createElectiveCourseAction", "actions.updateElectiveCourseAction", "shared/components/ui/*"]}, + {"name": "StudentSelectionView", "file": "components/student-selection-view.tsx", "purpose": "学生选课视图(可选课程列表 + 我的选课记录,含选课/退课按钮)", "deps": ["usePermission", "actions.selectCourseAction", "actions.dropCourseAction", "shared/components/ui/*"]} + ] + } } }, "dependencyMatrix": { @@ -1453,7 +1611,9 @@ "parent": {"dependsOn": ["shared", "auth", "homework", "classes", "grades"], "uses": {"shared": ["db", "auth-guard.requireAuth", "db.schema.parentStudentRelations", "db.schema.users", "db.schema.grades", "db.schema.classEnrollments", "db.schema.classes", "types"], "auth": ["auth"], "homework": ["data-access.getStudentHomeworkAssignments", "data-access.getStudentDashboardGrades"], "classes": ["data-access.getStudentClasses", "data-access.getStudentSchedule"], "grades": ["data-access.getStudentGradeSummary"]}}, "messaging": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.requireAuth", "db.schema.messages", "db.schema.messageNotifications", "db.schema.notificationPreferences", "db.schema.users", "db.schema.classEnrollments", "db.schema.classes", "db.schema.grades", "types.permissions", "types.action-state"], "auth": ["auth"]}}, "attendance": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "db.schema.attendanceRecords", "db.schema.attendanceRules", "db.schema.classEnrollments", "db.schema.users", "db.schema.classes", "types.permissions", "types.action-state", "types.DataScope"], "auth": ["auth"], "classes": ["data-access.getTeacherClasses", "data-access.getAdminClasses"]}}, - "scheduling": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.getAuthContext", "db.schema.schedulingRules", "db.schema.scheduleChanges", "db.schema.classSchedule", "db.schema.classes", "db.schema.users", "db.schema.classSubjectTeachers", "db.schema.subjects", "db.schema.classrooms", "types.permissions", "types.action-state"], "auth": ["auth"], "classes": []}} + "scheduling": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.getAuthContext", "db.schema.schedulingRules", "db.schema.scheduleChanges", "db.schema.classSchedule", "db.schema.classes", "db.schema.users", "db.schema.classSubjectTeachers", "db.schema.subjects", "db.schema.classrooms", "types.permissions", "types.action-state"], "auth": ["auth"], "classes": []}}, + "diagnostic": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.getAuthContext", "db.schema.knowledgePointMastery", "db.schema.learningDiagnosticReports", "db.schema.knowledgePoints", "db.schema.questionsToKnowledgePoints", "db.schema.examSubmissions", "db.schema.submissionAnswers", "db.schema.classEnrollments", "db.schema.classes", "db.schema.users", "types.permissions", "types.action-state", "hooks.usePermission", "components.ui.*"], "auth": ["auth"]}}, + "elective": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "db.schema.electiveCourses", "db.schema.courseSelections", "db.schema.users", "db.schema.subjects", "db.schema.grades", "db.schema.classes", "db.schema.classEnrollments", "types.permissions", "types.action-state", "types.DataScope", "hooks.usePermission", "components.ui.*"], "auth": ["auth"]}} }, "parameterFlowChains": { "userId": { @@ -1469,7 +1629,10 @@ "homework/actions.ts → 作为 creatorId 写入 homeworkAssignments 表", "classes/data-access.ts → getTeacherClasses(teacherId), getGradeManagedClasses(userId)", "grades/actions.ts → 作为 recordedBy 写入 gradeRecords 表", - "attendance/actions.ts → 作为 recordedBy 写入 attendanceRecords 表" + "attendance/actions.ts → 作为 recordedBy 写入 attendanceRecords 表", + "elective/actions.ts → 作为 teacherId 默认值写入 electiveCourses 表(createElectiveCourseAction)", + "elective/actions.ts → 作为 studentId 查询学生选课(selectCourseAction/dropCourseAction/getStudentSelectionsAction)", + "elective/data-access.ts → getElectiveCourses({ scope, currentUserId }) 按 teacherId 过滤(class_taught/owned)" ] }, "examId": { @@ -1501,15 +1664,15 @@ ] }, "permission": { - "origin": "shared/types/permissions.ts Permissions 常量定义(47 个权限点,含 FILE_UPLOAD/FILE_READ/FILE_DELETE、GRADE_RECORD_MANAGE/GRADE_RECORD_READ、ATTENDANCE_MANAGE/ATTENDANCE_READ、MESSAGE_SEND/MESSAGE_READ/MESSAGE_DELETE、SCHEDULE_AUTO/SCHEDULE_ADJUST)", + "origin": "shared/types/permissions.ts Permissions 常量定义(50 个权限点,含 FILE_UPLOAD/FILE_READ/FILE_DELETE、GRADE_RECORD_MANAGE/GRADE_RECORD_READ、ATTENDANCE_MANAGE/ATTENDANCE_READ、MESSAGE_SEND/MESSAGE_READ/MESSAGE_DELETE、SCHEDULE_AUTO/SCHEDULE_ADJUST、ELECTIVE_MANAGE/ELECTIVE_READ/ELECTIVE_SELECT)", "flow": [ - "shared/lib/permissions.ts ROLE_PERMISSIONS → 角色到权限映射(admin/teacher 拥有全部 FILE_* 及 GRADE_RECORD_MANAGE/READ,student/parent/grade_head/teaching_head 拥有 GRADE_RECORD_READ;admin/teacher 拥有 ATTENDANCE_MANAGE+ATTENDANCE_READ,student/parent/grade_head/teaching_head 拥有 ATTENDANCE_READ;admin/teacher/parent/grade_head/teaching_head 拥有 MESSAGE_SEND/READ/DELETE,student 拥有 MESSAGE_READ/DELETE 但无 MESSAGE_SEND;admin 拥有 SCHEDULE_AUTO+SCHEDULE_ADJUST,teacher 无排课权限)", + "shared/lib/permissions.ts ROLE_PERMISSIONS → 角色到权限映射(admin/teacher 拥有全部 FILE_* 及 GRADE_RECORD_MANAGE/READ,student/parent/grade_head/teaching_head 拥有 GRADE_RECORD_READ;admin/teacher 拥有 ATTENDANCE_MANAGE+ATTENDANCE_READ,student/parent/grade_head/teaching_head 拥有 ATTENDANCE_READ;admin/teacher/parent/grade_head/teaching_head 拥有 MESSAGE_SEND/READ/DELETE,student 拥有 MESSAGE_READ/DELETE 但无 MESSAGE_SEND;admin 拥有 SCHEDULE_AUTO+SCHEDULE_ADJUST,teacher 无排课权限;admin/teacher 拥有 ELECTIVE_MANAGE+ELECTIVE_READ,student 拥有 ELECTIVE_SELECT+ELECTIVE_READ,grade_head/teaching_head 拥有 ELECTIVE_READ)", "auth.ts JWT callback → resolvePermissions(roleNames) → token.permissions", "proxy.ts middleware → token.permissions → 路由权限检查", - "auth-guard.ts requirePermission(permission) → Server Action权限断言(如 /api/files/[id] DELETE 使用 FILE_DELETE;grades/actions.ts 使用 GRADE_RECORD_MANAGE/READ;messaging/actions.ts 使用 MESSAGE_SEND/READ/DELETE;attendance/actions.ts 使用 ATTENDANCE_MANAGE/READ;scheduling/actions.ts 使用 SCHEDULE_AUTO/SCHEDULE_ADJUST)", + "auth-guard.ts requirePermission(permission) → Server Action权限断言(如 /api/files/[id] DELETE 使用 FILE_DELETE;grades/actions.ts 使用 GRADE_RECORD_MANAGE/READ;messaging/actions.ts 使用 MESSAGE_SEND/READ/DELETE;attendance/actions.ts 使用 ATTENDANCE_MANAGE/READ;scheduling/actions.ts 使用 SCHEDULE_AUTO/SCHEDULE_ADJUST;elective/actions.ts 使用 ELECTIVE_MANAGE/READ/SELECT)", "auth-guard.ts requireAuth() → 仅校验登录(如 /api/upload POST、/api/files/[id] GET、messaging 通知读取 actions)", - "use-permission.ts hasPermission(permission) → 客户端条件渲染(如 file-list.tsx 删除按钮可见性;message-list/detail.tsx 写消息/删除按钮可见性)", - "layout/config/navigation.ts NavItem.permission → 侧边栏菜单过滤(Grades 菜单项使用 GRADE_RECORD_READ;Messages 菜单项使用 MESSAGE_READ;Attendance 菜单项 teacher 使用 ATTENDANCE_MANAGE,student/parent 使用 ATTENDANCE_READ;Scheduling 菜单项 admin 使用 SCHEDULE_ADJUST/SCHEDULE_AUTO,teacher Schedule Changes 使用 SCHEDULE_ADJUST)" + "use-permission.ts hasPermission(permission) → 客户端条件渲染(如 file-list.tsx 删除按钮可见性;message-list/detail.tsx 写消息/删除按钮可见性;elective-course-list.tsx 操作按钮可见性)", + "layout/config/navigation.ts NavItem.permission → 侧边栏菜单过滤(Grades 菜单项使用 GRADE_RECORD_READ;Messages 菜单项使用 MESSAGE_READ;Attendance 菜单项 teacher 使用 ATTENDANCE_MANAGE,student/parent 使用 ATTENDANCE_READ;Scheduling 菜单项 admin 使用 SCHEDULE_ADJUST/SCHEDULE_AUTO,teacher Schedule Changes 使用 SCHEDULE_ADJUST;Electives 菜单项 admin/teacher 使用 ELECTIVE_MANAGE,student 使用 ELECTIVE_SELECT)" ] }, "dataScope": { @@ -1522,7 +1685,9 @@ "exams/actions.ts update/delete → scope.type !== 'all' 时校验资源归属", "grades/data-access.getGradeRecords({ scope }) → 行级过滤(class_taught 限制所教班级,class_members 限制学生本人,children 限制子女)", "attendance/data-access.getAttendanceRecords({ scope }) → 行级过滤(class_taught 按教师班级过滤,children 按子女过滤,class_members 仅查自己,all 查全部)", - "attendance/actions.ts getStudentAttendanceAction → 对 class_members/children 进行 DataScope 二次校验" + "attendance/actions.ts getStudentAttendanceAction → 对 class_members/children 进行 DataScope 二次校验", + "elective/data-access.getElectiveCourses({ scope, currentUserId }) → 行级过滤(owned/class_taught 按 teacherId 过滤,grade_managed 按 gradeIds 过滤,class_members/children 返回 null 由 getAvailableCoursesForStudent 处理)", + "elective/actions.ts getStudentSelectionsAction → 对 class_members/children 进行 DataScope 二次校验" ] } }, @@ -1555,7 +1720,10 @@ "/admin/users/import": {"component": "UserImportPage (含 UserImportDialog)", "type": "server", "module": "users", "actions": ["users/actions.downloadUserTemplateAction", "users/actions.importUsersAction"], "permission": "user:manage", "description": "用户批量导入页面(说明卡片+字段文档表+导入对话框;权限:requirePermission(USER_MANAGE))"}, "/admin/scheduling/rules": {"component": "SchedulingRulesForm", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getSchedulingRules"], "actions": ["saveSchedulingRulesAction"], "permission": "schedule:adjust", "description": "排课规则配置页面(权限:requirePermission(SCHEDULE_ADJUST))"}, "/admin/scheduling/auto": {"component": "AutoSchedulePanel + AutoScheduleResultView", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling"], "actions": ["autoScheduleAction", "applyAutoScheduleAction"], "permission": "schedule:auto", "description": "自动排课页面(预览+应用;权限:requirePermission(SCHEDULE_AUTO))"}, - "/admin/scheduling/changes": {"component": "ScheduleChangeList + ScheduleConflictsView", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getScheduleChanges"], "actions": ["approveScheduleChangeAction", "rejectScheduleChangeAction", "getClassConflictsAction"], "permission": "schedule:adjust", "description": "调课申请审批+冲突检测页面(权限:requirePermission(SCHEDULE_ADJUST);审批操作需 SCHEDULE_AUTO)"} + "/admin/scheduling/changes": {"component": "ScheduleChangeList + ScheduleConflictsView", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getScheduleChanges"], "actions": ["approveScheduleChangeAction", "rejectScheduleChangeAction", "getClassConflictsAction"], "permission": "schedule:adjust", "description": "调课申请审批+冲突检测页面(权限:requirePermission(SCHEDULE_ADJUST);审批操作需 SCHEDULE_AUTO)"}, + "/admin/elective": {"component": "ElectiveCourseList", "type": "server", "module": "elective", "dataAccess": ["elective/data-access.getElectiveCourses (scope=all)"], "actions": ["deleteElectiveCourseAction", "openSelectionAction", "closeSelectionAction", "runLotteryAction"], "permission": "elective:manage", "description": "管理员选修课程列表(权限:requirePermission(ELECTIVE_MANAGE))"}, + "/admin/elective/create": {"component": "ElectiveCourseForm", "type": "client", "module": "elective", "actions": ["createElectiveCourseAction"], "dataAccess": ["elective/data-access.getSubjectOptions"], "permission": "elective:manage", "description": "创建选修课程(权限:requirePermission(ELECTIVE_MANAGE))"}, + "/admin/elective/[id]/edit": {"component": "ElectiveCourseForm (edit)", "type": "client", "module": "elective", "actions": ["updateElectiveCourseAction"], "dataAccess": ["elective/data-access.getElectiveCourseById", "elective/data-access.getSubjectOptions"], "permission": "elective:manage", "description": "编辑选修课程(权限:requirePermission(ELECTIVE_MANAGE))"} }, "teacher": { "/teacher/dashboard": {"component": "TeacherDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (teacher)", "homework/data-access.getTeacherGradeTrends", "classes/data-access.getTeacherClasses"], "permission": "exam:read"}, @@ -1588,7 +1756,11 @@ "/teacher/attendance": {"component": "AttendanceRecordList + AttendanceFilters", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access.getAttendanceRecords", "classes/data-access.getTeacherClasses"], "permission": "attendance:manage", "description": "教师考勤记录列表(权限:requirePermission(ATTENDANCE_MANAGE))"}, "/teacher/attendance/sheet": {"component": "AttendanceSheet", "type": "client", "module": "attendance", "actions": ["batchRecordAttendanceAction", "getClassAttendanceForDateAction"], "dataAccess": ["attendance/data-access.getClassStudentsForAttendance"], "permission": "attendance:manage", "description": "批量点名页面(权限:requirePermission(ATTENDANCE_MANAGE))"}, "/teacher/attendance/stats": {"component": "AttendanceStatsCard", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access-stats.getClassAttendanceStats", "classes/data-access.getTeacherClasses"], "permission": "attendance:read", "description": "班级考勤统计(权限:requirePermission(ATTENDANCE_READ))"}, - "/teacher/schedule-changes": {"component": "ScheduleChangeForm + ScheduleChangeList", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getTeachersForScheduling", "scheduling/actions.getScheduleChanges (requesterId=ctx.userId)"], "actions": ["requestScheduleChangeAction"], "permission": "schedule:adjust", "description": "教师调课/代课申请页面(提交申请+查看本人申请列表;权限:requirePermission(SCHEDULE_ADJUST);admin 角色查看全部申请)"} + "/teacher/schedule-changes": {"component": "ScheduleChangeForm + ScheduleChangeList", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getTeachersForScheduling", "scheduling/actions.getScheduleChanges (requesterId=ctx.userId)"], "actions": ["requestScheduleChangeAction"], "permission": "schedule:adjust", "description": "教师调课/代课申请页面(提交申请+查看本人申请列表;权限:requirePermission(SCHEDULE_ADJUST);admin 角色查看全部申请)"}, + "/teacher/diagnostic": {"component": "ReportList", "type": "client", "module": "diagnostic", "dataAccess": ["diagnostic/data-access-reports.getDiagnosticReports"], "actions": ["publishReportAction", "deleteReportAction"], "permission": "diagnostic:read", "description": "学情诊断报告列表(reportType/status 过滤器;权限:requirePermission(DIAGNOSTIC_READ);DataScope.class_members 仅查看自己报告;发布/删除操作需 DIAGNOSTIC_MANAGE)"}, + "/teacher/diagnostic/student/[studentId]": {"component": "StudentDiagnosticView", "type": "client", "module": "diagnostic", "dataAccess": ["diagnostic/data-access.getStudentMasterySummary", "diagnostic/data-access.getKnowledgePointStats (班级平均对比)", "diagnostic/data-access-reports.getDiagnosticReports"], "actions": ["generateStudentReportAction"], "permission": "diagnostic:read", "description": "学生学情诊断视图(概览卡片+雷达图+强项/弱项+生成报告[DIAGNOSTIC_MANAGE]+最新报告;权限:getAuthContext + DataScope 二次校验,class_members 仅自己,children 仅子女)"}, + "/teacher/diagnostic/class/[classId]": {"component": "ClassDiagnosticView", "type": "client", "module": "diagnostic", "dataAccess": ["diagnostic/data-access.getClassMasterySummary"], "actions": ["generateClassReportAction"], "permission": "diagnostic:read", "description": "班级学情诊断视图(概览+知识点热力图+排名表+需重点关注学生+生成班级报告[DIAGNOSTIC_MANAGE];权限:getAuthContext + DataScope 校验,class_taught 必须包含 classId,class_members/children notFound)"}, + "/teacher/elective": {"component": "ElectiveCourseList (teacher)", "type": "server", "module": "elective", "dataAccess": ["elective/data-access.getElectiveCourses (scope=class_taught/owned, currentUserId)"], "actions": ["deleteElectiveCourseAction", "openSelectionAction", "closeSelectionAction", "runLotteryAction"], "permission": "elective:manage", "description": "教师选修课程列表(权限:requirePermission(ELECTIVE_MANAGE);DataScope.class_taught/owned 按 teacherId 过滤)"} }, "student": { "/student/dashboard": {"component": "StudentDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (student)", "homework/data-access.getStudentDashboardGrades", "classes/data-access.getStudentClasses"], "permission": "homework:submit"}, @@ -1599,7 +1771,9 @@ "/student/learning/textbooks/[id]": {"component": "学生教材阅读(只读)", "type": "client", "module": "textbooks", "dataAccess": ["textbooks/data-access.getTextbookById", "getChaptersByTextbookId", "getKnowledgePointsByTextbookId"], "permission": "textbook:read"}, "/student/schedule": {"component": "学生课表", "type": "server", "module": "classes", "dataAccess": ["classes/data-access.getStudentSchedule"], "permission": "homework:submit"}, "/student/grades": {"component": "我的成绩", "type": "server", "module": "grades", "dataAccess": ["grades/actions.getStudentGradeSummaryAction"], "permission": "grade_record:read"}, - "/student/attendance": {"component": "StudentAttendanceView", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access-stats.getStudentAttendanceSummary"], "permission": "attendance:read", "description": "学生考勤视图(统计卡片 + 最近记录;权限:requirePermission(ATTENDANCE_READ),DataScope.class_members 仅查自己)"} + "/student/attendance": {"component": "StudentAttendanceView", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access-stats.getStudentAttendanceSummary"], "permission": "attendance:read", "description": "学生考勤视图(统计卡片 + 最近记录;权限:requirePermission(ATTENDANCE_READ),DataScope.class_members 仅查自己)"}, + "/student/diagnostic": {"component": "StudentDiagnosticView", "type": "client", "module": "diagnostic", "dataAccess": ["diagnostic/data-access.getStudentMasterySummary (ctx.userId)", "diagnostic/data-access-reports.getDiagnosticReports (studentId=ctx.userId)"], "permission": "diagnostic:read", "description": "学生本人学情诊断视图(概览+雷达图+强项/弱项+最新报告;权限:requirePermission(DIAGNOSTIC_READ),DataScope.class_members 仅查自己)"}, + "/student/elective": {"component": "StudentSelectionView", "type": "server", "module": "elective", "dataAccess": ["elective/data-access-selections.getAvailableCoursesForStudent", "elective/data-access-selections.getStudentSelections"], "actions": ["selectCourseAction", "dropCourseAction"], "permission": "elective:select", "description": "学生选课页面(可选课程列表 + 我的选课记录;权限:requirePermission(ELECTIVE_SELECT))"} }, "management": { "/management/grade/classes": {"component": "GradeClassesClient", "type": "client", "module": "classes", "permission": "grade:manage"}, diff --git a/docs/work_log.md b/docs/work_log.md index b44afad..4583e28 100644 --- a/docs/work_log.md +++ b/docs/work_log.md @@ -2,6 +2,52 @@ ## 2026-06-17 +### P2 功能扩展类实现(功能扩展 + 质量保障首批) + +#### 1. 选课管理模块(elective) +- 新增 schema 表:`electiveCourses`(选修课程)、`courseSelections`(选课记录) +- 新增权限:`ELECTIVE_MANAGE`、`ELECTIVE_READ`、`ELECTIVE_SELECT` +- 模块文件:`src/modules/elective/`(types/schema/data-access×3/actions/components×3) +- 路由:admin/teacher/student 三端 elective 页面 +- 支持:先到先得 + 抽签两种选课模式,容量控制,退选 + +#### 2. 考试监考模块(proctoring) +- 新增 schema:`exams` 表扩展 examMode/durationMinutes/antiCheatEnabled 等字段;新增 `examProctoringEvents` 表 +- 新增权限:`EXAM_PROCTOR`、`EXAM_PROCTOR_READ` +- 模块文件:`src/modules/proctoring/`(types/data-access/actions/components×3) +- API 路由:`/api/proctoring/event` 接收学生端上报 +- 页面:教师监考面板 + 学生端防作弊监控(tab切换/复制粘贴/右键/开发者工具/全屏退出/空闲超时检测) + +#### 3. 学情诊断报告模块(diagnostic) +- 新增 schema:`knowledgePointMastery`(知识点掌握度)、`learningDiagnosticReports`(诊断报告) +- 新增权限:`DIAGNOSTIC_MANAGE`、`DIAGNOSTIC_READ` +- 模块文件:`src/modules/diagnostic/`(types/data-access×2/actions/components×4) +- 功能:基于提交答案自动计算知识点掌握度,生成个人/班级诊断报告(强项/弱项/建议),雷达图可视化 +- 页面:teacher 诊断管理 + 学生查看自己报告 + +#### 4. 项目规则更新 +- `.trae/rules/project_rules.md` 单文件行数限制从 300 行调整为企业级规范: + - React 组件 ≤ 500 行(复杂场景可放宽至 800) + - Server Actions / Data Access ≤ 800 行 + - 硬性上限 1000 行 + +#### 5. 种子脚本 lint 修复 +- `scripts/seed.ts` 消除全部 `any` 类型(17 个 error → 0) +- 定义内部类型:SeedQuestion/SeedQuestionBank/SeedGradeRecord/SeedAttendanceRecord +- 移除未使用参数,函数签名精简 + +#### 6. 架构文档同步 +- `docs/architecture/004_architecture_impact_map.md` 新增 elective/proctoring/diagnostic 三个模块章节 +- `docs/architecture/005_architecture_data.json` 同步权限点、角色映射、dbTables、modules、dependencyMatrix、routes + +#### 验证 +- `npx tsc --noEmit`:0 错误 +- `npm run lint`:0 错误 0 警告 + +--- + +### 前序工作(同日早些时候) + ### 1. Next.js 16 Proxy 修复 - `src/proxy.ts` 导出函数从 `middleware` 重命名为 `proxy`(Next.js 16 要求) - 修复 `getToken()` 在 edge 运行时缺少 `secret` 导致的 `MissingSecret` 错误 diff --git a/drizzle/0001_heavy_sage.sql b/drizzle/0001_heavy_sage.sql new file mode 100644 index 0000000..0af3690 --- /dev/null +++ b/drizzle/0001_heavy_sage.sql @@ -0,0 +1,115 @@ +CREATE TABLE `course_selections` ( + `id` varchar(128) NOT NULL, + `course_id` varchar(128) NOT NULL, + `student_id` varchar(128) NOT NULL, + `selection_status` enum('selected','enrolled','waitlist','dropped','rejected') NOT NULL DEFAULT 'selected', + `priority` int DEFAULT 1, + `selected_at` timestamp NOT NULL DEFAULT (now()), + `enrolled_at` timestamp, + `dropped_at` timestamp, + `lottery_rank` int, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `course_selections_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `elective_courses` ( + `id` varchar(128) NOT NULL, + `name` varchar(255) NOT NULL, + `subject_id` varchar(128), + `teacher_id` varchar(128) NOT NULL, + `grade_id` varchar(128), + `description` text, + `capacity` int NOT NULL DEFAULT 30, + `enrolled_count` int NOT NULL DEFAULT 0, + `classroom` varchar(100), + `schedule` varchar(255), + `start_date` date, + `end_date` date, + `selection_start_at` datetime, + `selection_end_at` datetime, + `status` enum('draft','open','closed','cancelled') NOT NULL DEFAULT 'draft', + `selection_mode` enum('fcfs','lottery') NOT NULL DEFAULT 'fcfs', + `credit` decimal(3,1) DEFAULT '1.0', + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `elective_courses_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `exam_proctoring_events` ( + `id` varchar(128) NOT NULL, + `submission_id` varchar(128) NOT NULL, + `student_id` varchar(128) NOT NULL, + `exam_id` varchar(128) NOT NULL, + `event_type` enum('tab_switch','window_blur','copy_attempt','paste_attempt','right_click','devtools_open','fullscreen_exit','idle_timeout') NOT NULL, + `event_detail` text, + `occurred_at` timestamp NOT NULL DEFAULT (now()), + `created_at` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `exam_proctoring_events_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `knowledge_point_mastery` ( + `id` varchar(128) NOT NULL, + `student_id` varchar(128) NOT NULL, + `knowledge_point_id` varchar(128) NOT NULL, + `mastery_level` decimal(5,2) NOT NULL DEFAULT '0', + `total_questions` int NOT NULL DEFAULT 0, + `correct_questions` int NOT NULL DEFAULT 0, + `last_assessed_at` timestamp NOT NULL DEFAULT (now()), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `knowledge_point_mastery_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `learning_diagnostic_reports` ( + `id` varchar(128) NOT NULL, + `student_id` varchar(128) NOT NULL, + `generated_by` varchar(128), + `report_type` enum('individual','class','grade') NOT NULL DEFAULT 'individual', + `period` varchar(50), + `summary` text, + `strengths` json, + `weaknesses` json, + `recommendations` json, + `overall_score` decimal(5,2), + `report_status` enum('draft','published','archived') NOT NULL DEFAULT 'draft', + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `learning_diagnostic_reports_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `exams` ADD `exam_mode` enum('homework','timed','proctored') DEFAULT 'homework';--> statement-breakpoint +ALTER TABLE `exams` ADD `duration_minutes` int;--> statement-breakpoint +ALTER TABLE `exams` ADD `shuffle_questions` boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE `exams` ADD `allow_late_start` boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE `exams` ADD `late_start_grace_minutes` int DEFAULT 0;--> statement-breakpoint +ALTER TABLE `exams` ADD `anti_cheat_enabled` boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE `course_selections` ADD CONSTRAINT `course_selections_course_id_elective_courses_id_fk` FOREIGN KEY (`course_id`) REFERENCES `elective_courses`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `course_selections` ADD CONSTRAINT `course_selections_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `elective_courses` ADD CONSTRAINT `elective_courses_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `elective_courses` ADD CONSTRAINT `elective_courses_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `elective_courses` ADD CONSTRAINT `elective_courses_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `exam_proctoring_events` ADD CONSTRAINT `exam_proctoring_events_submission_id_exam_submissions_id_fk` FOREIGN KEY (`submission_id`) REFERENCES `exam_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `exam_proctoring_events` ADD CONSTRAINT `exam_proctoring_events_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `exam_proctoring_events` ADD CONSTRAINT `exam_proctoring_events_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `knowledge_point_mastery` ADD CONSTRAINT `knowledge_point_mastery_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `knowledge_point_mastery` ADD CONSTRAINT `knowledge_point_mastery_knowledge_point_id_knowledge_points_id_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `learning_diagnostic_reports` ADD CONSTRAINT `learning_diagnostic_reports_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `learning_diagnostic_reports` ADD CONSTRAINT `learning_diagnostic_reports_generated_by_users_id_fk` FOREIGN KEY (`generated_by`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `course_selections_course_idx` ON `course_selections` (`course_id`);--> statement-breakpoint +CREATE INDEX `course_selections_student_idx` ON `course_selections` (`student_id`);--> statement-breakpoint +CREATE INDEX `course_selections_status_idx` ON `course_selections` (`selection_status`);--> statement-breakpoint +CREATE INDEX `elective_courses_teacher_idx` ON `elective_courses` (`teacher_id`);--> statement-breakpoint +CREATE INDEX `elective_courses_subject_idx` ON `elective_courses` (`subject_id`);--> statement-breakpoint +CREATE INDEX `elective_courses_grade_idx` ON `elective_courses` (`grade_id`);--> statement-breakpoint +CREATE INDEX `elective_courses_status_idx` ON `elective_courses` (`status`);--> statement-breakpoint +CREATE INDEX `proctoring_submission_idx` ON `exam_proctoring_events` (`submission_id`);--> statement-breakpoint +CREATE INDEX `proctoring_student_idx` ON `exam_proctoring_events` (`student_id`);--> statement-breakpoint +CREATE INDEX `proctoring_exam_idx` ON `exam_proctoring_events` (`exam_id`);--> statement-breakpoint +CREATE INDEX `proctoring_event_type_idx` ON `exam_proctoring_events` (`event_type`);--> statement-breakpoint +CREATE INDEX `mastery_student_idx` ON `knowledge_point_mastery` (`student_id`);--> statement-breakpoint +CREATE INDEX `mastery_kp_idx` ON `knowledge_point_mastery` (`knowledge_point_id`);--> statement-breakpoint +CREATE INDEX `diagnostic_student_idx` ON `learning_diagnostic_reports` (`student_id`);--> statement-breakpoint +CREATE INDEX `diagnostic_generated_by_idx` ON `learning_diagnostic_reports` (`generated_by`);--> statement-breakpoint +CREATE INDEX `diagnostic_status_idx` ON `learning_diagnostic_reports` (`report_status`);--> statement-breakpoint +CREATE INDEX `diagnostic_report_type_idx` ON `learning_diagnostic_reports` (`report_type`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..27c8a70 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,6809 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "ce4890dc-330e-4599-b6df-b659f40e63f8", + "prevId": "0ec4760a-6847-43de-8c01-13cb9676df8c", + "tables": { + "academic_years": { + "name": "academic_years", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "academic_years_name_idx": { + "name": "academic_years_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "academic_years_active_idx": { + "name": "academic_years_active_idx", + "columns": [ + "is_active" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "academic_years_id": { + "name": "academic_years_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "academic_years_name_unique": { + "name": "academic_years_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "accounts": { + "name": "accounts", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accounts_provider_providerAccountId_pk": { + "name": "accounts_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ai_providers": { + "name": "ai_providers", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('zhipu','openai','gemini','custom')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "ai_provider_idx": { + "name": "ai_provider_idx", + "columns": [ + "provider" + ], + "isUnique": false + }, + "ai_provider_default_idx": { + "name": "ai_provider_default_idx", + "columns": [ + "is_default" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ai_providers_id": { + "name": "ai_providers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "announcements": { + "name": "announcements", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('school','grade','class')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'school'" + }, + "status": { + "name": "status", + "type": "enum('draft','published','archived')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "target_grade_id": { + "name": "target_grade_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_class_id": { + "name": "target_class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "announcements_author_idx": { + "name": "announcements_author_idx", + "columns": [ + "author_id" + ], + "isUnique": false + }, + "announcements_status_idx": { + "name": "announcements_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "announcements_type_idx": { + "name": "announcements_type_idx", + "columns": [ + "type" + ], + "isUnique": false + }, + "announcements_target_grade_idx": { + "name": "announcements_target_grade_idx", + "columns": [ + "target_grade_id" + ], + "isUnique": false + }, + "announcements_target_class_idx": { + "name": "announcements_target_class_idx", + "columns": [ + "target_class_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "announcements_author_id_users_id_fk": { + "name": "announcements_author_id_users_id_fk", + "tableFrom": "announcements", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "announcements_id": { + "name": "announcements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "attendance_records": { + "name": "attendance_records", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('present','absent','late','early_leave','excused')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recorded_by": { + "name": "recorded_by", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "attendance_records_student_idx": { + "name": "attendance_records_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + }, + "attendance_records_class_idx": { + "name": "attendance_records_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + }, + "attendance_records_date_idx": { + "name": "attendance_records_date_idx", + "columns": [ + "date" + ], + "isUnique": false + }, + "attendance_records_class_date_idx": { + "name": "attendance_records_class_date_idx", + "columns": [ + "class_id", + "date" + ], + "isUnique": false + }, + "attendance_records_student_date_idx": { + "name": "attendance_records_student_date_idx", + "columns": [ + "student_id", + "date" + ], + "isUnique": false + }, + "attendance_records_schedule_idx": { + "name": "attendance_records_schedule_idx", + "columns": [ + "schedule_id" + ], + "isUnique": false + }, + "attendance_records_recorded_by_idx": { + "name": "attendance_records_recorded_by_idx", + "columns": [ + "recorded_by" + ], + "isUnique": false + } + }, + "foreignKeys": { + "attendance_records_student_id_users_id_fk": { + "name": "attendance_records_student_id_users_id_fk", + "tableFrom": "attendance_records", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attendance_records_class_id_classes_id_fk": { + "name": "attendance_records_class_id_classes_id_fk", + "tableFrom": "attendance_records", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attendance_records_recorded_by_users_id_fk": { + "name": "attendance_records_recorded_by_users_id_fk", + "tableFrom": "attendance_records", + "tableTo": "users", + "columnsFrom": [ + "recorded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ar_c_fk": { + "name": "ar_c_fk", + "tableFrom": "attendance_records", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ar_s_fk": { + "name": "ar_s_fk", + "tableFrom": "attendance_records", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ar_rb_fk": { + "name": "ar_rb_fk", + "tableFrom": "attendance_records", + "tableTo": "users", + "columnsFrom": [ + "recorded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "attendance_records_id": { + "name": "attendance_records_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "attendance_rules": { + "name": "attendance_rules", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "late_threshold_minutes": { + "name": "late_threshold_minutes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 15 + }, + "early_leave_threshold_minutes": { + "name": "early_leave_threshold_minutes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 15 + }, + "enable_auto_mark": { + "name": "enable_auto_mark", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "attendance_rules_class_idx": { + "name": "attendance_rules_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "attendance_rules_class_id_classes_id_fk": { + "name": "attendance_rules_class_id_classes_id_fk", + "tableFrom": "attendance_rules", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "atr_c_fk": { + "name": "atr_c_fk", + "tableFrom": "attendance_rules", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "attendance_rules_id": { + "name": "attendance_rules_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "module": { + "name": "module", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('success','failure')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'success'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "audit_logs_module_idx": { + "name": "audit_logs_module_idx", + "columns": [ + "module" + ], + "isUnique": false + }, + "audit_logs_action_idx": { + "name": "audit_logs_action_idx", + "columns": [ + "action" + ], + "isUnique": false + }, + "audit_logs_status_idx": { + "name": "audit_logs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "audit_logs_id": { + "name": "audit_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "chapters": { + "name": "chapters", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "textbook_id": { + "name": "textbook_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "textbook_idx": { + "name": "textbook_idx", + "columns": [ + "textbook_id" + ], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chapters_textbook_id_textbooks_id_fk": { + "name": "chapters_textbook_id_textbooks_id_fk", + "tableFrom": "chapters", + "tableTo": "textbooks", + "columnsFrom": [ + "textbook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chapters_id": { + "name": "chapters_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "class_enrollments": { + "name": "class_enrollments", + "columns": { + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_enrollment_status": { + "name": "class_enrollment_status", + "type": "enum('active','inactive')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "class_enrollments_class_idx": { + "name": "class_enrollments_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + }, + "class_enrollments_student_idx": { + "name": "class_enrollments_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ce_c_fk": { + "name": "ce_c_fk", + "tableFrom": "class_enrollments", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ce_s_fk": { + "name": "ce_s_fk", + "tableFrom": "class_enrollments", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "class_enrollments_class_id_student_id_pk": { + "name": "class_enrollments_class_id_student_id_pk", + "columns": [ + "class_id", + "student_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "class_schedule": { + "name": "class_schedule", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weekday": { + "name": "weekday", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "course": { + "name": "course", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "class_schedule_class_idx": { + "name": "class_schedule_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + }, + "class_schedule_class_day_idx": { + "name": "class_schedule_class_day_idx", + "columns": [ + "class_id", + "weekday" + ], + "isUnique": false + } + }, + "foreignKeys": { + "cs_c_fk": { + "name": "cs_c_fk", + "tableFrom": "class_schedule", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "class_schedule_id": { + "name": "class_schedule_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "class_subject_teachers": { + "name": "class_subject_teachers", + "columns": { + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "teacher_id": { + "name": "teacher_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "class_subject_teachers_class_idx": { + "name": "class_subject_teachers_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + }, + "class_subject_teachers_teacher_idx": { + "name": "class_subject_teachers_teacher_idx", + "columns": [ + "teacher_id" + ], + "isUnique": false + }, + "class_subject_teachers_subject_id_idx": { + "name": "class_subject_teachers_subject_id_idx", + "columns": [ + "subject_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "class_subject_teachers_teacher_id_users_id_fk": { + "name": "class_subject_teachers_teacher_id_users_id_fk", + "tableFrom": "class_subject_teachers", + "tableTo": "users", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cst_c_fk": { + "name": "cst_c_fk", + "tableFrom": "class_subject_teachers", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cst_s_fk": { + "name": "cst_s_fk", + "tableFrom": "class_subject_teachers", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "class_subject_teachers_class_id_subject_id_pk": { + "name": "class_subject_teachers_class_id_subject_id_pk", + "columns": [ + "class_id", + "subject_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "classes": { + "name": "classes", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "school_name": { + "name": "school_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "school_id": { + "name": "school_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade": { + "name": "grade", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade_id": { + "name": "grade_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homeroom": { + "name": "homeroom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room": { + "name": "room", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitation_code": { + "name": "invitation_code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teacher_id": { + "name": "teacher_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "classes_teacher_idx": { + "name": "classes_teacher_idx", + "columns": [ + "teacher_id" + ], + "isUnique": false + }, + "classes_grade_idx": { + "name": "classes_grade_idx", + "columns": [ + "grade" + ], + "isUnique": false + }, + "classes_school_idx": { + "name": "classes_school_idx", + "columns": [ + "school_id" + ], + "isUnique": false + }, + "classes_grade_id_idx": { + "name": "classes_grade_id_idx", + "columns": [ + "grade_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "classes_teacher_id_users_id_fk": { + "name": "classes_teacher_id_users_id_fk", + "tableFrom": "classes", + "tableTo": "users", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "c_s_fk": { + "name": "c_s_fk", + "tableFrom": "classes", + "tableTo": "schools", + "columnsFrom": [ + "school_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "c_g_fk": { + "name": "c_g_fk", + "tableFrom": "classes", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "classes_id": { + "name": "classes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "classes_invitation_code_unique": { + "name": "classes_invitation_code_unique", + "columns": [ + "invitation_code" + ] + } + }, + "checkConstraint": {} + }, + "classrooms": { + "name": "classrooms", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "building": { + "name": "building", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "floor": { + "name": "floor", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capacity": { + "name": "capacity", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "classrooms_name_idx": { + "name": "classrooms_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "classrooms_id": { + "name": "classrooms_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "classrooms_name_unique": { + "name": "classrooms_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "course_plan_items": { + "name": "course_plan_items", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "week": { + "name": "week", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topic": { + "name": "topic", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hours": { + "name": "hours", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "textbook_chapter": { + "name": "textbook_chapter", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "course_plan_items_plan_idx": { + "name": "course_plan_items_plan_idx", + "columns": [ + "plan_id" + ], + "isUnique": false + }, + "course_plan_items_plan_week_idx": { + "name": "course_plan_items_plan_week_idx", + "columns": [ + "plan_id", + "week" + ], + "isUnique": false + } + }, + "foreignKeys": { + "course_plan_items_plan_id_course_plans_id_fk": { + "name": "course_plan_items_plan_id_course_plans_id_fk", + "tableFrom": "course_plan_items", + "tableTo": "course_plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cpi_p_fk": { + "name": "cpi_p_fk", + "tableFrom": "course_plan_items", + "tableTo": "course_plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "course_plan_items_id": { + "name": "course_plan_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "course_plans": { + "name": "course_plans", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "teacher_id": { + "name": "teacher_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "academic_year_id": { + "name": "academic_year_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semester": { + "name": "semester", + "type": "enum('1','2')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1'" + }, + "total_hours": { + "name": "total_hours", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "completed_hours": { + "name": "completed_hours", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "weekly_hours": { + "name": "weekly_hours", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syllabus": { + "name": "syllabus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objectives": { + "name": "objectives", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('planning','active','completed','paused')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'planning'" + }, + "created_by": { + "name": "created_by", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "course_plans_class_idx": { + "name": "course_plans_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + }, + "course_plans_teacher_idx": { + "name": "course_plans_teacher_idx", + "columns": [ + "teacher_id" + ], + "isUnique": false + }, + "course_plans_subject_idx": { + "name": "course_plans_subject_idx", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "course_plans_status_idx": { + "name": "course_plans_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "course_plans_class_subject_idx": { + "name": "course_plans_class_subject_idx", + "columns": [ + "class_id", + "subject_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "course_plans_class_id_classes_id_fk": { + "name": "course_plans_class_id_classes_id_fk", + "tableFrom": "course_plans", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_plans_subject_id_subjects_id_fk": { + "name": "course_plans_subject_id_subjects_id_fk", + "tableFrom": "course_plans", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_plans_teacher_id_users_id_fk": { + "name": "course_plans_teacher_id_users_id_fk", + "tableFrom": "course_plans", + "tableTo": "users", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_plans_created_by_users_id_fk": { + "name": "course_plans_created_by_users_id_fk", + "tableFrom": "course_plans", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cp_c_fk": { + "name": "cp_c_fk", + "tableFrom": "course_plans", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cp_s_fk": { + "name": "cp_s_fk", + "tableFrom": "course_plans", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cp_t_fk": { + "name": "cp_t_fk", + "tableFrom": "course_plans", + "tableTo": "users", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cp_cb_fk": { + "name": "cp_cb_fk", + "tableFrom": "course_plans", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "course_plans_id": { + "name": "course_plans_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "course_selections": { + "name": "course_selections", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "course_id": { + "name": "course_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selection_status": { + "name": "selection_status", + "type": "enum('selected','enrolled','waitlist','dropped','rejected')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'selected'" + }, + "priority": { + "name": "priority", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "selected_at": { + "name": "selected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dropped_at": { + "name": "dropped_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lottery_rank": { + "name": "lottery_rank", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "course_selections_course_idx": { + "name": "course_selections_course_idx", + "columns": [ + "course_id" + ], + "isUnique": false + }, + "course_selections_student_idx": { + "name": "course_selections_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + }, + "course_selections_status_idx": { + "name": "course_selections_status_idx", + "columns": [ + "selection_status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "course_selections_course_id_elective_courses_id_fk": { + "name": "course_selections_course_id_elective_courses_id_fk", + "tableFrom": "course_selections", + "tableTo": "elective_courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_selections_student_id_users_id_fk": { + "name": "course_selections_student_id_users_id_fk", + "tableFrom": "course_selections", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "course_selections_id": { + "name": "course_selections_id", + "columns": [ + "id" + ] + }, + "course_selections_course_id_student_id_pk": { + "name": "course_selections_course_id_student_id_pk", + "columns": [ + "course_id", + "student_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "data_change_logs": { + "name": "data_change_logs", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "table_name": { + "name": "table_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_id": { + "name": "record_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "enum('create','update','delete')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_by": { + "name": "changed_by", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_by_name": { + "name": "changed_by_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "data_change_logs_table_name_idx": { + "name": "data_change_logs_table_name_idx", + "columns": [ + "table_name" + ], + "isUnique": false + }, + "data_change_logs_record_id_idx": { + "name": "data_change_logs_record_id_idx", + "columns": [ + "record_id" + ], + "isUnique": false + }, + "data_change_logs_action_idx": { + "name": "data_change_logs_action_idx", + "columns": [ + "action" + ], + "isUnique": false + }, + "data_change_logs_changed_by_idx": { + "name": "data_change_logs_changed_by_idx", + "columns": [ + "changed_by" + ], + "isUnique": false + }, + "data_change_logs_created_at_idx": { + "name": "data_change_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "data_change_logs_id": { + "name": "data_change_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "departments": { + "name": "departments", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "departments_name_idx": { + "name": "departments_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "departments_id": { + "name": "departments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "departments_name_unique": { + "name": "departments_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "elective_courses": { + "name": "elective_courses", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teacher_id": { + "name": "teacher_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade_id": { + "name": "grade_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capacity": { + "name": "capacity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "enrolled_count": { + "name": "enrolled_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "classroom": { + "name": "classroom", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selection_start_at": { + "name": "selection_start_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selection_end_at": { + "name": "selection_end_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('draft','open','closed','cancelled')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "selection_mode": { + "name": "selection_mode", + "type": "enum('fcfs','lottery')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fcfs'" + }, + "credit": { + "name": "credit", + "type": "decimal(3,1)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'1.0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "elective_courses_teacher_idx": { + "name": "elective_courses_teacher_idx", + "columns": [ + "teacher_id" + ], + "isUnique": false + }, + "elective_courses_subject_idx": { + "name": "elective_courses_subject_idx", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "elective_courses_grade_idx": { + "name": "elective_courses_grade_idx", + "columns": [ + "grade_id" + ], + "isUnique": false + }, + "elective_courses_status_idx": { + "name": "elective_courses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "elective_courses_subject_id_subjects_id_fk": { + "name": "elective_courses_subject_id_subjects_id_fk", + "tableFrom": "elective_courses", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "elective_courses_teacher_id_users_id_fk": { + "name": "elective_courses_teacher_id_users_id_fk", + "tableFrom": "elective_courses", + "tableTo": "users", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "elective_courses_grade_id_grades_id_fk": { + "name": "elective_courses_grade_id_grades_id_fk", + "tableFrom": "elective_courses", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "elective_courses_id": { + "name": "elective_courses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exam_proctoring_events": { + "name": "exam_proctoring_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_id": { + "name": "submission_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "enum('tab_switch','window_blur','copy_attempt','paste_attempt','right_click','devtools_open','fullscreen_exit','idle_timeout')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_detail": { + "name": "event_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "proctoring_submission_idx": { + "name": "proctoring_submission_idx", + "columns": [ + "submission_id" + ], + "isUnique": false + }, + "proctoring_student_idx": { + "name": "proctoring_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + }, + "proctoring_exam_idx": { + "name": "proctoring_exam_idx", + "columns": [ + "exam_id" + ], + "isUnique": false + }, + "proctoring_event_type_idx": { + "name": "proctoring_event_type_idx", + "columns": [ + "event_type" + ], + "isUnique": false + } + }, + "foreignKeys": { + "exam_proctoring_events_submission_id_exam_submissions_id_fk": { + "name": "exam_proctoring_events_submission_id_exam_submissions_id_fk", + "tableFrom": "exam_proctoring_events", + "tableTo": "exam_submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_proctoring_events_student_id_users_id_fk": { + "name": "exam_proctoring_events_student_id_users_id_fk", + "tableFrom": "exam_proctoring_events", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_proctoring_events_exam_id_exams_id_fk": { + "name": "exam_proctoring_events_exam_id_exams_id_fk", + "tableFrom": "exam_proctoring_events", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_proctoring_events_id": { + "name": "exam_proctoring_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exam_questions": { + "name": "exam_questions", + "columns": { + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "exam_questions_exam_id_exams_id_fk": { + "name": "exam_questions_exam_id_exams_id_fk", + "tableFrom": "exam_questions", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_questions_question_id_questions_id_fk": { + "name": "exam_questions_question_id_questions_id_fk", + "tableFrom": "exam_questions", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_questions_exam_id_question_id_pk": { + "name": "exam_questions_exam_id_question_id_pk", + "columns": [ + "exam_id", + "question_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exam_submissions": { + "name": "exam_submissions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'started'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "exam_student_idx": { + "name": "exam_student_idx", + "columns": [ + "exam_id", + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "exam_submissions_exam_id_exams_id_fk": { + "name": "exam_submissions_exam_id_exams_id_fk", + "tableFrom": "exam_submissions", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_submissions_student_id_users_id_fk": { + "name": "exam_submissions_student_id_users_id_fk", + "tableFrom": "exam_submissions", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_submissions_id": { + "name": "exam_submissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exams": { + "name": "exams", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grade_id": { + "name": "grade_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exam_mode": { + "name": "exam_mode", + "type": "enum('homework','timed','proctored')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'homework'" + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shuffle_questions": { + "name": "shuffle_questions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "allow_late_start": { + "name": "allow_late_start", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "late_start_grace_minutes": { + "name": "late_start_grace_minutes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "anti_cheat_enabled": { + "name": "anti_cheat_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "exams_subject_idx": { + "name": "exams_subject_idx", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "exams_grade_idx": { + "name": "exams_grade_idx", + "columns": [ + "grade_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "exams_creator_id_users_id_fk": { + "name": "exams_creator_id_users_id_fk", + "tableFrom": "exams", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "exams_subject_id_subjects_id_fk": { + "name": "exams_subject_id_subjects_id_fk", + "tableFrom": "exams", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "exams_grade_id_grades_id_fk": { + "name": "exams_grade_id_grades_id_fk", + "tableFrom": "exams", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exams_id": { + "name": "exams_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_attachments": { + "name": "file_attachments", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_path": { + "name": "storage_path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploader_id": { + "name": "uploader_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "file_attachments_uploader_idx": { + "name": "file_attachments_uploader_idx", + "columns": [ + "uploader_id" + ], + "isUnique": false + }, + "file_attachments_target_idx": { + "name": "file_attachments_target_idx", + "columns": [ + "target_type", + "target_id" + ], + "isUnique": false + }, + "file_attachments_created_at_idx": { + "name": "file_attachments_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_attachments_uploader_id_users_id_fk": { + "name": "file_attachments_uploader_id_users_id_fk", + "tableFrom": "file_attachments", + "tableTo": "users", + "columnsFrom": [ + "uploader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_attachments_id": { + "name": "file_attachments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "grade_records": { + "name": "grade_records", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "academic_year_id": { + "name": "academic_year_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "decimal(6,2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_score": { + "name": "full_score", + "type": "decimal(6,2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'100'" + }, + "type": { + "name": "type", + "type": "enum('exam','quiz','homework','other')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'exam'" + }, + "semester": { + "name": "semester", + "type": "enum('1','2')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1'" + }, + "recorded_by": { + "name": "recorded_by", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "grade_records_student_idx": { + "name": "grade_records_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + }, + "grade_records_class_idx": { + "name": "grade_records_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + }, + "grade_records_subject_idx": { + "name": "grade_records_subject_idx", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "grade_records_exam_idx": { + "name": "grade_records_exam_idx", + "columns": [ + "exam_id" + ], + "isUnique": false + }, + "grade_records_class_subject_idx": { + "name": "grade_records_class_subject_idx", + "columns": [ + "class_id", + "subject_id" + ], + "isUnique": false + }, + "grade_records_recorded_by_idx": { + "name": "grade_records_recorded_by_idx", + "columns": [ + "recorded_by" + ], + "isUnique": false + } + }, + "foreignKeys": { + "grade_records_student_id_users_id_fk": { + "name": "grade_records_student_id_users_id_fk", + "tableFrom": "grade_records", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grade_records_class_id_classes_id_fk": { + "name": "grade_records_class_id_classes_id_fk", + "tableFrom": "grade_records", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grade_records_subject_id_subjects_id_fk": { + "name": "grade_records_subject_id_subjects_id_fk", + "tableFrom": "grade_records", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grade_records_recorded_by_users_id_fk": { + "name": "grade_records_recorded_by_users_id_fk", + "tableFrom": "grade_records", + "tableTo": "users", + "columnsFrom": [ + "recorded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gr_c_fk": { + "name": "gr_c_fk", + "tableFrom": "grade_records", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gr_s_fk": { + "name": "gr_s_fk", + "tableFrom": "grade_records", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gr_sub_fk": { + "name": "gr_sub_fk", + "tableFrom": "grade_records", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gr_rb_fk": { + "name": "gr_rb_fk", + "tableFrom": "grade_records", + "tableTo": "users", + "columnsFrom": [ + "recorded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "grade_records_id": { + "name": "grade_records_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "grades": { + "name": "grades", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "school_id": { + "name": "school_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "grade_head_id": { + "name": "grade_head_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teaching_head_id": { + "name": "teaching_head_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "grades_school_idx": { + "name": "grades_school_idx", + "columns": [ + "school_id" + ], + "isUnique": false + }, + "grades_school_name_uniq": { + "name": "grades_school_name_uniq", + "columns": [ + "school_id", + "name" + ], + "isUnique": false + }, + "grades_grade_head_idx": { + "name": "grades_grade_head_idx", + "columns": [ + "grade_head_id" + ], + "isUnique": false + }, + "grades_teaching_head_idx": { + "name": "grades_teaching_head_idx", + "columns": [ + "teaching_head_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "g_s_fk": { + "name": "g_s_fk", + "tableFrom": "grades", + "tableTo": "schools", + "columnsFrom": [ + "school_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "g_gh_fk": { + "name": "g_gh_fk", + "tableFrom": "grades", + "tableTo": "users", + "columnsFrom": [ + "grade_head_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "g_th_fk": { + "name": "g_th_fk", + "tableFrom": "grades", + "tableTo": "users", + "columnsFrom": [ + "teaching_head_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "grades_id": { + "name": "grades_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_answers": { + "name": "homework_answers", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_id": { + "name": "submission_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer_content": { + "name": "answer_content", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "hw_answer_submission_idx": { + "name": "hw_answer_submission_idx", + "columns": [ + "submission_id" + ], + "isUnique": false + }, + "hw_answer_submission_question_idx": { + "name": "hw_answer_submission_question_idx", + "columns": [ + "submission_id", + "question_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_ans_sub_fk": { + "name": "hw_ans_sub_fk", + "tableFrom": "homework_answers", + "tableTo": "homework_submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_ans_q_fk": { + "name": "hw_ans_q_fk", + "tableFrom": "homework_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_answers_id": { + "name": "homework_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_assignment_questions": { + "name": "homework_assignment_questions", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "hw_assignment_questions_assignment_idx": { + "name": "hw_assignment_questions_assignment_idx", + "columns": [ + "assignment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_aq_a_fk": { + "name": "hw_aq_a_fk", + "tableFrom": "homework_assignment_questions", + "tableTo": "homework_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_aq_q_fk": { + "name": "hw_aq_q_fk", + "tableFrom": "homework_assignment_questions", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_assignment_questions_assignment_id_question_id_pk": { + "name": "homework_assignment_questions_assignment_id_question_id_pk", + "columns": [ + "assignment_id", + "question_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_assignment_targets": { + "name": "homework_assignment_targets", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "hw_assignment_targets_assignment_idx": { + "name": "hw_assignment_targets_assignment_idx", + "columns": [ + "assignment_id" + ], + "isUnique": false + }, + "hw_assignment_targets_student_idx": { + "name": "hw_assignment_targets_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_at_a_fk": { + "name": "hw_at_a_fk", + "tableFrom": "homework_assignment_targets", + "tableTo": "homework_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_at_s_fk": { + "name": "hw_at_s_fk", + "tableFrom": "homework_assignment_targets", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_assignment_targets_assignment_id_student_id_pk": { + "name": "homework_assignment_targets_assignment_id_student_id_pk", + "columns": [ + "assignment_id", + "student_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_assignments": { + "name": "homework_assignments", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_exam_id": { + "name": "source_exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_late": { + "name": "allow_late", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "late_due_at": { + "name": "late_due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_attempts": { + "name": "max_attempts", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "hw_assignment_creator_idx": { + "name": "hw_assignment_creator_idx", + "columns": [ + "creator_id" + ], + "isUnique": false + }, + "hw_assignment_source_exam_idx": { + "name": "hw_assignment_source_exam_idx", + "columns": [ + "source_exam_id" + ], + "isUnique": false + }, + "hw_assignment_status_idx": { + "name": "hw_assignment_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_asg_exam_fk": { + "name": "hw_asg_exam_fk", + "tableFrom": "homework_assignments", + "tableTo": "exams", + "columnsFrom": [ + "source_exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_asg_creator_fk": { + "name": "hw_asg_creator_fk", + "tableFrom": "homework_assignments", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_assignments_id": { + "name": "homework_assignments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_submissions": { + "name": "homework_submissions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assignment_id": { + "name": "assignment_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_no": { + "name": "attempt_no", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'started'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_late": { + "name": "is_late", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "hw_assignment_student_idx": { + "name": "hw_assignment_student_idx", + "columns": [ + "assignment_id", + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_sub_a_fk": { + "name": "hw_sub_a_fk", + "tableFrom": "homework_submissions", + "tableTo": "homework_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_sub_student_fk": { + "name": "hw_sub_student_fk", + "tableFrom": "homework_submissions", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_submissions_id": { + "name": "homework_submissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_point_mastery": { + "name": "knowledge_point_mastery", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "knowledge_point_id": { + "name": "knowledge_point_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mastery_level": { + "name": "mastery_level", + "type": "decimal(5,2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "total_questions": { + "name": "total_questions", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "correct_questions": { + "name": "correct_questions", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_assessed_at": { + "name": "last_assessed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "mastery_student_idx": { + "name": "mastery_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + }, + "mastery_kp_idx": { + "name": "mastery_kp_idx", + "columns": [ + "knowledge_point_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "knowledge_point_mastery_student_id_users_id_fk": { + "name": "knowledge_point_mastery_student_id_users_id_fk", + "tableFrom": "knowledge_point_mastery", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_point_mastery_knowledge_point_id_knowledge_points_id_fk": { + "name": "knowledge_point_mastery_knowledge_point_id_knowledge_points_id_fk", + "tableFrom": "knowledge_point_mastery", + "tableTo": "knowledge_points", + "columnsFrom": [ + "knowledge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_point_mastery_id": { + "name": "knowledge_point_mastery_id", + "columns": [ + "id" + ] + }, + "knowledge_point_mastery_student_id_knowledge_point_id_pk": { + "name": "knowledge_point_mastery_student_id_knowledge_point_id_pk", + "columns": [ + "student_id", + "knowledge_point_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_points": { + "name": "knowledge_points", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anchor_text": { + "name": "anchor_text", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chapter_id": { + "name": "chapter_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "kp_chapter_id_idx": { + "name": "kp_chapter_id_idx", + "columns": [ + "chapter_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_points_id": { + "name": "knowledge_points_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "learning_diagnostic_reports": { + "name": "learning_diagnostic_reports", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_by": { + "name": "generated_by", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_type": { + "name": "report_type", + "type": "enum('individual','class','grade')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'individual'" + }, + "period": { + "name": "period", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strengths": { + "name": "strengths", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weaknesses": { + "name": "weaknesses", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recommendations": { + "name": "recommendations", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overall_score": { + "name": "overall_score", + "type": "decimal(5,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_status": { + "name": "report_status", + "type": "enum('draft','published','archived')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "diagnostic_student_idx": { + "name": "diagnostic_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + }, + "diagnostic_generated_by_idx": { + "name": "diagnostic_generated_by_idx", + "columns": [ + "generated_by" + ], + "isUnique": false + }, + "diagnostic_status_idx": { + "name": "diagnostic_status_idx", + "columns": [ + "report_status" + ], + "isUnique": false + }, + "diagnostic_report_type_idx": { + "name": "diagnostic_report_type_idx", + "columns": [ + "report_type" + ], + "isUnique": false + } + }, + "foreignKeys": { + "learning_diagnostic_reports_student_id_users_id_fk": { + "name": "learning_diagnostic_reports_student_id_users_id_fk", + "tableFrom": "learning_diagnostic_reports", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "learning_diagnostic_reports_generated_by_users_id_fk": { + "name": "learning_diagnostic_reports_generated_by_users_id_fk", + "tableFrom": "learning_diagnostic_reports", + "tableTo": "users", + "columnsFrom": [ + "generated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "learning_diagnostic_reports_id": { + "name": "learning_diagnostic_reports_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "login_logs": { + "name": "login_logs", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "enum('signin','signout','signup')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('success','failure')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'success'" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "login_logs_user_id_idx": { + "name": "login_logs_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "login_logs_user_email_idx": { + "name": "login_logs_user_email_idx", + "columns": [ + "user_email" + ], + "isUnique": false + }, + "login_logs_action_idx": { + "name": "login_logs_action_idx", + "columns": [ + "action" + ], + "isUnique": false + }, + "login_logs_status_idx": { + "name": "login_logs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "login_logs_created_at_idx": { + "name": "login_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "login_logs_id": { + "name": "login_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "message_notifications": { + "name": "message_notifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link": { + "name": "link", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "message_notifications_user_idx": { + "name": "message_notifications_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "message_notifications_is_read_idx": { + "name": "message_notifications_is_read_idx", + "columns": [ + "is_read" + ], + "isUnique": false + }, + "message_notifications_user_read_idx": { + "name": "message_notifications_user_read_idx", + "columns": [ + "user_id", + "is_read" + ], + "isUnique": false + }, + "message_notifications_created_at_idx": { + "name": "message_notifications_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "message_notifications_user_id_users_id_fk": { + "name": "message_notifications_user_id_users_id_fk", + "tableFrom": "message_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "message_notifications_id": { + "name": "message_notifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "receiver_id": { + "name": "receiver_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "messages_sender_idx": { + "name": "messages_sender_idx", + "columns": [ + "sender_id" + ], + "isUnique": false + }, + "messages_receiver_idx": { + "name": "messages_receiver_idx", + "columns": [ + "receiver_id" + ], + "isUnique": false + }, + "messages_is_read_idx": { + "name": "messages_is_read_idx", + "columns": [ + "is_read" + ], + "isUnique": false + }, + "messages_parent_idx": { + "name": "messages_parent_idx", + "columns": [ + "parent_message_id" + ], + "isUnique": false + }, + "messages_receiver_read_idx": { + "name": "messages_receiver_read_idx", + "columns": [ + "receiver_id", + "is_read" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_receiver_id_users_id_fk": { + "name": "messages_receiver_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "receiver_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messages_id": { + "name": "messages_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notification_preferences": { + "name": "notification_preferences", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_enabled": { + "name": "email_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sms_enabled": { + "name": "sms_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "homework_notifications": { + "name": "homework_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "grade_notifications": { + "name": "grade_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "announcement_notifications": { + "name": "announcement_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "message_notifications": { + "name": "message_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "attendance_notifications": { + "name": "attendance_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "notification_preferences_user_idx": { + "name": "notification_preferences_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_preferences_user_id_users_id_fk": { + "name": "notification_preferences_user_id_users_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "np_u_fk": { + "name": "np_u_fk", + "tableFrom": "notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_preferences_id": { + "name": "notification_preferences_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "notification_preferences_user_id_unique": { + "name": "notification_preferences_user_id_unique", + "columns": [ + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "parent_student_relations": { + "name": "parent_student_relations", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "relation": { + "name": "relation", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "parent_student_relations_parent_idx": { + "name": "parent_student_relations_parent_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "parent_student_relations_student_idx": { + "name": "parent_student_relations_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "parent_student_relations_parent_id_users_id_fk": { + "name": "parent_student_relations_parent_id_users_id_fk", + "tableFrom": "parent_student_relations", + "tableTo": "users", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "parent_student_relations_student_id_users_id_fk": { + "name": "parent_student_relations_student_id_users_id_fk", + "tableFrom": "parent_student_relations", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "psr_p_fk": { + "name": "psr_p_fk", + "tableFrom": "parent_student_relations", + "tableTo": "users", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "psr_s_fk": { + "name": "psr_s_fk", + "tableFrom": "parent_student_relations", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "parent_student_relations_id": { + "name": "parent_student_relations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "password_security": { + "name": "password_security", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "failed_login_attempts": { + "name": "failed_login_attempts", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_changed_at": { + "name": "password_changed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "password_security_user_idx": { + "name": "password_security_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "password_security_user_id_users_id_fk": { + "name": "password_security_user_id_users_id_fk", + "tableFrom": "password_security", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ps_u_fk": { + "name": "ps_u_fk", + "tableFrom": "password_security", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "password_security_id": { + "name": "password_security_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "password_security_user_id_unique": { + "name": "password_security_user_id_unique", + "columns": [ + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "questions": { + "name": "questions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('single_choice','multiple_choice','text','judgment','composite')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": [ + "author_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "questions_author_id_users_id_fk": { + "name": "questions_author_id_users_id_fk", + "tableFrom": "questions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_id": { + "name": "questions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions_to_knowledge_points": { + "name": "questions_to_knowledge_points", + "columns": { + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "knowledge_point_id": { + "name": "knowledge_point_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "kp_idx": { + "name": "kp_idx", + "columns": [ + "knowledge_point_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "q_kp_qid_fk": { + "name": "q_kp_qid_fk", + "tableFrom": "questions_to_knowledge_points", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "q_kp_kpid_fk": { + "name": "q_kp_kpid_fk", + "tableFrom": "questions_to_knowledge_points", + "tableTo": "knowledge_points", + "columnsFrom": [ + "knowledge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_to_knowledge_points_question_id_knowledge_point_id_pk": { + "name": "questions_to_knowledge_points_question_id_knowledge_point_id_pk", + "columns": [ + "question_id", + "knowledge_point_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "role_permissions": { + "name": "role_permissions", + "columns": { + "role_id": { + "name": "role_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "role_permissions_role_idx": { + "name": "role_permissions_role_idx", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_pk": { + "name": "role_permissions_role_id_permission_pk", + "columns": [ + "role_id", + "permission" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "schedule_changes": { + "name": "schedule_changes", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_schedule_id": { + "name": "original_schedule_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_teacher_id": { + "name": "original_teacher_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "substitute_teacher_id": { + "name": "substitute_teacher_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_date": { + "name": "original_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_date": { + "name": "new_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_start_time": { + "name": "new_start_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_end_time": { + "name": "new_end_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','approved','rejected','completed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "requested_by": { + "name": "requested_by", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "schedule_changes_class_idx": { + "name": "schedule_changes_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + }, + "schedule_changes_status_idx": { + "name": "schedule_changes_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "schedule_changes_requested_by_idx": { + "name": "schedule_changes_requested_by_idx", + "columns": [ + "requested_by" + ], + "isUnique": false + }, + "schedule_changes_original_schedule_idx": { + "name": "schedule_changes_original_schedule_idx", + "columns": [ + "original_schedule_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schedule_changes_id": { + "name": "schedule_changes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "scheduling_rules": { + "name": "scheduling_rules", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_daily_hours": { + "name": "max_daily_hours", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 8 + }, + "max_continuous_hours": { + "name": "max_continuous_hours", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 2 + }, + "lunch_break_start": { + "name": "lunch_break_start", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'12:00'" + }, + "lunch_break_end": { + "name": "lunch_break_end", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'13:00'" + }, + "morning_start": { + "name": "morning_start", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'08:00'" + }, + "afternoon_end": { + "name": "afternoon_end", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'17:00'" + }, + "avoid_back_to_back": { + "name": "avoid_back_to_back", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "balanced_subjects": { + "name": "balanced_subjects", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "scheduling_rules_class_idx": { + "name": "scheduling_rules_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "scheduling_rules_id": { + "name": "scheduling_rules_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "schools": { + "name": "schools", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "schools_name_idx": { + "name": "schools_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "schools_code_idx": { + "name": "schools_code_idx", + "columns": [ + "code" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schools_id": { + "name": "schools_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "schools_name_unique": { + "name": "schools_name_unique", + "columns": [ + "name" + ] + }, + "schools_code_unique": { + "name": "schools_code_unique", + "columns": [ + "code" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_userId_users_id_fk": { + "name": "sessions_userId_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_sessionToken": { + "name": "sessions_sessionToken", + "columns": [ + "sessionToken" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subjects": { + "name": "subjects", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "subjects_name_idx": { + "name": "subjects_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subjects_id": { + "name": "subjects_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "subjects_name_unique": { + "name": "subjects_name_unique", + "columns": [ + "name" + ] + }, + "subjects_code_unique": { + "name": "subjects_code_unique", + "columns": [ + "code" + ] + } + }, + "checkConstraint": {} + }, + "submission_answers": { + "name": "submission_answers", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_id": { + "name": "submission_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer_content": { + "name": "answer_content", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "submission_idx": { + "name": "submission_idx", + "columns": [ + "submission_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "submission_answers_submission_id_exam_submissions_id_fk": { + "name": "submission_answers_submission_id_exam_submissions_id_fk", + "tableFrom": "submission_answers", + "tableTo": "exam_submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submission_answers_question_id_questions_id_fk": { + "name": "submission_answers_question_id_questions_id_fk", + "tableFrom": "submission_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "submission_answers_id": { + "name": "submission_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "textbooks": { + "name": "textbooks", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade": { + "name": "grade", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "textbooks_id": { + "name": "textbooks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gender": { + "name": "gender", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age": { + "name": "age", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grade_id": { + "name": "grade_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "department_id": { + "name": "department_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guardian_name": { + "name": "guardian_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guardian_phone": { + "name": "guardian_phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guardian_relation": { + "name": "guardian_relation", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consent_accepted_at": { + "name": "consent_accepted_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_grade_id_idx": { + "name": "users_grade_id_idx", + "columns": [ + "grade_id" + ], + "isUnique": false + }, + "users_department_id_idx": { + "name": "users_department_id_idx", + "columns": [ + "department_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "users_to_roles": { + "name": "users_to_roles", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_roles_user_id_users_id_fk": { + "name": "users_to_roles_user_id_users_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_to_roles_role_id_roles_id_fk": { + "name": "users_to_roles_role_id_roles_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_roles_user_id_role_id_pk": { + "name": "users_to_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationTokens": { + "name": "verificationTokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationTokens_identifier_token_pk": { + "name": "verificationTokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e9ebd31..1172d51 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1781676504560, "tag": "0000_perfect_pestilence", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1781679978738, + "tag": "0001_heavy_sage", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/seed.ts b/scripts/seed.ts index c550d7d..3e8b626 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -20,6 +20,56 @@ import { sql, eq } from "drizzle-orm"; import { hash } from "bcryptjs"; import { ROLE_PERMISSIONS } from "../src/shared/lib/permissions"; +// ---- 内部类型定义(避免 any) ---- + +interface SeedOption { + id: string; + text: string; + isCorrect?: boolean; +} + +interface SeedQuestionContent { + text?: string; + options?: SeedOption[]; + correctAnswer?: string | boolean; +} + +interface SeedQuestion { + id: string; + type: "single_choice" | "text" | "judgment"; + difficulty: number; + content: SeedQuestionContent; + score: number; +} + +interface SeedQuestionBank { + chineseQuestions: SeedQuestion[]; + mathQuestions: SeedQuestion[]; + engQuestions?: SeedQuestion[]; +} + +interface SeedGradeRecord { + id: string; + studentId: string; + subjectId: string; + classId: string; + teacherId: string; + score: number; + examType: string; + term: string; + recordedAt: Date; +} + +interface SeedAttendanceRecord { + id: string; + studentId: string; + classId: string; + date: Date; + status: "present" | "late" | "absent"; + recorderId: string; + remark: string | null; +} + /** * 小学场景初始化脚本 * @@ -73,22 +123,22 @@ async function seed() { await seedEnrollmentsAndParents(classMap, studentMap, parentMap); // --- 8. 教材 + 章节(每科第一章第一节课)--- - const chapterMap = await seedTextbooksAndChapters(subjectMap, gradeMap); + const chapterMap = await seedTextbooksAndChapters(); // --- 9. 知识点 --- const kpMap = await seedKnowledgePoints(chapterMap); // --- 10. 课表 --- - await seedClassSchedule(classMap, subjectMap); + await seedClassSchedule(classMap); // --- 11. 题库 --- const questionBanks = await seedQuestions(teacherMap, subjectMap, kpMap); // --- 12. 试卷(语文、数学各 1 套)+ 学生答题与批改 --- - await seedExamsAndSubmissions(teacherMap, classMap, studentMap, subjectMap, questionBanks, gradeMap); + await seedExamsAndSubmissions(teacherMap, classMap, studentMap, questionBanks); // --- 13. 作业(引用试卷)+ 学生答题与批改 --- - await seedHomework(teacherMap, classMap, studentMap, questionBanks, subjectMap); + await seedHomework(teacherMap, classMap, studentMap, questionBanks); // --- 14. 成绩记录 --- await seedGradeRecords(teacherMap, classMap, studentMap, subjectMap, academicYearId); @@ -451,10 +501,7 @@ async function seedEnrollmentsAndParents( // ============ 8. 教材 + 章节 ============ -async function seedTextbooksAndChapters( - subjectMap: Record, - gradeMap: Record -) { +async function seedTextbooksAndChapters() { console.log("📖 创建教材与章节..."); // 每科 1 本教材(一年级语文、一年级数学、一年级英语) // 只实现第一章第一节课 @@ -558,8 +605,7 @@ async function seedKnowledgePoints(chapterMap: Record) { // ============ 10. 课表 ============ async function seedClassSchedule( - classMap: Record, - subjectMap: Record + classMap: Record ) { console.log("📅 创建课表..."); // 每班每天安排语数外,简化为周一三五各 2 节 @@ -807,13 +853,11 @@ async function seedExamsAndSubmissions( teacherMap: Record, classMap: Record, studentMap: Record, - subjectMap: Record, - questionBanks: { chineseQuestions: any[]; mathQuestions: any[] }, - gradeMap: Record + questionBanks: SeedQuestionBank ) { console.log("📝 创建试卷与学生答题..."); - const makeGroup = (title: string, children: any[]) => ({ + const makeGroup = (title: string, children: SeedQuestion[]) => ({ id: createId(), type: "group" as const, title, @@ -926,12 +970,12 @@ async function seedExamsAndSubmissions( submissionCount++; // 答案 - const buildAnswer = (q: any, idx: number) => { + const buildAnswer = (q: SeedQuestion, idx: number) => { if (q.type === "single_choice") { // 前 3 题选正确项,后 2 题选错误项 - const correct = q.content.options.find((o: any) => o.isCorrect); - const wrong = q.content.options.find((o: any) => !o.isCorrect); - return { answer: idx < 3 ? correct.id : wrong.id }; + const correct = q.content.options?.find((o) => o.isCorrect); + const wrong = q.content.options?.find((o) => !o.isCorrect); + return { answer: idx < 3 ? correct?.id : wrong?.id }; } if (q.type === "judgment") return { answer: idx < 3 }; return { answer: idx < 3 ? "标准答案" : "学生答案" }; @@ -939,7 +983,7 @@ async function seedExamsAndSubmissions( await db.insert(submissionAnswers).values( exam.qIds.map((qid, idx) => { - const q = exam.qs.find((x: any) => x.id === qid)!; + const q = exam.qs.find((x) => x.id === qid)!; const score = isGraded ? (perScores[idx] ?? 0) : null; return { id: createId(), @@ -963,8 +1007,7 @@ async function seedHomework( teacherMap: Record, classMap: Record, studentMap: Record, - questionBanks: { chineseQuestions: any[]; mathQuestions: any[]; engQuestions: any[] }, - subjectMap: Record + questionBanks: SeedQuestionBank ) { console.log("📚 创建作业..."); // 1 个语文作业(引用语文题)+ 1 个数学作业(引用数学题) @@ -972,7 +1015,7 @@ async function seedHomework( const dueAt = new Date(now.getTime() + 7 * 86400_000); const lateDueAt = new Date(now.getTime() + 9 * 86400_000); - const assignments: { id: string; title: string; creatorId: string; qIds: string[]; qs: any[]; targetClassKey: string }[] = [ + const assignments: { id: string; title: string; creatorId: string; qIds: string[]; qs: SeedQuestion[]; targetClassKey: string }[] = [ { id: "hw_chinese_g1", title: "一年级语文第一课作业", @@ -1039,7 +1082,6 @@ async function seedHomework( ); // 前 3 个学生提交作业并批改 - const scoreMap = new Map(hw.qs.map(q => [q.id, q.score] as const)); for (let i = 0; i < Math.min(3, targets.length); i++) { const student = targets[i]; const submissionId = createId(); @@ -1067,11 +1109,11 @@ async function seedHomework( }); hwSubmissionCount++; - const buildAnswer = (q: any, idx: number) => { + const buildAnswer = (q: SeedQuestion, idx: number) => { if (q.type === "single_choice") { - const correct = q.content.options.find((o: any) => o.isCorrect); - const wrong = q.content.options.find((o: any) => !o.isCorrect); - return { answer: idx < 3 ? correct.id : wrong.id }; + const correct = q.content.options?.find((o) => o.isCorrect); + const wrong = q.content.options?.find((o) => !o.isCorrect); + return { answer: idx < 3 ? correct?.id : wrong?.id }; } if (q.type === "judgment") return { answer: idx < 3 }; return { answer: idx < 3 ? "标准答案" : "学生答案" }; @@ -1079,7 +1121,7 @@ async function seedHomework( await db.insert(homeworkAnswers).values( hw.qIds.map((qid, idx) => { - const q = hw.qs.find((x: any) => x.id === qid)!; + const q = hw.qs.find((x) => x.id === qid)!; const score = isGraded ? (perScores[idx] ?? 0) : null; return { id: createId(), @@ -1117,7 +1159,7 @@ async function seedGradeRecords( ENG: teacherMap.T_E1.id, }; - const records: any[] = []; + const records: SeedGradeRecord[] = []; for (const s of g1c1Students) { for (const [subCode, sid] of Object.entries(subjectMap)) { records.push({ @@ -1151,7 +1193,7 @@ async function seedAttendanceRecords( // 为一年级1班最近 5 天的考勤 const g1c1Students = Object.values(studentMap).filter(s => s.classKey === "G1C1"); const recorder = teacherMap.T_C1.id; - const records: any[] = []; + const records: SeedAttendanceRecord[] = []; const statuses = ["present", "present", "present", "present", "late", "absent"] as const; for (let d = 0; d < 5; d++) { diff --git a/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx b/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx new file mode 100644 index 0000000..20519b9 --- /dev/null +++ b/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx @@ -0,0 +1,41 @@ +import { notFound } from "next/navigation" + +import { getElectiveCourseById, getSubjectOptions } from "@/modules/elective/data-access" +import { getGrades, getStaffOptions } from "@/modules/school/data-access" +import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form" + +export const dynamic = "force-dynamic" + +export default async function EditElectiveCoursePage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + + const [course, subjects, grades, teachers] = await Promise.all([ + getElectiveCourseById(id), + getSubjectOptions(), + getGrades(), + getStaffOptions(), + ]) + + if (!course) notFound() + + return ( +
+
+

Edit Elective Course

+

Update the elective course details below.

+
+ ({ id: g.id, name: g.name }))} + teachers={teachers.map((t) => ({ id: t.id, name: t.name }))} + backHref="/admin/elective" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/elective/create/page.tsx b/src/app/(dashboard)/admin/elective/create/page.tsx new file mode 100644 index 0000000..442ad7f --- /dev/null +++ b/src/app/(dashboard)/admin/elective/create/page.tsx @@ -0,0 +1,29 @@ +import { getGrades, getStaffOptions } from "@/modules/school/data-access" +import { getSubjectOptions } from "@/modules/elective/data-access" +import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form" + +export const dynamic = "force-dynamic" + +export default async function CreateElectiveCoursePage() { + const [subjects, grades, teachers] = await Promise.all([ + getSubjectOptions(), + getGrades(), + getStaffOptions(), + ]) + + return ( +
+
+

New Elective Course

+

Create a new elective course.

+
+ ({ id: g.id, name: g.name }))} + teachers={teachers.map((t) => ({ id: t.id, name: t.name }))} + backHref="/admin/elective" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/elective/page.tsx b/src/app/(dashboard)/admin/elective/page.tsx new file mode 100644 index 0000000..fd37542 --- /dev/null +++ b/src/app/(dashboard)/admin/elective/page.tsx @@ -0,0 +1,44 @@ +import { getElectiveCourses } from "@/modules/elective/data-access" +import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list" +import type { ElectiveCourseStatus } from "@/modules/elective/types" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +const isValidStatus = (v?: string): v is ElectiveCourseStatus => + v === "draft" || v === "open" || v === "closed" || v === "cancelled" + +export default async function AdminElectivePage({ + searchParams, +}: { + searchParams: Promise +}) { + const sp = await searchParams + const statusParam = getParam(sp, "status") + const status = isValidStatus(statusParam) ? statusParam : undefined + + const courses = await getElectiveCourses({ status }) + + return ( +
+
+

Elective Courses

+

+ Manage elective courses, open/close selection, and run lottery. +

+
+ `/admin/elective/${id}/edit`} + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/diagnostic/page.tsx b/src/app/(dashboard)/student/diagnostic/page.tsx new file mode 100644 index 0000000..2aee2a8 --- /dev/null +++ b/src/app/(dashboard)/student/diagnostic/page.tsx @@ -0,0 +1,31 @@ +import { Stethoscope } from "lucide-react" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getStudentMasterySummary } from "@/modules/diagnostic/data-access" +import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports" +import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view" + +export const dynamic = "force-dynamic" + +export default async function StudentDiagnosticPage() { + const ctx = await getAuthContext() + + const [summary, reports] = await Promise.all([ + getStudentMasterySummary(ctx.userId), + getDiagnosticReports({ studentId: ctx.userId }), + ]) + + return ( +
+
+

+ + My Diagnostic +

+

+ Your knowledge point mastery analysis and diagnostic reports. +

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/student/elective/page.tsx b/src/app/(dashboard)/student/elective/page.tsx new file mode 100644 index 0000000..438856e --- /dev/null +++ b/src/app/(dashboard)/student/elective/page.tsx @@ -0,0 +1,49 @@ +import { auth } from "@/auth" +import { Inbox } from "lucide-react" + +import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections" +import { StudentSelectionView } from "@/modules/elective/components/student-selection-view" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export const dynamic = "force-dynamic" + +export default async function StudentElectivePage() { + const session = await auth() + const studentId = String(session?.user?.id ?? "") + + if (!studentId) { + return ( +
+
+

Elective Courses

+

Browse and select elective courses.

+
+ +
+ ) + } + + const [availableCourses, mySelections] = await Promise.all([ + getAvailableCoursesForStudent(studentId), + getStudentSelections(studentId), + ]) + + return ( +
+
+

Elective Courses

+

+ Browse available electives and manage your selections. +

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx b/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx new file mode 100644 index 0000000..090e0ce --- /dev/null +++ b/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx @@ -0,0 +1,45 @@ +import { notFound } from "next/navigation" +import { Stethoscope } from "lucide-react" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getClassMasterySummary } from "@/modules/diagnostic/data-access" +import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view" + +export const dynamic = "force-dynamic" + +export default async function ClassDiagnosticPage({ + params, +}: { + params: Promise<{ classId: string }> +}) { + const { classId } = await params + const ctx = await getAuthContext() + + // DataScope 校验:教师只能查看所教班级,学生/家长不可访问 + if (ctx.dataScope.type === "class_taught" && !ctx.dataScope.classIds.includes(classId)) { + notFound() + } + if (ctx.dataScope.type === "class_members" || ctx.dataScope.type === "children") { + notFound() + } + + const summary = await getClassMasterySummary(classId) + + if (!summary) { + notFound() + } + + return ( +
+
+

+ + Class Diagnostic +

+

+ Class-level knowledge point mastery overview and student attention list. +

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/diagnostic/page.tsx b/src/app/(dashboard)/teacher/diagnostic/page.tsx new file mode 100644 index 0000000..52e33c6 --- /dev/null +++ b/src/app/(dashboard)/teacher/diagnostic/page.tsx @@ -0,0 +1,48 @@ +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports" +import { ReportList } from "@/modules/diagnostic/components/report-list" +import type { DiagnosticReportType, DiagnosticReportStatus } from "@/modules/diagnostic/types" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function TeacherDiagnosticPage({ + searchParams, +}: { + searchParams: Promise +}) { + const sp = await searchParams + const ctx = await getAuthContext() + + const reportType = getParam(sp, "reportType") + const status = getParam(sp, "status") + + const reports = await getDiagnosticReports({ + reportType: reportType && reportType !== "all" ? (reportType as DiagnosticReportType) : undefined, + status: status && status !== "all" ? (status as DiagnosticReportStatus) : undefined, + }) + + // 学生角色仅查看自己的报告;其他角色查看全部 + const visibleReports = + ctx.dataScope.type === "class_members" + ? reports.filter((r) => r.studentId === ctx.userId) + : reports + + return ( +
+
+

Learning Diagnostic

+

+ View and manage diagnostic reports based on knowledge point mastery. +

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx b/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx new file mode 100644 index 0000000..25c5884 --- /dev/null +++ b/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx @@ -0,0 +1,65 @@ +import { notFound } from "next/navigation" +import { Stethoscope } from "lucide-react" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { + getStudentMasterySummary, + getKnowledgePointStats, +} from "@/modules/diagnostic/data-access" +import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports" +import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view" +import type { MasteryRadarPoint } from "@/modules/diagnostic/types" + +export const dynamic = "force-dynamic" + +export default async function StudentDiagnosticPage({ + params, +}: { + params: Promise<{ studentId: string }> +}) { + const { studentId } = await params + const ctx = await getAuthContext() + + // DataScope 二次校验:学生只能看自己,家长只能看子女 + if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) { + notFound() + } + if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) { + notFound() + } + + const [summary, reports] = await Promise.all([ + getStudentMasterySummary(studentId), + getDiagnosticReports({ studentId }), + ]) + + // 班级平均掌握度(用于雷达图对比) + let classAverageMastery: MasteryRadarPoint[] | undefined + if (summary) { + // 通过学生所在班级获取班级平均 + const classStats = await getKnowledgePointStats() + classAverageMastery = classStats.map((k) => ({ + knowledgePoint: k.knowledgePointName, + student: 0, + classAverage: k.averageMastery, + })) + } + + return ( +
+
+

+ + Student Diagnostic +

+

+ Knowledge point mastery analysis and diagnostic reports. +

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/elective/page.tsx b/src/app/(dashboard)/teacher/elective/page.tsx new file mode 100644 index 0000000..943b5a7 --- /dev/null +++ b/src/app/(dashboard)/teacher/elective/page.tsx @@ -0,0 +1,50 @@ +import { auth } from "@/auth" +import { getElectiveCourses } from "@/modules/elective/data-access" +import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list" +import type { ElectiveCourseStatus } from "@/modules/elective/types" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +const isValidStatus = (v?: string): v is ElectiveCourseStatus => + v === "draft" || v === "open" || v === "closed" || v === "cancelled" + +export default async function TeacherElectivePage({ + searchParams, +}: { + searchParams: Promise +}) { + const session = await auth() + const teacherId = String(session?.user?.id ?? "") + + const sp = await searchParams + const statusParam = getParam(sp, "status") + const status = isValidStatus(statusParam) ? statusParam : undefined + + const courses = teacherId + ? await getElectiveCourses({ teacherId, status }) + : [] + + return ( +
+
+

My Elective Courses

+

+ View and manage the elective courses you teach. +

+
+ `/admin/elective/${id}/edit`} + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx b/src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx new file mode 100644 index 0000000..bbbad32 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx @@ -0,0 +1,55 @@ +import { notFound } from "next/navigation" +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { ProctoringDashboard } from "@/modules/proctoring/components/proctoring-dashboard" +import { + getExamForProctoring, + getExamProctoringSummary, + getStudentProctoringStatuses, + getRecentProctoringEvents, +} from "@/modules/proctoring/data-access" +import type { ProctoringDashboardData } from "@/modules/proctoring/types" + +export const dynamic = "force-dynamic" + +export default async function ExamProctoringPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + try { + await requirePermission(Permissions.EXAM_PROCTOR) + } catch (error) { + if (error instanceof PermissionDeniedError) { + return ( +
+ 您没有监考权限(exam:proctor)。 +
+ ) + } + throw error + } + + const { id } = await params + const exam = await getExamForProctoring(id) + if (!exam) return notFound() + + // 并行拉取面板初始数据 + const [summary, students, recentEvents] = await Promise.all([ + getExamProctoringSummary(id), + getStudentProctoringStatuses(id), + getRecentProctoringEvents(id, 20), + ]) + + const initialData: ProctoringDashboardData = { + summary, + students, + recentEvents, + } + + return ( +
+ +
+ ) +} diff --git a/src/app/api/proctoring/event/route.ts b/src/app/api/proctoring/event/route.ts new file mode 100644 index 0000000..d852003 --- /dev/null +++ b/src/app/api/proctoring/event/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { requireAuth, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { db } from "@/shared/db" +import { examSubmissions } from "@/shared/db/schema" +import { and, eq } from "drizzle-orm" +import { recordProctoringEvent } from "@/modules/proctoring/data-access" +import type { ProctoringEventType } from "@/modules/proctoring/types" + +export const dynamic = "force-dynamic" + +const EventSchema = z.object({ + submissionId: z.string().min(1), + eventType: z.enum([ + "tab_switch", + "window_blur", + "copy_attempt", + "paste_attempt", + "right_click", + "devtools_open", + "fullscreen_exit", + "idle_timeout", + ]) as z.ZodType, + eventDetail: z.string().optional(), +}) + +export async function POST(req: Request) { + try { + const ctx = await requireAuth() + + const body = await req.json().catch(() => null) + if (!body) { + return NextResponse.json( + { success: false, message: "Invalid JSON body" }, + { status: 400 }, + ) + } + + const parsed = EventSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { + success: false, + message: parsed.error.issues[0]?.message ?? "Invalid payload", + }, + { status: 400 }, + ) + } + + // 安全校验:submission 必须属于当前学生 + const submission = await db.query.examSubmissions.findFirst({ + where: and( + eq(examSubmissions.id, parsed.data.submissionId), + eq(examSubmissions.studentId, ctx.userId), + ), + columns: { + id: true, + examId: true, + }, + }) + + if (!submission) { + return NextResponse.json( + { success: false, message: "Submission not found for current user" }, + { status: 404 }, + ) + } + + await recordProctoringEvent({ + submissionId: parsed.data.submissionId, + studentId: ctx.userId, + examId: submission.examId, + eventType: parsed.data.eventType, + eventDetail: parsed.data.eventDetail, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof PermissionDeniedError) { + return NextResponse.json( + { success: false, message: error.message }, + { status: 401 }, + ) + } + console.error("POST /api/proctoring/event error:", error) + return NextResponse.json( + { success: false, message: "Failed to record proctoring event" }, + { status: 500 }, + ) + } +} diff --git a/src/modules/diagnostic/actions.ts b/src/modules/diagnostic/actions.ts new file mode 100644 index 0000000..fc1f652 --- /dev/null +++ b/src/modules/diagnostic/actions.ts @@ -0,0 +1,148 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import type { ActionState } from "@/shared/types/action-state" + +import { + generateDiagnosticReport, + generateClassDiagnosticReport, + getDiagnosticReports, + getDiagnosticReportById, + publishDiagnosticReport, + deleteDiagnosticReport, +} from "./data-access-reports" +import type { DiagnosticReportQueryParams } from "./types" + +/** 生成学生个人诊断报告 */ +export async function generateStudentReportAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE) + + const studentId = formData.get("studentId") + const period = formData.get("period") + if (typeof studentId !== "string" || studentId.length === 0) { + return { success: false, message: "Missing studentId" } + } + if (typeof period !== "string" || period.length === 0) { + return { success: false, message: "Missing period" } + } + + const id = await generateDiagnosticReport(studentId, period, ctx.userId) + revalidatePath("/teacher/diagnostic") + revalidatePath(`/teacher/diagnostic/student/${studentId}`) + return { success: true, message: "Diagnostic report generated", data: id } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +/** 生成班级诊断报告 */ +export async function generateClassReportAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE) + + const classId = formData.get("classId") + const period = formData.get("period") + if (typeof classId !== "string" || classId.length === 0) { + return { success: false, message: "Missing classId" } + } + if (typeof period !== "string" || period.length === 0) { + return { success: false, message: "Missing period" } + } + + const id = await generateClassDiagnosticReport(classId, period, ctx.userId) + revalidatePath("/teacher/diagnostic") + revalidatePath(`/teacher/diagnostic/class/${classId}`) + return { success: true, message: "Class diagnostic report generated", data: id } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +/** 发布诊断报告 */ +export async function publishReportAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.DIAGNOSTIC_MANAGE) + + const id = formData.get("id") + if (typeof id !== "string" || id.length === 0) { + return { success: false, message: "Missing report id" } + } + + await publishDiagnosticReport(id) + revalidatePath("/teacher/diagnostic") + return { success: true, message: "Report published" } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +/** 删除诊断报告 */ +export async function deleteReportAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.DIAGNOSTIC_MANAGE) + + const id = formData.get("id") + if (typeof id !== "string" || id.length === 0) { + return { success: false, message: "Missing report id" } + } + + await deleteDiagnosticReport(id) + revalidatePath("/teacher/diagnostic") + return { success: true, message: "Report deleted" } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +/** 查询诊断报告列表(读权限) */ +export async function getDiagnosticReportsAction( + params: DiagnosticReportQueryParams +): Promise>>> { + try { + await requirePermission(Permissions.DIAGNOSTIC_READ) + const reports = await getDiagnosticReports(params) + return { success: true, data: reports } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +/** 获取诊断报告详情(读权限) */ +export async function getDiagnosticReportByIdAction( + id: string +): Promise>>> { + try { + await requirePermission(Permissions.DIAGNOSTIC_READ) + const report = await getDiagnosticReportById(id) + return { success: true, data: report } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} diff --git a/src/modules/diagnostic/components/class-diagnostic-view.tsx b/src/modules/diagnostic/components/class-diagnostic-view.tsx new file mode 100644 index 0000000..94e59af --- /dev/null +++ b/src/modules/diagnostic/components/class-diagnostic-view.tsx @@ -0,0 +1,267 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { Users, AlertTriangle, TrendingUp, FileText } from "lucide-react" + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" +import { usePermission } from "@/shared/hooks" +import { Permissions } from "@/shared/types/permissions" +import { generateClassReportAction } from "../actions" +import type { ClassMasterySummary } from "../types" + +interface ClassDiagnosticViewProps { + summary: ClassMasterySummary | null +} + +/** 掌握度热力图颜色 */ +function masteryColor(level: number): string { + if (level >= 80) return "bg-green-500" + if (level >= 60) return "bg-yellow-500" + if (level >= 40) return "bg-orange-500" + return "bg-red-500" +} + +export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { + const router = useRouter() + const { hasPermission } = usePermission() + const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) + const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7)) + const [isGenerating, setIsGenerating] = useState(false) + + const handleGenerate = async () => { + if (!summary) return + setIsGenerating(true) + const formData = new FormData() + formData.set("classId", summary.classId) + formData.set("period", period) + const result = await generateClassReportAction(null, formData) + setIsGenerating(false) + if (result.success) { + toast.success(result.message) + router.refresh() + } else { + toast.error(result.message || "Failed to generate class report") + } + } + + if (!summary) { + return ( + + ) + } + + return ( +
+ {/* 概览 */} +
+ + + Class + + +

{summary.className}

+
+
+ + + Students + + +

{summary.studentCount}

+
+
+ + + Avg Mastery + + +

{summary.averageMastery.toFixed(1)}%

+
+
+ + + Need Attention + + +

{summary.studentsNeedingAttention.length}

+
+
+
+ + {/* 知识点掌握度热力图 */} + + + + + Knowledge Point Mastery Heatmap + + + Average mastery level per knowledge point (green ≥80%, yellow 60-79%, orange 40-59%, red <40%). + + + + {summary.knowledgePointStats.length === 0 ? ( +

No knowledge point data available.

+ ) : ( +
+ {summary.knowledgePointStats.map((kp) => ( +
+ + {kp.knowledgePointName} + + {kp.averageMastery.toFixed(0)}% +
+ ))} +
+ )} +
+
+ + {/* 知识点排名表 */} + + + Knowledge Point Ranking + + + {summary.knowledgePointStats.length === 0 ? ( +

No data.

+ ) : ( +
+ + + + Knowledge Point + Avg Mastery + Mastered (≥80%) + Not Mastered (<60%) + + + + {[...summary.knowledgePointStats] + .sort((a, b) => b.averageMastery - a.averageMastery) + .map((kp) => ( + + {kp.knowledgePointName} + + = 80 ? "default" : kp.averageMastery >= 60 ? "secondary" : "destructive"}> + {kp.averageMastery.toFixed(1)}% + + + {kp.masteredCount} + {kp.notMasteredCount} + + ))} + +
+
+ )} +
+
+ + {/* 需重点关注的学生 */} + + + + + Students Needing Attention (avg <60%) + + Students with low overall mastery. + + + {summary.studentsNeedingAttention.length === 0 ? ( +

All students are above the attention threshold.

+ ) : ( +
+ + + + Student + Avg Mastery + Weak Points + + + + + {summary.studentsNeedingAttention.map((s) => ( + + {s.studentName} + + {s.averageMastery.toFixed(1)}% + + {s.weakCount} + + + + + ))} + +
+
+ )} +
+
+ + {/* 生成班级报告 */} + {canManage ? ( + + + + + Generate Class Diagnostic Report + + + Generate a class-level diagnostic report with aggregated analysis. + + + +
+
+ + setPeriod(e.target.value)} + className="w-[180px]" + /> +
+ +
+
+
+ ) : null} +
+ ) +} diff --git a/src/modules/diagnostic/components/mastery-radar-chart.tsx b/src/modules/diagnostic/components/mastery-radar-chart.tsx new file mode 100644 index 0000000..513767c --- /dev/null +++ b/src/modules/diagnostic/components/mastery-radar-chart.tsx @@ -0,0 +1,114 @@ +"use client" + +import { RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend } from "recharts" +import { Target } from "lucide-react" + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card" +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/shared/components/ui/chart" +import { EmptyState } from "@/shared/components/ui/empty-state" +import type { MasteryRadarPoint } from "@/modules/diagnostic/types" + +const chartConfig = { + student: { label: "Student", color: "hsl(var(--primary))" }, + classAverage: { label: "Class Avg", color: "hsl(var(--chart-2))" }, +} + +interface MasteryRadarChartProps { + data: MasteryRadarPoint[] +} + +export function MasteryRadarChart({ data }: MasteryRadarChartProps) { + if (!data || data.length === 0) { + return ( + + + + + Knowledge Point Mastery + + + Radar chart of mastery level (0-100) across knowledge points. + + + + + + + ) + } + + // 知识点名称过长时截断显示 + const chartData = data.map((d) => ({ + ...d, + shortName: d.knowledgePoint.length > 8 ? `${d.knowledgePoint.slice(0, 8)}...` : d.knowledgePoint, + })) + + const hasClassAverage = data.some((d) => d.classAverage !== undefined) + + return ( + + + + + Knowledge Point Mastery + + + Radar chart of mastery level (0-100) across knowledge points. + + + + + + + + + } /> + {hasClassAverage ? : null} + + {hasClassAverage ? ( + + ) : null} + + + + + ) +} diff --git a/src/modules/diagnostic/components/report-list.tsx b/src/modules/diagnostic/components/report-list.tsx new file mode 100644 index 0000000..e067f9b --- /dev/null +++ b/src/modules/diagnostic/components/report-list.tsx @@ -0,0 +1,262 @@ +"use client" + +import { useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { useCallback } from "react" +import { toast } from "sonner" +import { FileText, Trash2, Send } from "lucide-react" + +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Label } from "@/shared/components/ui/label" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog" +import { formatDate } from "@/shared/lib/utils" +import { usePermission } from "@/shared/hooks" +import { Permissions } from "@/shared/types/permissions" +import { publishReportAction, deleteReportAction } from "../actions" +import type { DiagnosticReportWithDetails } from "../types" + +const typeLabels: Record = { + individual: "Individual", + class: "Class", + grade: "Grade", +} + +const statusColors: Record = { + draft: "secondary", + published: "default", + archived: "outline", +} + +interface ReportListProps { + reports: DiagnosticReportWithDetails[] +} + +export function ReportList({ reports }: ReportListProps) { + const router = useRouter() + const searchParams = useSearchParams() + const { hasPermission } = usePermission() + const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) + + const [deleteId, setDeleteId] = useState(null) + const [publishId, setPublishId] = useState(null) + const [isBusy, setIsBusy] = useState(false) + + const updateParam = useCallback( + (key: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()) + if (value && value !== "all") { + params.set(key, value) + } else { + params.delete(key) + } + router.push(`?${params.toString()}`) + }, + [router, searchParams] + ) + + const handlePublish = async () => { + if (!publishId) return + setIsBusy(true) + const formData = new FormData() + formData.set("id", publishId) + const result = await publishReportAction(null, formData) + setIsBusy(false) + if (result.success) { + toast.success(result.message) + setPublishId(null) + router.refresh() + } else { + toast.error(result.message || "Failed to publish") + } + } + + const handleDelete = async () => { + if (!deleteId) return + setIsBusy(true) + const formData = new FormData() + formData.set("id", deleteId) + const result = await deleteReportAction(null, formData) + setIsBusy(false) + if (result.success) { + toast.success(result.message) + setDeleteId(null) + router.refresh() + } else { + toast.error(result.message || "Failed to delete") + } + } + + const reportType = searchParams.get("reportType") ?? "all" + const status = searchParams.get("status") ?? "all" + + return ( +
+ {/* 过滤器 */} +
+
+ + +
+
+ + +
+
+ + {reports.length === 0 ? ( + + ) : ( +
+ + + + Type + Student / Target + Period + Score + Status + Generated By + Date + {canManage ? Actions : null} + + + + {reports.map((r) => ( + + + {typeLabels[r.reportType] ?? r.reportType} + + {r.studentName} + {r.period ?? "-"} + + {r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"} + + + {r.status} + + {r.generatedByName ?? "-"} + {formatDate(r.createdAt)} + {canManage ? ( + +
+ {r.status === "draft" ? ( + + ) : null} + +
+
+ ) : null} +
+ ))} +
+
+
+ )} + + {/* 发布确认 */} + !open && setPublishId(null)}> + + + Publish Report + + Once published, the report will be visible to students. Continue? + + + + + + + + + + {/* 删除确认 */} + !open && setDeleteId(null)}> + + + Delete Report + + Are you sure you want to delete this diagnostic report? This action cannot be undone. + + + + + + + + +
+ ) +} diff --git a/src/modules/diagnostic/components/student-diagnostic-view.tsx b/src/modules/diagnostic/components/student-diagnostic-view.tsx new file mode 100644 index 0000000..67a750a --- /dev/null +++ b/src/modules/diagnostic/components/student-diagnostic-view.tsx @@ -0,0 +1,229 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { Award, AlertTriangle, Lightbulb, FileText, TrendingUp } from "lucide-react" + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { usePermission } from "@/shared/hooks" +import { Permissions } from "@/shared/types/permissions" +import { MasteryRadarChart } from "./mastery-radar-chart" +import { generateStudentReportAction } from "../actions" +import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types" + +interface StudentDiagnosticViewProps { + summary: StudentMasterySummary | null + reports: DiagnosticReportWithDetails[] + classAverageMastery?: MasteryRadarPoint[] +} + +export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) { + const router = useRouter() + const { hasPermission } = usePermission() + const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) + const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7)) + const [isGenerating, setIsGenerating] = useState(false) + + const handleGenerate = async () => { + if (!summary) return + setIsGenerating(true) + const formData = new FormData() + formData.set("studentId", summary.studentId) + formData.set("period", period) + const result = await generateStudentReportAction(null, formData) + setIsGenerating(false) + if (result.success) { + toast.success(result.message) + router.refresh() + } else { + toast.error(result.message || "Failed to generate report") + } + } + + if (!summary) { + return ( + + ) + } + + const radarData: MasteryRadarPoint[] = summary.allMastery.map((m) => { + const classAvg = classAverageMastery?.find((c) => c.knowledgePoint === m.knowledgePointName) + return { + knowledgePoint: m.knowledgePointName, + student: Math.round(m.masteryLevel * 100) / 100, + classAverage: classAvg?.classAverage, + } + }) + + const publishedReports = reports.filter((r) => r.status === "published") + const latestReport = publishedReports[0] ?? reports[0] ?? null + + return ( +
+ {/* 概览卡片 */} +
+ + + Student + + +

{summary.studentName}

+
+
+ + + Overall Mastery + + +

{summary.averageMastery.toFixed(1)}%

+
+
+ + + Strengths + + +

{summary.strengths.length}

+
+
+ + + Weaknesses + + +

{summary.weaknesses.length}

+
+
+
+ + {/* 雷达图 */} + + + {/* 强项 / 弱项 */} +
+ + + + + Strengths (≥80%) + + Knowledge points with high mastery. + + + {summary.strengths.length === 0 ? ( +

No strengths identified yet.

+ ) : ( +
    + {summary.strengths.map((m) => ( +
  • + {m.knowledgePointName} + {m.masteryLevel.toFixed(1)}% +
  • + ))} +
+ )} +
+
+ + + + + Weaknesses (<60%) + + Knowledge points needing attention. + + + {summary.weaknesses.length === 0 ? ( +

No weaknesses identified.

+ ) : ( +
    + {summary.weaknesses.map((m) => ( +
  • + {m.knowledgePointName} + {m.masteryLevel.toFixed(1)}% +
  • + ))} +
+ )} +
+
+
+ + {/* 生成报告 */} + {canManage ? ( + + + + + Generate Diagnostic Report + + + Generate an AI-analyzed diagnostic report for this student. + + + +
+
+ + setPeriod(e.target.value)} + className="w-[180px]" + /> +
+ +
+
+
+ ) : null} + + {/* 最新报告 / 建议 */} + {latestReport ? ( + + + + + Diagnostic Report + + {latestReport.status} + + + + Period: {latestReport.period ?? "-"} · Overall: {latestReport.overallScore?.toFixed(1) ?? "-"}% + + + + {latestReport.summary ? ( +

{latestReport.summary}

+ ) : null} + {latestReport.recommendations && latestReport.recommendations.length > 0 ? ( +
+

Recommendations

+
    + {latestReport.recommendations.map((rec, i) => ( +
  • • {rec}
  • + ))} +
+
+ ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/src/modules/diagnostic/data-access-reports.ts b/src/modules/diagnostic/data-access-reports.ts new file mode 100644 index 0000000..5d1fafa --- /dev/null +++ b/src/modules/diagnostic/data-access-reports.ts @@ -0,0 +1,202 @@ +import "server-only" + +import { and, desc, eq, inArray } from "drizzle-orm" + +import { db } from "@/shared/db" +import { learningDiagnosticReports, users } from "@/shared/db/schema" + +import { getClassMasterySummary, getStudentMasterySummary } from "./data-access" +import type { + DiagnosticReport, + DiagnosticReportQueryParams, + DiagnosticReportWithDetails, +} from "./types" + +const toNumber = (v: unknown): number => { + const n = typeof v === "number" ? v : Number(v) + return Number.isFinite(n) ? n : 0 +} + +const round2 = (n: number): number => Math.round(n * 100) / 100 + +const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({ + id: r.id, + studentId: r.studentId, + generatedBy: r.generatedBy, + reportType: r.reportType, + period: r.period, + summary: r.summary, + strengths: (r.strengths as string[] | null) ?? null, + weaknesses: (r.weaknesses as string[] | null) ?? null, + recommendations: (r.recommendations as string[] | null) ?? null, + overallScore: r.overallScore !== null ? toNumber(r.overallScore) : null, + status: r.status, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), +}) + +/** 生成个人诊断报告 */ +export async function generateDiagnosticReport( + studentId: string, + period: string, + generatedBy: string +): Promise { + const summary = await getStudentMasterySummary(studentId) + if (!summary) throw new Error("Student not found") + + const overallScore = summary.averageMastery + const strengths = summary.strengths.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`) + const weaknesses = summary.weaknesses.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`) + const recommendations = summary.weaknesses.map( + (m) => `建议复习「${m.knowledgePointName}」知识点,多做相关练习以提升掌握度(当前 ${m.masteryLevel.toFixed(1)}%)。` + ) + if (recommendations.length === 0) { + recommendations.push("整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。") + } + + const summaryText = `学生 ${summary.studentName} 在 ${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。` + + const { createId } = await import("@paralleldrive/cuid2") + const id = createId() + await db.insert(learningDiagnosticReports).values({ + id, + studentId, + generatedBy, + reportType: "individual", + period, + summary: summaryText, + strengths, + weaknesses, + recommendations, + overallScore: String(overallScore), + status: "draft", + }) + return id +} + +/** 生成班级诊断报告 */ +export async function generateClassDiagnosticReport( + classId: string, + period: string, + generatedBy: string +): Promise { + const summary = await getClassMasterySummary(classId) + if (!summary) throw new Error("Class not found") + + const topWeak = summary.knowledgePointStats + .filter((k) => k.averageMastery < 60) + .sort((a, b) => a.averageMastery - b.averageMastery) + .slice(0, 5) + const strengths = summary.knowledgePointStats + .filter((k) => k.averageMastery >= 80) + .map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`) + const weaknesses = topWeak.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`) + const recommendations = topWeak.map( + (k) => `班级在「${k.knowledgePointName}」整体掌握度偏低(${k.averageMastery.toFixed(1)}%),建议安排专项复习与巩固练习。` + ) + if (recommendations.length === 0) { + recommendations.push("班级整体掌握情况良好,建议保持当前教学节奏。") + } + + const summaryText = `班级 ${summary.className} 在 ${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。` + + const { createId } = await import("@paralleldrive/cuid2") + const id = createId() + await db.insert(learningDiagnosticReports).values({ + id, + studentId: generatedBy, // 班级报告 studentId 存生成者 ID(schema 要求 NOT NULL) + generatedBy, + reportType: "class", + period, + summary: summaryText, + strengths, + weaknesses, + recommendations, + overallScore: String(summary.averageMastery), + status: "draft", + }) + return id +} + +/** 查询诊断报告列表 */ +export async function getDiagnosticReports( + filters: DiagnosticReportQueryParams +): Promise { + const conditions = [] + if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId)) + if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType)) + if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status)) + if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period)) + + const rows = await db + .select({ + report: learningDiagnosticReports, + studentName: users.name, + }) + .from(learningDiagnosticReports) + .leftJoin(users, eq(users.id, learningDiagnosticReports.studentId)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(learningDiagnosticReports.createdAt)) + + const generatorIds = Array.from( + new Set(rows.map((r) => r.report.generatedBy).filter((id): id is string => id !== null)) + ) + const generatorMap = new Map() + if (generatorIds.length > 0) { + const generators = await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(inArray(users.id, generatorIds)) + for (const g of generators) generatorMap.set(g.id, g.name ?? "Unknown") + } + + return rows.map((r) => ({ + ...serializeReport(r.report), + studentName: r.studentName ?? "Unknown", + generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null, + })) +} + +/** 获取报告详情 */ +export async function getDiagnosticReportById( + id: string +): Promise { + const [row] = await db + .select({ report: learningDiagnosticReports, studentName: users.name }) + .from(learningDiagnosticReports) + .leftJoin(users, eq(users.id, learningDiagnosticReports.studentId)) + .where(eq(learningDiagnosticReports.id, id)) + .limit(1) + if (!row) return null + + let generatedByName: string | null = null + if (row.report.generatedBy) { + const [gen] = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, row.report.generatedBy)) + .limit(1) + generatedByName = gen?.name ?? null + } + return { + ...serializeReport(row.report), + studentName: row.studentName ?? "Unknown", + generatedByName, + } +} + +/** 发布诊断报告 */ +export async function publishDiagnosticReport(id: string): Promise { + await db + .update(learningDiagnosticReports) + .set({ status: "published", updatedAt: new Date() }) + .where(eq(learningDiagnosticReports.id, id)) +} + +/** 删除诊断报告 */ +export async function deleteDiagnosticReport(id: string): Promise { + await db.delete(learningDiagnosticReports).where(eq(learningDiagnosticReports.id, id)) +} + +// 防止 round2 未使用警告(保留以备扩展) +void round2 diff --git a/src/modules/diagnostic/data-access.ts b/src/modules/diagnostic/data-access.ts new file mode 100644 index 0000000..2517128 --- /dev/null +++ b/src/modules/diagnostic/data-access.ts @@ -0,0 +1,254 @@ +import "server-only" + +import { and, asc, desc, eq, inArray } from "drizzle-orm" + +import { db } from "@/shared/db" +import { + classEnrollments, + classes, + examSubmissions, + knowledgePointMastery, + knowledgePoints, + questionsToKnowledgePoints, + submissionAnswers, + users, +} from "@/shared/db/schema" + +import type { + ClassMasterySummary, + KnowledgePointMastery, + KnowledgePointStat, + MasteryWithKnowledgePoint, + StudentMasterySummary, +} from "./types" + +const toNumber = (v: unknown): number => { + const n = typeof v === "number" ? v : Number(v) + return Number.isFinite(n) ? n : 0 +} + +const round2 = (n: number): number => Math.round(n * 100) / 100 + +const serializeMastery = (r: typeof knowledgePointMastery.$inferSelect): KnowledgePointMastery => ({ + id: r.id, + studentId: r.studentId, + knowledgePointId: r.knowledgePointId, + masteryLevel: toNumber(r.masteryLevel), + totalQuestions: r.totalQuestions, + correctQuestions: r.correctQuestions, + lastAssessedAt: r.lastAssessedAt.toISOString(), + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), +}) + +/** 获取学生在所有知识点的掌握度(含知识点名称) */ +export async function getStudentMastery(studentId: string): Promise { + const rows = await db + .select({ + mastery: knowledgePointMastery, + kpName: knowledgePoints.name, + kpDescription: knowledgePoints.description, + }) + .from(knowledgePointMastery) + .leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) + .where(eq(knowledgePointMastery.studentId, studentId)) + .orderBy(desc(knowledgePointMastery.masteryLevel)) + + return rows.map((r) => ({ + ...serializeMastery(r.mastery), + knowledgePointName: r.kpName ?? "Unknown", + knowledgePointDescription: r.kpDescription, + })) +} + +/** 获取学生掌握度摘要(含强项/弱项分析) */ +export async function getStudentMasterySummary(studentId: string): Promise { + const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1) + if (!student) return null + + const allMastery = await getStudentMastery(studentId) + const averageMastery = + allMastery.length > 0 + ? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length) + : 0 + + return { + studentId, + studentName: student.name ?? "Unknown", + averageMastery, + totalKnowledgePoints: allMastery.length, + strengths: allMastery.filter((m) => m.masteryLevel >= 80), + weaknesses: allMastery.filter((m) => m.masteryLevel < 60), + allMastery, + } +} + +/** 从提交答案更新掌握度(正确率作为掌握度) */ +export async function updateMasteryFromSubmission(submissionId: string): Promise { + const [submission] = await db + .select({ studentId: examSubmissions.studentId }) + .from(examSubmissions) + .where(eq(examSubmissions.id, submissionId)) + .limit(1) + if (!submission) return + + const answers = await db + .select({ + questionId: submissionAnswers.questionId, + score: submissionAnswers.score, + }) + .from(submissionAnswers) + .where(eq(submissionAnswers.submissionId, submissionId)) + + if (answers.length === 0) return + + const questionIds = Array.from(new Set(answers.map((a) => a.questionId))) + const kpLinks = await db + .select({ + questionId: questionsToKnowledgePoints.questionId, + knowledgePointId: questionsToKnowledgePoints.knowledgePointId, + }) + .from(questionsToKnowledgePoints) + .where(inArray(questionsToKnowledgePoints.questionId, questionIds)) + + const kpStats = new Map() + for (const link of kpLinks) { + const answer = answers.find((a) => a.questionId === link.questionId) + if (!answer) continue + const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 } + stat.total += 1 + if ((answer.score ?? 0) > 0) stat.correct += 1 + kpStats.set(link.knowledgePointId, stat) + } + + const now = new Date() + for (const [kpId, stat] of kpStats.entries()) { + const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0 + await db + .insert(knowledgePointMastery) + .values({ + studentId: submission.studentId, + knowledgePointId: kpId, + masteryLevel: String(masteryLevel), + totalQuestions: stat.total, + correctQuestions: stat.correct, + lastAssessedAt: now, + }) + .onDuplicateKeyUpdate({ + set: { + masteryLevel: String(masteryLevel), + totalQuestions: stat.total, + correctQuestions: stat.correct, + lastAssessedAt: now, + updatedAt: now, + }, + }) + } +} + +/** 获取班级掌握度摘要 */ +export async function getClassMasterySummary(classId: string): Promise { + const [classRow] = await db.select({ id: classes.id, name: classes.name }).from(classes).where(eq(classes.id, classId)).limit(1) + if (!classRow) return null + + const students = await db + .select({ id: users.id, name: users.name }) + .from(classEnrollments) + .innerJoin(users, eq(users.id, classEnrollments.studentId)) + .where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active"))) + .orderBy(asc(users.name)) + + if (students.length === 0) { + return { classId, className: classRow.name, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] } + } + + const studentIds = students.map((s) => s.id) + const masteryRows = await db + .select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name }) + .from(knowledgePointMastery) + .leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) + .where(inArray(knowledgePointMastery.studentId, studentIds)) + + const byKp = new Map() + const byStudent = new Map() + for (const s of students) byStudent.set(s.id, { levels: [], weakCount: 0 }) + + for (const r of masteryRows) { + const level = toNumber(r.mastery.masteryLevel) + const kpId = r.mastery.knowledgePointId + const kpEntry = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 } + kpEntry.levels.push(level) + if (level >= 80) kpEntry.mastered += 1 + if (level < 60) kpEntry.notMastered += 1 + byKp.set(kpId, kpEntry) + + const stuEntry = byStudent.get(r.mastery.studentId) + if (stuEntry) { + stuEntry.levels.push(level) + if (level < 60) stuEntry.weakCount += 1 + } + } + + const knowledgePointStats: KnowledgePointStat[] = Array.from(byKp.entries()).map(([kpId, e]) => ({ + knowledgePointId: kpId, + knowledgePointName: e.name, + averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0, + masteredCount: e.mastered, + notMasteredCount: e.notMastered, + totalStudents: students.length, + })) + + const allLevels = masteryRows.map((r) => toNumber(r.mastery.masteryLevel)) + const averageMastery = allLevels.length > 0 ? round2(allLevels.reduce((a, b) => a + b, 0) / allLevels.length) : 0 + + const studentsNeedingAttention = students + .map((s) => { + const e = byStudent.get(s.id)! + const avg = e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0 + return { studentId: s.id, studentName: s.name ?? "Unknown", averageMastery: avg, weakCount: e.weakCount } + }) + .filter((s) => s.averageMastery < 60) + .sort((a, b) => a.averageMastery - b.averageMastery) + + return { classId, className: classRow.name, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention } +} + +/** 获取知识点统计(按班级或年级聚合) */ +export async function getKnowledgePointStats(classId?: string, gradeId?: string): Promise { + let studentIds: string[] = [] + if (classId) { + const rows = await db.select({ studentId: classEnrollments.studentId }).from(classEnrollments).where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active"))) + studentIds = rows.map((r) => r.studentId) + } else if (gradeId) { + const rows = await db.select({ id: users.id }).from(users).where(eq(users.gradeId, gradeId)) + studentIds = rows.map((r) => r.id) + } + + if (studentIds.length === 0) return [] + + const masteryRows = await db + .select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name }) + .from(knowledgePointMastery) + .leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) + .where(inArray(knowledgePointMastery.studentId, studentIds)) + + const byKp = new Map() + for (const r of masteryRows) { + const level = toNumber(r.mastery.masteryLevel) + const kpId = r.mastery.knowledgePointId + const e = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 } + e.levels.push(level) + if (level >= 80) e.mastered += 1 + if (level < 60) e.notMastered += 1 + byKp.set(kpId, e) + } + + return Array.from(byKp.entries()).map(([kpId, e]) => ({ + knowledgePointId: kpId, + knowledgePointName: e.name, + averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0, + masteredCount: e.mastered, + notMasteredCount: e.notMastered, + totalStudents: studentIds.length, + })) +} diff --git a/src/modules/diagnostic/types.ts b/src/modules/diagnostic/types.ts new file mode 100644 index 0000000..b848314 --- /dev/null +++ b/src/modules/diagnostic/types.ts @@ -0,0 +1,97 @@ +// Learning Diagnostic Module Types + +export type DiagnosticReportType = "individual" | "class" | "grade" +export type DiagnosticReportStatus = "draft" | "published" | "archived" + +/** 知识点掌握度记录 */ +export interface KnowledgePointMastery { + id: string + studentId: string + knowledgePointId: string + masteryLevel: number // 0-100 + totalQuestions: number + correctQuestions: number + lastAssessedAt: string + createdAt: string + updatedAt: string +} + +/** 含知识点名称的掌握度(join knowledgePoints 后) */ +export interface MasteryWithKnowledgePoint extends KnowledgePointMastery { + knowledgePointName: string + knowledgePointDescription: string | null +} + +/** 学生掌握度摘要 */ +export interface StudentMasterySummary { + studentId: string + studentName: string + averageMastery: number + totalKnowledgePoints: number + strengths: MasteryWithKnowledgePoint[] // 掌握度 >= 80 + weaknesses: MasteryWithKnowledgePoint[] // 掌握度 < 60 + allMastery: MasteryWithKnowledgePoint[] +} + +/** 诊断报告 */ +export interface DiagnosticReport { + id: string + studentId: string + generatedBy: string | null + reportType: DiagnosticReportType + period: string | null + summary: string | null + strengths: string[] | null + weaknesses: string[] | null + recommendations: string[] | null + overallScore: number | null + status: DiagnosticReportStatus + createdAt: string + updatedAt: string +} + +/** 含学生名的诊断报告(join users 后) */ +export interface DiagnosticReportWithDetails extends DiagnosticReport { + studentName: string + generatedByName: string | null +} + +/** 班级掌握度摘要 */ +export interface ClassMasterySummary { + classId: string + className: string + studentCount: number + averageMastery: number + knowledgePointStats: KnowledgePointStat[] + studentsNeedingAttention: Array<{ + studentId: string + studentName: string + averageMastery: number + weakCount: number + }> +} + +/** 知识点统计 */ +export interface KnowledgePointStat { + knowledgePointId: string + knowledgePointName: string + averageMastery: number + masteredCount: number // 掌握度 >= 80 + notMasteredCount: number // 掌握度 < 60 + totalStudents: number +} + +/** 报告查询过滤参数 */ +export interface DiagnosticReportQueryParams { + studentId?: string + reportType?: DiagnosticReportType + status?: DiagnosticReportStatus + period?: string +} + +/** 雷达图数据点 */ +export interface MasteryRadarPoint { + knowledgePoint: string + student: number // 0-100 + classAverage?: number // 0-100 +} diff --git a/src/modules/elective/actions.ts b/src/modules/elective/actions.ts new file mode 100644 index 0000000..0788cb9 --- /dev/null +++ b/src/modules/elective/actions.ts @@ -0,0 +1,304 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import type { ActionState } from "@/shared/types/action-state" + +import { + CreateElectiveCourseSchema, + UpdateElectiveCourseSchema, + SelectCourseSchema, + DropCourseSchema, + RunLotterySchema, +} from "./schema" +import { + getElectiveCourses, + getElectiveCourseById, + createElectiveCourse, + updateElectiveCourse, + deleteElectiveCourse, + openSelection, + closeSelection, +} from "./data-access" +import { runLottery, selectCourse, dropCourse } from "./data-access-operations" +import { + getStudentSelections, + getAvailableCoursesForStudent, +} from "./data-access-selections" +import type { + ElectiveCourseWithDetails, + CourseSelectionWithDetails, + GetElectiveCoursesParams, +} from "./types" + +const handleError = (e: unknown): ActionState => { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } +} + +const revalidateElectivePaths = (id?: string) => { + revalidatePath("/admin/elective") + revalidatePath("/teacher/elective") + revalidatePath("/student/elective") + if (id) { + revalidatePath(`/admin/elective/${id}`) + revalidatePath(`/admin/elective/${id}/edit`) + } +} + +const requireCourseId = (formData: FormData): string => { + const id = String(formData.get("courseId") ?? "") + if (!id) throw new Error("Course ID is required") + return id +} + +export async function createElectiveCourseAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE) + const parsed = CreateElectiveCourseSchema.safeParse({ + name: formData.get("name"), + subjectId: formData.get("subjectId") || undefined, + teacherId: formData.get("teacherId") || ctx.userId, + gradeId: formData.get("gradeId") || undefined, + description: formData.get("description") || undefined, + capacity: formData.get("capacity") || undefined, + classroom: formData.get("classroom") || undefined, + schedule: formData.get("schedule") || undefined, + startDate: formData.get("startDate") || undefined, + endDate: formData.get("endDate") || undefined, + selectionStartAt: formData.get("selectionStartAt") || undefined, + selectionEndAt: formData.get("selectionEndAt") || undefined, + selectionMode: formData.get("selectionMode") || undefined, + credit: formData.get("credit") || undefined, + }) + if (!parsed.success) { + return { + success: false, + message: "Invalid form data", + errors: parsed.error.flatten().fieldErrors, + } + } + const id = await createElectiveCourse(parsed.data, ctx.userId) + revalidateElectivePaths(id) + return { success: true, message: "Elective course created", data: id } + } catch (e) { + return handleError(e) + } +} + +export async function updateElectiveCourseAction( + id: string, + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.ELECTIVE_MANAGE) + const existing = await getElectiveCourseById(id) + if (!existing) return { success: false, message: "Course not found" } + + const parsed = UpdateElectiveCourseSchema.safeParse({ + name: formData.get("name") || undefined, + subjectId: formData.get("subjectId") || undefined, + teacherId: formData.get("teacherId") || undefined, + gradeId: formData.get("gradeId") || undefined, + description: formData.get("description") || undefined, + capacity: formData.get("capacity") || undefined, + classroom: formData.get("classroom") || undefined, + schedule: formData.get("schedule") || undefined, + startDate: formData.get("startDate") || undefined, + endDate: formData.get("endDate") || undefined, + selectionStartAt: formData.get("selectionStartAt") || undefined, + selectionEndAt: formData.get("selectionEndAt") || undefined, + status: formData.get("status") || undefined, + selectionMode: formData.get("selectionMode") || undefined, + credit: formData.get("credit") || undefined, + }) + if (!parsed.success) { + return { + success: false, + message: "Invalid form data", + errors: parsed.error.flatten().fieldErrors, + } + } + await updateElectiveCourse(id, parsed.data) + revalidateElectivePaths(id) + return { success: true, message: "Elective course updated", data: id } + } catch (e) { + return handleError(e) + } +} + +export async function deleteElectiveCourseAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.ELECTIVE_MANAGE) + const id = requireCourseId(formData) + + const existing = await getElectiveCourseById(id) + if (!existing) return { success: false, message: "Course not found" } + + await deleteElectiveCourse(id) + revalidateElectivePaths() + return { success: true, message: "Elective course deleted" } + } catch (e) { + return handleError(e) + } +} + +export async function openSelectionAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.ELECTIVE_MANAGE) + const courseId = requireCourseId(formData) + await openSelection(courseId) + revalidateElectivePaths(courseId) + return { success: true, message: "Selection opened" } + } catch (e) { + return handleError(e) + } +} + +export async function closeSelectionAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.ELECTIVE_MANAGE) + const courseId = requireCourseId(formData) + await closeSelection(courseId) + revalidateElectivePaths(courseId) + return { success: true, message: "Selection closed" } + } catch (e) { + return handleError(e) + } +} + +export async function runLotteryAction( + prevState: ActionState<{ enrolled: number; waitlist: number }> | null, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.ELECTIVE_MANAGE) + const parsed = RunLotterySchema.safeParse({ + courseId: formData.get("courseId"), + }) + if (!parsed.success) { + return { + success: false, + message: "Invalid form data", + errors: parsed.error.flatten().fieldErrors, + } + } + const result = await runLottery(parsed.data.courseId) + revalidateElectivePaths(parsed.data.courseId) + return { + success: true, + message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`, + data: result, + } + } catch (e) { + return handleError(e) + } +} + +export async function selectCourseAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.ELECTIVE_SELECT) + const parsed = SelectCourseSchema.safeParse({ + courseId: formData.get("courseId"), + priority: formData.get("priority") || undefined, + }) + if (!parsed.success) { + return { + success: false, + message: "Invalid form data", + errors: parsed.error.flatten().fieldErrors, + } + } + const result = await selectCourse(parsed.data.courseId, ctx.userId, parsed.data.priority) + revalidateElectivePaths(parsed.data.courseId) + return { success: true, message: result.message, data: result.status } + } catch (e) { + return handleError(e) + } +} + +export async function dropCourseAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.ELECTIVE_SELECT) + const parsed = DropCourseSchema.safeParse({ + courseId: formData.get("courseId"), + }) + if (!parsed.success) { + return { + success: false, + message: "Invalid form data", + errors: parsed.error.flatten().fieldErrors, + } + } + await dropCourse(parsed.data.courseId, ctx.userId) + revalidateElectivePaths(parsed.data.courseId) + return { success: true, message: "Course dropped" } + } catch (e) { + return handleError(e) + } +} + +export async function getElectiveCoursesAction( + params?: GetElectiveCoursesParams +): Promise> { + try { + const ctx = await requirePermission(Permissions.ELECTIVE_READ) + const data = await getElectiveCourses({ + ...params, + scope: ctx.dataScope, + currentUserId: ctx.userId, + }) + return { success: true, data } + } catch (e) { + return handleError(e) + } +} + +export async function getStudentSelectionsAction( + studentId: string +): Promise> { + try { + const ctx = await requirePermission(Permissions.ELECTIVE_READ) + if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) { + return { success: false, message: "Can only view your own selections" } + } + if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) { + return { success: false, message: "Can only view your children's selections" } + } + const data = await getStudentSelections(studentId) + return { success: true, data } + } catch (e) { + return handleError(e) + } +} + +export async function getAvailableCoursesAction(): Promise> { + try { + const ctx = await requirePermission(Permissions.ELECTIVE_SELECT) + const data = await getAvailableCoursesForStudent(ctx.userId) + return { success: true, data } + } catch (e) { + return handleError(e) + } +} diff --git a/src/modules/elective/components/elective-course-form.tsx b/src/modules/elective/components/elective-course-form.tsx new file mode 100644 index 0000000..16767c8 --- /dev/null +++ b/src/modules/elective/components/elective-course-form.tsx @@ -0,0 +1,293 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" + +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Textarea } from "@/shared/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" + +import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions" +import type { ElectiveCourseWithDetails } from "../types" + +type Mode = "create" | "edit" + +interface Option { + id: string + name: string +} + +export function ElectiveCourseForm({ + mode, + course, + subjects = [], + grades = [], + teachers = [], + backHref, +}: { + mode: Mode + course?: ElectiveCourseWithDetails + subjects?: Option[] + grades?: Option[] + teachers?: Option[] + backHref?: string +}) { + const router = useRouter() + const [isWorking, setIsWorking] = useState(false) + + const [subjectId, setSubjectId] = useState(course?.subjectId ?? "") + const [gradeId, setGradeId] = useState(course?.gradeId ?? "") + const [teacherId, setTeacherId] = useState(course?.teacherId ?? "") + const [selectionMode, setSelectionMode] = useState(course?.selectionMode ?? "fcfs") + + const handleSubmit = async (formData: FormData) => { + setIsWorking(true) + try { + formData.set("subjectId", subjectId) + formData.set("gradeId", gradeId) + formData.set("teacherId", teacherId) + formData.set("selectionMode", selectionMode) + + const res = + mode === "create" + ? await createElectiveCourseAction(null, formData) + : course + ? await updateElectiveCourseAction(course.id, null, formData) + : null + + if (!res) { + toast.error("Invalid form state") + return + } + + if (res.success) { + toast.success(res.message) + const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective" + router.push(redirectBase) + router.refresh() + } else { + toast.error(res.message || "Failed to save course") + } + } catch { + toast.error("Failed to save course") + } finally { + setIsWorking(false) + } + } + + return ( + + + + {mode === "create" ? "New Elective Course" : "Edit Elective Course"} + + + +
+
+
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +