From 57807def37569c5250a59d2d0a264092aaf7f383 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:14:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现在已经实现了大部分基础功能 --- .gitea/workflows/ci.yml | 2 +- .../003_frontend_engineering_standards.md | 434 +++ docs/db/schema-changelog.md | 125 + docs/db/seed-data.md | 6 + .../002_teacher_dashboard_implementation.md | 121 +- .../003_textbooks_module_implementation.md | 62 +- .../006_homework_module_implementation.md | 119 +- .../007_school_module_implementation.md | 164 + drizzle/0003_petite_newton_destine.sql | 43 + ...004_add_chapter_content_and_kp_chapter.sql | 3 + ...0005_add_class_school_subject_teachers.sql | 52 + drizzle/0006_faithful_king_bedlam.sql | 38 + drizzle/0007_add_class_invitation_code.sql | 3 + drizzle/meta/0003_snapshot.json | 2192 +++++++++++++ drizzle/meta/0004_snapshot.json | 2213 +++++++++++++ drizzle/meta/0005_snapshot.json | 2564 +++++++++++++++ drizzle/meta/0006_snapshot.json | 2848 +++++++++++++++++ drizzle/meta/_journal.json | 37 +- package-lock.json | 1552 ++++++++- package.json | 8 +- scripts/seed.ts | 567 +++- src/app/(auth)/error.tsx | 13 +- src/app/(dashboard)/admin/dashboard/page.tsx | 10 +- .../admin/school/academic-year/page.tsx | 18 + .../(dashboard)/admin/school/classes/page.tsx | 19 + .../admin/school/departments/page.tsx | 17 + .../admin/school/grades/insights/page.tsx | 231 ++ .../(dashboard)/admin/school/grades/page.tsx | 19 + src/app/(dashboard)/admin/school/page.tsx | 5 + .../(dashboard)/admin/school/schools/page.tsx | 18 + src/app/(dashboard)/dashboard/page.tsx | 78 +- src/app/(dashboard)/error.tsx | 15 +- src/app/(dashboard)/profile/page.tsx | 151 + src/app/(dashboard)/settings/page.tsx | 21 + .../(dashboard)/student/dashboard/loading.tsx | 61 + .../(dashboard)/student/dashboard/page.tsx | 89 +- .../student/learning/courses/loading.tsx | 29 + .../student/learning/courses/page.tsx | 40 + .../student/learning/textbooks/[id]/page.tsx | 78 + .../student/learning/textbooks/page.tsx | 80 + .../(dashboard)/student/schedule/loading.tsx | 32 + src/app/(dashboard)/student/schedule/page.tsx | 55 + .../teacher/classes/insights/page.tsx | 259 ++ .../teacher/classes/my/[id]/page.tsx | 315 ++ .../teacher/classes/my/loading.tsx | 32 + .../(dashboard)/teacher/classes/my/page.tsx | 23 +- .../teacher/classes/schedule/loading.tsx | 29 + .../teacher/classes/schedule/page.tsx | 86 +- .../teacher/classes/students/loading.tsx | 21 + .../teacher/classes/students/page.tsx | 87 +- .../(dashboard)/teacher/dashboard/page.tsx | 49 +- .../teacher/grades/insights/page.tsx | 244 ++ .../homework/assignments/[id]/page.tsx | 29 +- .../assignments/[id]/submissions/page.tsx | 14 +- .../homework/assignments/create/page.tsx | 12 +- .../teacher/homework/assignments/page.tsx | 53 +- .../teacher/homework/submissions/page.tsx | 42 +- .../teacher/textbooks/[id]/page.tsx | 30 +- .../(dashboard)/teacher/textbooks/page.tsx | 108 +- src/app/api/auth/[...nextauth]/route.ts | 4 + src/app/globals.css | 1 + src/app/layout.tsx | 9 +- src/auth.ts | 61 + src/middleware.ts | 44 + src/modules/auth/components/auth-layout.tsx | 1 - src/modules/auth/components/login-form.tsx | 28 +- src/modules/classes/actions.ts | 434 +++ .../classes/components/admin-classes-view.tsx | 434 +++ .../classes/components/insights-filters.tsx | 40 + .../classes/components/my-classes-grid.tsx | 533 +++ .../classes/components/schedule-filters.tsx | 195 ++ .../classes/components/schedule-view.tsx | 465 +++ .../classes/components/students-filters.tsx | 169 + .../classes/components/students-table.tsx | 158 + src/modules/classes/data-access.ts | 1541 +++++++++ src/modules/classes/types.ts | 194 ++ .../admin-dashboard/admin-dashboard.tsx | 158 + .../dashboard/components/admin-view.tsx | 25 - .../student-dashboard-header.tsx | 17 + .../student-dashboard-view.tsx | 42 + .../student-dashboard/student-grades-card.tsx | 99 + .../student-ranking-card.tsx | 47 + .../student-dashboard/student-stats-grid.tsx | 66 + .../student-today-schedule-card.tsx | 58 + .../student-upcoming-assignments-card.tsx | 83 + .../dashboard/components/student-view.tsx | 21 - .../recent-submissions.tsx | 77 +- .../teacher-classes-card.tsx | 86 + .../teacher-dashboard-header.tsx | 13 + .../teacher-dashboard-view.tsx | 59 + .../teacher-homework-card.tsx | 56 + .../teacher-quick-actions.tsx | 29 + .../teacher-dashboard/teacher-schedule.tsx | 73 + .../{ => teacher-dashboard}/teacher-stats.tsx | 75 +- .../components/teacher-quick-actions.tsx | 21 - .../dashboard/components/teacher-schedule.tsx | 81 - .../dashboard/components/teacher-view.tsx | 25 - src/modules/dashboard/data-access.ts | 103 + src/modules/dashboard/types.ts | 70 + src/modules/exams/actions.ts | 12 +- src/modules/exams/components/exam-viewer.tsx | 184 ++ src/modules/homework/actions.ts | 39 +- .../homework-assignment-exam-content-card.tsx | 29 + ...rk-assignment-exam-error-explorer-lazy.tsx | 79 + ...omework-assignment-exam-error-explorer.tsx | 44 + .../homework-assignment-exam-preview-pane.tsx | 35 + .../components/homework-assignment-form.tsx | 36 +- ...assignment-question-error-detail-panel.tsx | 150 + ...assignment-question-error-details-card.tsx | 49 + ...ssignment-question-error-overview-card.tsx | 134 + .../components/homework-grading-view.tsx | 310 +- src/modules/homework/data-access.ts | 453 ++- src/modules/homework/schema.ts | 2 +- src/modules/homework/types.ts | 90 + src/modules/layout/components/app-sidebar.tsx | 35 +- .../layout/components/sidebar-provider.tsx | 1 - src/modules/layout/components/site-header.tsx | 21 +- src/modules/layout/config/navigation.ts | 13 +- src/modules/questions/actions.ts | 10 +- src/modules/school/actions.ts | 291 ++ .../school/components/academic-year-view.tsx | 315 ++ .../school/components/departments-view.tsx | 247 ++ src/modules/school/components/grades-view.tsx | 779 +++++ .../school/components/schools-view.tsx | 253 ++ src/modules/school/data-access.ts | 183 ++ src/modules/school/schema.ts | 52 + src/modules/school/types.ts | 42 + .../components/admin-settings-view.tsx | 158 + .../components/student-settings-view.tsx | 136 + .../components/teacher-settings-view.tsx | 148 + .../components/theme-preferences-card.tsx | 60 + .../components/student-courses-view.tsx | 156 + .../components/student-schedule-filters.tsx | 33 + .../components/student-schedule-view.tsx | 89 + src/modules/textbooks/actions.ts | 19 +- .../components/chapter-content-viewer.tsx | 9 +- .../textbooks/components/chapter-list.tsx | 59 +- .../components/chapter-sidebar-list.tsx | 115 +- .../components/create-chapter-dialog.tsx | 25 +- .../components/knowledge-point-panel.tsx | 87 +- .../textbooks/components/textbook-card.tsx | 6 +- .../components/textbook-content-layout.tsx | 18 +- .../textbooks/components/textbook-filters.tsx | 82 + .../textbooks/components/textbook-reader.tsx | 156 + .../components/textbook-settings-dialog.tsx | 2 +- src/modules/textbooks/data-access.ts | 542 ++-- src/modules/textbooks/types.ts | 3 - src/next-auth.d.ts | 17 + .../components/auth-session-provider.tsx | 9 + src/shared/components/ui/scroll-area.tsx | 2 + src/shared/components/ui/select.tsx | 1 + src/shared/db/relations.ts | 70 + src/shared/db/schema.ts | 178 ++ tailwind.config.ts | 3 +- tsconfig.json | 26 +- 155 files changed, 26421 insertions(+), 1036 deletions(-) create mode 100644 docs/architecture/003_frontend_engineering_standards.md create mode 100644 docs/design/007_school_module_implementation.md create mode 100644 drizzle/0003_petite_newton_destine.sql create mode 100644 drizzle/0004_add_chapter_content_and_kp_chapter.sql create mode 100644 drizzle/0005_add_class_school_subject_teachers.sql create mode 100644 drizzle/0006_faithful_king_bedlam.sql create mode 100644 drizzle/0007_add_class_invitation_code.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 src/app/(dashboard)/admin/school/academic-year/page.tsx create mode 100644 src/app/(dashboard)/admin/school/classes/page.tsx create mode 100644 src/app/(dashboard)/admin/school/departments/page.tsx create mode 100644 src/app/(dashboard)/admin/school/grades/insights/page.tsx create mode 100644 src/app/(dashboard)/admin/school/grades/page.tsx create mode 100644 src/app/(dashboard)/admin/school/page.tsx create mode 100644 src/app/(dashboard)/admin/school/schools/page.tsx create mode 100644 src/app/(dashboard)/profile/page.tsx create mode 100644 src/app/(dashboard)/settings/page.tsx create mode 100644 src/app/(dashboard)/student/dashboard/loading.tsx create mode 100644 src/app/(dashboard)/student/learning/courses/loading.tsx create mode 100644 src/app/(dashboard)/student/learning/courses/page.tsx create mode 100644 src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx create mode 100644 src/app/(dashboard)/student/learning/textbooks/page.tsx create mode 100644 src/app/(dashboard)/student/schedule/loading.tsx create mode 100644 src/app/(dashboard)/student/schedule/page.tsx create mode 100644 src/app/(dashboard)/teacher/classes/insights/page.tsx create mode 100644 src/app/(dashboard)/teacher/classes/my/[id]/page.tsx create mode 100644 src/app/(dashboard)/teacher/classes/my/loading.tsx create mode 100644 src/app/(dashboard)/teacher/classes/schedule/loading.tsx create mode 100644 src/app/(dashboard)/teacher/classes/students/loading.tsx create mode 100644 src/app/(dashboard)/teacher/grades/insights/page.tsx create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/auth.ts create mode 100644 src/middleware.ts create mode 100644 src/modules/classes/actions.ts create mode 100644 src/modules/classes/components/admin-classes-view.tsx create mode 100644 src/modules/classes/components/insights-filters.tsx create mode 100644 src/modules/classes/components/my-classes-grid.tsx create mode 100644 src/modules/classes/components/schedule-filters.tsx create mode 100644 src/modules/classes/components/schedule-view.tsx create mode 100644 src/modules/classes/components/students-filters.tsx create mode 100644 src/modules/classes/components/students-table.tsx create mode 100644 src/modules/classes/data-access.ts create mode 100644 src/modules/classes/types.ts create mode 100644 src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx delete mode 100644 src/modules/dashboard/components/admin-view.tsx create mode 100644 src/modules/dashboard/components/student-dashboard/student-dashboard-header.tsx create mode 100644 src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx create mode 100644 src/modules/dashboard/components/student-dashboard/student-grades-card.tsx create mode 100644 src/modules/dashboard/components/student-dashboard/student-ranking-card.tsx create mode 100644 src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx create mode 100644 src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx create mode 100644 src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx delete mode 100644 src/modules/dashboard/components/student-view.tsx rename src/modules/dashboard/components/{ => teacher-dashboard}/recent-submissions.tsx (55%) create mode 100644 src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx create mode 100644 src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx create mode 100644 src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx create mode 100644 src/modules/dashboard/components/teacher-dashboard/teacher-homework-card.tsx create mode 100644 src/modules/dashboard/components/teacher-dashboard/teacher-quick-actions.tsx create mode 100644 src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx rename src/modules/dashboard/components/{ => teacher-dashboard}/teacher-stats.tsx (63%) delete mode 100644 src/modules/dashboard/components/teacher-quick-actions.tsx delete mode 100644 src/modules/dashboard/components/teacher-schedule.tsx delete mode 100644 src/modules/dashboard/components/teacher-view.tsx create mode 100644 src/modules/dashboard/data-access.ts create mode 100644 src/modules/dashboard/types.ts create mode 100644 src/modules/exams/components/exam-viewer.tsx create mode 100644 src/modules/homework/components/homework-assignment-exam-content-card.tsx create mode 100644 src/modules/homework/components/homework-assignment-exam-error-explorer-lazy.tsx create mode 100644 src/modules/homework/components/homework-assignment-exam-error-explorer.tsx create mode 100644 src/modules/homework/components/homework-assignment-exam-preview-pane.tsx create mode 100644 src/modules/homework/components/homework-assignment-question-error-detail-panel.tsx create mode 100644 src/modules/homework/components/homework-assignment-question-error-details-card.tsx create mode 100644 src/modules/homework/components/homework-assignment-question-error-overview-card.tsx create mode 100644 src/modules/school/actions.ts create mode 100644 src/modules/school/components/academic-year-view.tsx create mode 100644 src/modules/school/components/departments-view.tsx create mode 100644 src/modules/school/components/grades-view.tsx create mode 100644 src/modules/school/components/schools-view.tsx create mode 100644 src/modules/school/data-access.ts create mode 100644 src/modules/school/schema.ts create mode 100644 src/modules/school/types.ts create mode 100644 src/modules/settings/components/admin-settings-view.tsx create mode 100644 src/modules/settings/components/student-settings-view.tsx create mode 100644 src/modules/settings/components/teacher-settings-view.tsx create mode 100644 src/modules/settings/components/theme-preferences-card.tsx create mode 100644 src/modules/student/components/student-courses-view.tsx create mode 100644 src/modules/student/components/student-schedule-filters.tsx create mode 100644 src/modules/student/components/student-schedule-view.tsx create mode 100644 src/modules/textbooks/components/textbook-filters.tsx create mode 100644 src/modules/textbooks/components/textbook-reader.tsx create mode 100644 src/next-auth.d.ts create mode 100644 src/shared/components/auth-session-provider.tsx diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index efa139a..4dd58f4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -125,7 +125,7 @@ jobs: --restart unless-stopped \ --name nextjs-app \ -e NODE_ENV=production \ - -e DATABASE_URL=${{ secrets.DATABASE_URL_PRODUCTION }} \ + -e DATABASE_URL=${{ secrets.DATABASE_URL }} \ -e NEXT_TELEMETRY_DISABLED=1 \ nextjs-app diff --git a/docs/architecture/003_frontend_engineering_standards.md b/docs/architecture/003_frontend_engineering_standards.md new file mode 100644 index 0000000..a633fae --- /dev/null +++ b/docs/architecture/003_frontend_engineering_standards.md @@ -0,0 +1,434 @@ +# Frontend Engineering Standards (Next_Edu) + +**Status**: ACTIVE +**Owner**: Frontend Team +**Scope**: Next.js App Router 前端工程规范(编码、目录、交互、样式、数据流、质量门禁) +**Applies To**: `src/app/*`, `src/modules/*`, `src/shared/*`, `docs/design/*` + +--- + +## 0. 目标与非目标 + +### 0.1 目标 + +- 让新加入的前端工程师在 30 分钟内完成对齐并开始稳定迭代 +- 保证 UI 一致性(Design Token + Shadcn/UI 复用) +- 充分利用 App Router + RSC(Server-First)降低 bundle、提升性能 +- 保证类型安全与可维护性(Vertical Slice、数据访问边界清晰) +- 形成可执行的质量门禁(lint/typecheck/build 与评审清单) + +### 0.2 非目标 + +- 不规定具体业务模块的需求细节(业务规则以 `docs/design/*` 与 PRD 为准) +- 不引入与当前仓库技术栈不一致的新框架/库(新增依赖需明确收益与替代方案) + +--- + +## 1. 接手流程(Onboarding Checklist) + +### 1.1 先读什么(按顺序) + +- 设计系统与 UI 规范:[docs/design/design_system.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/design_system.md) +- 角色路由与目录规范:[docs/architecture/002_role_based_routing.md](file:///c:/Users/xiner/Desktop/CICD/docs/architecture/002_role_based_routing.md) +- 项目架构总览:[ARCHITECTURE.md](file:///c:/Users/xiner/Desktop/CICD/ARCHITECTURE.md) +- 你将要改动的模块实现文档:`docs/design/00*_*.md` + +### 1.2 开发前对齐(必须) + +- 核对 Design Tokens 与暗色模式变量: + - Tailwind 语义色映射:[tailwind.config.ts](file:///c:/Users/xiner/Desktop/CICD/tailwind.config.ts) + - CSS 变量定义:[src/app/globals.css](file:///c:/Users/xiner/Desktop/CICD/src/app/globals.css) +- 盘点可复用 UI 组件:`src/shared/components/ui/*` +- 盘点通用工具(`cn` 等):[src/shared/lib/utils.ts](file:///c:/Users/xiner/Desktop/CICD/src/shared/lib/utils.ts) + +### 1.3 环境变量与配置校验(必须) + +- 统一使用 `@t3-oss/env-nextjs` 的 `env` 入口读取环境变量,禁止在业务代码中散落 `process.env.*` +- Schema 定义与校验入口:[src/env.mjs](file:///c:/Users/xiner/Desktop/CICD/src/env.mjs) +- 任何新增环境变量: + - 必须先在 `src/env.mjs` 增加 schema + - 必须在 docs 中更新部署/运行说明(就近更新对应模块文档或全局架构文档) + +### 1.3 本地跑通(推荐顺序) + +- 安装依赖:`npm install` +- 启动开发:`npm run dev` +- 质量检查: + - `npm run lint` + - `npm run typecheck` + - `npm run build` + +--- + +## 2. 核心工程原则(必须遵守) + +### 2.1 Vertical Slice(按业务功能组织) + +- 业务必须放在 `src/modules//*` +- `src/app/*` 是路由层,只负责: + - 布局组合(layout) + - 读取 `searchParams` / `params` + - 调用模块的数据访问函数(`data-access.ts`) + - 组合模块组件渲染 +- 通用能力放在 `src/shared/*`: + - 通用 UI:`src/shared/components/ui/*` + - 通用工具:`src/shared/lib/*` + - DB 与 schema:`src/shared/db/*` + +### 2.2 Server-First(默认 Server Component) + +- 默认写 Server Component +- 只有在需要以下能力时,才把“最小子组件”标记为 Client Component: + - `useState/useEffect/useMemo`(与交互/浏览器相关) + - DOM 事件(`onClick/onChange` 等) + - `useRouter/usePathname` 等客户端导航 hooks + - Radix/Portal 类组件需要客户端(Dialog/Dropdown 等通常在 client 内组合使用) + +### 2.3 不重复造轮子(Shadcn/UI 优先) + +- 禁止手写 Modal/Dropdown/Tooltip 等基础交互容器 +- 优先组合 `src/shared/components/ui/*`(Button/Card/Dialog/DropdownMenu/AlertDialog/Skeleton/EmptyState 等) +- 若现有基础组件无法满足需求: + 1. 优先通过 Composition 在业务模块里封装“业务组件” + 2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面) + +### 2.4 Client Component 引用边界(强制) + +- 禁止在 Client Component 中导入任何“服务端实现”代码(例如 DB 实例、data-access、server-only 模块) +- Client Component 允许导入: + - `src/shared/components/ui/*`(基础 UI) + - `src/shared/lib/*`(纯前端工具函数) + - Server Actions(`"use server"` 导出的 action 函数) + - 类型定义必须使用 `import type`(避免把服务端依赖带入 client bundle) +- 所有 `data-access.ts` 必须包含 `import "server-only"`,并将其视为强制安全边界(不是可选优化) + +--- + +## 3. 目录与路由规范 + +### 3.1 路由目录(App Router) + +- 认证域:`src/app/(auth)/*` +- 控制台域(共享 App Shell):`src/app/(dashboard)/*` +- 角色域:`src/app/(dashboard)/teacher|student|admin/*` +- `/dashboard` 作为入口页(重定向/分发到具体角色 dashboard):参考 [002_role_based_routing.md](file:///c:/Users/xiner/Desktop/CICD/docs/architecture/002_role_based_routing.md) + +### 3.2 页面文件职责 + +- `page.tsx`:页面组装(RSC),不承载复杂交互 +- `loading.tsx`:路由级加载态(Skeleton) +- `error.tsx`:路由级错误边界(友好 UI) +- `not-found.tsx`:路由级 404 + +### 3.3 错误处理与用户反馈(Error Handling & Feedback) + +- 路由级错误(404/500/未捕获异常): + - 交由 `not-found.tsx` / `error.tsx` 处理 + - 禁止在 `error.tsx` 里弹 Toast 堆栈刷屏,错误页输出必须友好且可恢复(例如提供 retry / 返回入口) +- 业务操作反馈(表单提交/按钮操作/行级动作): + - 统一由 Client Component 在调用 Server Action 后触发 `sonner` toast + - 只在“成功”或“明确失败(业务/校验错误)”时触发 toast;未知异常由 action 归一为失败 message + +### 3.4 异步组件与 Suspense(Streaming) + +- 对于数据加载超过 300ms 的非核心 UI 区块(例如:仪表盘某张统计卡片/图表/第三方数据块): + - 必须用 `}>` 包裹,以避免全页阻塞 +- 禁止在 `page.tsx` 顶层用多个串行 `await` 造成瀑布请求: + - 多个独立请求必须使用 `Promise.all` + - 或拆分为多个 async 子组件并行流式渲染(用 `Suspense` 分段展示) + +### 3.3 动态渲染策略(避免 build 阶段查库) + +当页面在渲染时会查询数据库或依赖 request-time 数据,且无法安全静态化时: + +- 在页面入口显式声明: + - `export const dynamic = "force-dynamic"` +- 该策略已用于教师端班级与作业相关页面,见相应 design 文档(例如教师班级模块更新记录) + +--- + +## 4. 模块内文件结构(强制) + +每个业务模块使用统一结构(可按复杂度增减,但命名必须一致): + +``` +src/modules// +├── components/ # 仅该模块使用的 UI 组件(可含 client 组件) +├── actions.ts # Server Actions(写入/变更 + revalidatePath) +├── data-access.ts # 数据查询与聚合(server-only + cache) +├── schema.ts # Zod schema(若需要) +└── types.ts # 类型定义(与 DB/DTO 对齐) +``` + +约束: + +- `actions.ts` 必须包含 `"use server"` +- `data-access.ts` 必须包含 `import "server-only"`(防止误导入到 client bundle) +- 复杂页面组件必须下沉到 `src/modules//components/*`,路由层只做组装 + +--- + +## 5. Server / Client 边界与拆分策略 + +### 5.1 最小化 Client Component 的落地方式 + +- 页面保持 RSC +- 把需要交互的部分抽成独立 `components/*` 子组件并标记 `"use client"` +- Client 组件向上暴露“数据变化事件”,由 Server Action 完成写入并 `revalidatePath` + +### 5.4 Hydration 一致性(必须) + +- 所有 Client Component 的首屏渲染必须保证与 SSR 产出的 HTML 一致 +- 禁止在 render 分支中使用: + - `typeof window !== "undefined"` 之类的 server/client 分支 + - `Date.now()` / `Math.random()` 等不稳定输入 + - 依赖用户 locale 的时间格式化(除非服务端与客户端完全一致并带 snapshot) +- 对于 Radix 等组件生成的动态 aria/id 导致的属性差异: + - 优先通过组件封装确保首屏稳定 + - 若确认差异不可避免且不影响交互,可在最小范围使用 `suppressHydrationWarning` + +### 5.2 页面必须只做“拼装”,功能模块必须独立 + +- 任何功能模块都必须在 `src/modules//components/*` 内独立实现 +- `page.tsx` 只负责: + - 读取 `params/searchParams` + - 调用 `data-access.ts` 获取数据 + - 以组合方式拼装模块组件(不在 page 内实现具体交互与复杂 UI) +- 行数不是拆分依据,只是“路由层变厚”的信号;一旦出现成块的功能 UI,应立即下沉到模块组件 + +### 5.3 什么时候允许在 Client 中做“局部工作台” + +当交互复杂到“页面需要类似 SPA 的局部体验”,允许将工作台容器作为 Client: + +- 典型场景:三栏工作台、拖拽排序编辑器、复杂筛选器组合、富交互表格 +- 但仍要求: + - 初始数据由 RSC 获取并传入 Client + - 写操作通过 Server Actions + - UI 状态尽量 URL 化(能分享/回溯) + +--- + +## 6. 样式与 UI 一致性(Design System 强制项) + +### 6.1 Token 优先(语义化颜色/圆角) + +- 颜色必须使用语义 token: + - `bg-background`, `bg-card`, `bg-muted`, `text-foreground`, `text-muted-foreground`, `border-border` 等 +- 禁止硬编码颜色值(`#fff`/`rgb()`)与随意引入灰度(如 `bg-gray-100`) +- 圆角、边框、阴影遵循设计系统: + - 常规组件使用 `rounded-md` 等语义半径(由 `--radius` 映射) + +### 6.2 className 规范 + +- 所有条件样式必须使用 `className={cn(...)}` +- `cn` 入口为 `@/shared/lib/utils` + +### 6.3 禁止 Arbitrary Values(默认) + +- 默认禁止 `w-[123px]` 等任意值 +- 只有在设计系统或现有实现明确允许、并且无法用 token/栅格解决时,才可使用,并在 PR 描述说明原因 + +### 6.4 微交互与状态(必须有) + +- 按钮 hover:必须有 transition(现有 Button 组件已内置) +- 列表项 hover:使用 `hover:bg-muted/50` 等轻量反馈 +- Loading:必须使用 `Skeleton`(路由级 `loading.tsx` 或组件内 skeleton) +- Empty:必须使用 `EmptyState` +- Toast:统一使用 `sonner` + +--- + +## 7. 图标规范(lucide-react) + +- 统一使用 `lucide-react` +- 图标尺寸统一:默认 `h-4 w-4`,需要强调时 `h-5 w-5` +- 颜色使用语义化:例如 `text-muted-foreground` + +--- + +## 8. 数据流规范(查询、写入、状态) + +### 8.1 查询(data-access.ts) + +- 所有查询放在 `src/modules//data-access.ts` +- 需要复用/去重的查询优先用 `cache` 包裹(React cache) +- 查询函数返回“UI 直接可消费的 DTO”,避免页面层再做复杂映射 + +### 8.2 写入(actions.ts) + +- 所有写操作必须通过 Server Actions +- 每个 action: + - 校验输入(Zod 或手写 guard) + - 执行 DB 写入 + - 必须 `revalidatePath`(以页面为单位) + +### 8.3 Server Action 返回结构(统一反馈协议) + +- 所有 Server Action 必须返回统一结构,用于前端统一处理 toast 与表单错误 +- 统一使用类型:[src/shared/types/action-state.ts](file:///c:/Users/xiner/Desktop/CICD/src/shared/types/action-state.ts) + +```ts +export type ActionState = { + success: boolean + message?: string + errors?: Record + data?: T +} +``` + +约束: + +- `errors` 必须对齐 `zod` 的 `error.flatten().fieldErrors` 结构 +- 禁止在各模块内重复定义自有的 ActionState 类型 + +### 8.4 Toast 触发时机(强制) + +- Client Component 在调用 Server Action 后: + - `success: true`:触发 `toast.success(message)`(或使用模块内约定的成功文案) + - `success: false`: + - 存在 `errors`:优先渲染表单字段错误;可选触发 `toast.error(message)` + - 不存在 `errors`:触发 `toast.error(message || "Action failed")` +- 对于路由级异常与边界错误,禁止用 toast 替代 `error.tsx` + +### 8.5 URL State(nuqs 优先) + +- 列表页筛选/分页/Tab/排序等“可分享状态”必须放 URL +- 使用 `nuqs` 做类型安全的 query state 管理 + +### 8.6 Data Access 权限边界(Security / IDOR 防护) + +- `data-access.ts` 不是纯 DTO 映射层,必须承担数据归属权校验 +- 允许两种合规方式(二选一,但模块内必须统一): + - **方式 A(强制传参)**:所有 data-access 函数显式接收 `actor`(userId/role)并在查询条件中约束归属(例如 teacherId) + - **方式 B(函数内获取)**:data-access 函数首行获取 session/user 并校验 role/归属,再执行查询 +- 禁止把权限校验放在 page.tsx 或 client 组件中作为唯一屏障 + +--- + +## 9. 数据完整性与 Seed 规则(禁止 Mock) + +项目默认不使用 Mock 数据。 + +当某功能缺失实际数据,开发者必须把数据补齐到数据库与种子数据中,而不是在前端临时模拟。 + +执行规范: + +- 若缺失的是“表结构/字段/关系”: + - 修改 `src/shared/db/schema.ts` 与 `src/shared/db/relations.ts`(按既有模式) + - 生成并提交 Drizzle migration(`drizzle/*.sql`) +- 若缺失的是“可演示的业务数据”: + - 更新 `scripts/seed.ts`,确保 `npm run db:seed` 可一键生成可用数据 +- 文档同步(必须): + - 在 [schema-changelog.md](file:///c:/Users/xiner/Desktop/CICD/docs/db/schema-changelog.md) 记录本次新增/变更的数据表、字段、索引与外键 + - 在对应模块的 `docs/design/00*_*.md` 中补充“新增了哪些数据/为什么需要/如何验证(db:seed + 页面路径)” + +### 9.1 Seed 分层(降低阻塞) + +- Seed 分为两类: + - **Baseline Seed**:全项目必备的最小集合(核心用户/角色/基础字典数据等),保证任何页面都不因“数据空”而无法进入流程 + - **Scenario Seed(按模块)**:面向具体模块的可演示数据包(例如:班级/题库/试卷/作业),用于复现与验证该模块交互 +- 任何模块新增数据依赖,必须以 “Scenario Seed” 的形式落到 `scripts/seed.ts`,而不是把数据要求隐含在前端逻辑里 + +### 9.2 Seed 可复现与数据锚点(保证跨模块联动) + +- Seed 必须可重复执行(idempotent),避免开发环境多次执行后产生脏数据与重复数据 +- 对跨模块联动依赖的关键实体,必须提供可稳定引用的数据锚点: + - 固定标识(如固定 email/slug/title 组合)或可预测 ID(按现有 seed 约定) + - 文档必须写明锚点是什么、依赖它的模块有哪些、如何验证 +- 禁止在 UI 里依赖“随机生成数据顺序”来定位实体(例如 “取第一条记录作为 demo 用户” 这类逻辑应退化为明确锚点) + +### 9.3 外部服务的例外(仅限 Adapter Mock) + +- 内部业务数据严格遵守“DB + Migration + Seed”,不允许 Mock +- 仅当对接外部不可控服务(支付/短信/第三方 AI 流式等)且无法用本地 seed 复现时: + - 允许在 `src/shared/lib/mock-adapters/*` 建立 mock 适配器 + - 必须先定义 Adapter 接口,再提供真实实现与 mock 实现(业务模块只能依赖接口,不可直接依赖某个具体实现) + - 该 mock 仅用于外部服务交互层,禁止承载内部业务数据 + +--- + +## 10. 表单规范(react-hook-form + zod) + +- 表单统一使用 `react-hook-form` + `@hookform/resolvers` + `zod` +- 错误提示放在输入框下方: + - 字号 `text-xs` + - 颜色 `text-destructive` +- 破坏性操作必须二次确认(`AlertDialog`) +- 提交中按钮禁用并展示 loading(可使用 `useFormStatus` 或本地 state) + +--- + +## 11. 质量门禁与评审清单(PR 必须过) + +### 11.1 本地必须通过 + +- `npm run lint` +- `npm run typecheck` +- `npm run build` + +### 11.2 代码评审清单(Reviewer 逐项检查) + +- 目录结构是否符合 Vertical Slice(路由层是否保持“薄”) +- 页面是否只做拼装(功能 UI 是否全部下沉到模块组件) +- Server/Client 边界是否最小化(是否把整页误标 client) +- 是否复用 `src/shared/components/ui/*`,是否重复实现基础交互 +- 是否使用语义化 token(颜色/圆角/间距),是否引入硬编码颜色与大量 arbitrary values +- Loading/Empty/Error 是否齐全(Skeleton/EmptyState/error.tsx) +- 列表页筛选是否 URL 化(nuqs),是否支持刷新/分享 +- 写操作是否通过 Server Action 且正确 `revalidatePath` +- 是否避免 Mock(数据是否通过迁移 + seed 补齐,且 docs/db 与模块文档已同步) +- 是否引入不必要的依赖与重型客户端逻辑 + +### 11.3 Commit 规范(Git History) + +- 推荐遵循 Conventional Commits: + - `feat:` 新功能 + - `fix:` 修复 bug + - `docs:` 文档更新 + - `refactor:` 重构(无功能变化) + - `chore:` 工程杂项 +- 约束: + - 单次提交必须聚焦一个意图,避免把大范围格式化与功能修改混在一起 + - 涉及 DB 迁移与 seed 变更时,commit message 必须包含模块/领域关键词,便于追溯 + +--- + +## 12. 文档同步规则(Docs Sync) + +以下情况必须同步更新文档(就近放在 `docs/design/*` 或 `docs/architecture/*`): + +- 新增“全局交互模式”(例如:新的工作台/拖拽范式/跨模块复用交互) +- 新增“全局组件”或改变基础 UI 行为(影响 `src/shared/components/ui/*`) +- 新增关键路由结构或权限/角色策略 + +### 12.1 业务组件可发现性(可选但推荐) + +- 对 `src/modules//components` 内的复杂业务组件(例如:试卷编辑器、排课表、工作台): + - 推荐在对应的 `docs/design/00*_*.md` 增加“用法示例 + 关键 props + 截图” + - 若团队资源允许,可引入 Storybook 作为可视化组件目录(不作为硬性门禁) + +--- + +## 13. Performance Essentials(必须遵守) + +- 图片: + - 强制使用 `next/image` 替代 ``(SVG 或已明确无需优化的极小图标除外) + - 头像等外部域名资源必须配置并明确缓存策略 +- 字体: + - 强制使用 `next/font` 管理字体加载 + - 禁止在 CSS 中 `@import` 外部字体 URL(避免 CLS 与阻塞渲染) +- 依赖: + - 禁止引入重型动画库作为默认方案;复杂动效需按需加载并解释收益 + - 大体积 Client 组件必须拆分与动态加载,并通过 `Suspense` 提供 skeleton fallback + +--- + +## 14. 参考实现(从现有代码学习的路径) + +- 设计系统与 UI 组件清单:[docs/design/design_system.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/design_system.md) +- Auth:路由层 RSC + 表单 client 拆分模式:[docs/design/001_auth_ui_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/001_auth_ui_implementation.md) +- 教师端班级模块:URL state + client 交互组件 + server actions 的组合:[docs/design/002_teacher_dashboard_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/002_teacher_dashboard_implementation.md) +- 教材工作台:RSC 拉初始数据 + client 工作台容器接管交互:[docs/design/003_textbooks_module_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/003_textbooks_module_implementation.md) +- 题库:nuqs 驱动筛选 + TanStack Table + CRUD actions:[docs/design/004_question_bank_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/004_question_bank_implementation.md) +- 考试组卷:拖拽编辑器(@dnd-kit)+ structure JSON 模型:[docs/design/005_exam_module_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/005_exam_module_implementation.md) +- 作业:冻结 exam → assignment 的域模型 + 学生作答/教师批改闭环:[docs/design/006_homework_module_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/006_homework_module_implementation.md) diff --git a/docs/db/schema-changelog.md b/docs/db/schema-changelog.md index 2261b3b..adb539c 100644 --- a/docs/db/schema-changelog.md +++ b/docs/db/schema-changelog.md @@ -75,3 +75,128 @@ This release introduces homework-related tables and hardens foreign key names to ### 4. Impact Analysis * **Performance**: Minimal. New indexes are scoped to common homework access patterns. * **Data Integrity**: High. Foreign keys enforce referential integrity for homework workflow. + +## v1.3.0 - Classes Domain (Teacher Class Management) +**Date:** 2025-12-31 +**Migration ID:** `0003_petite_newton_destine` +**Author:** Principal Database Architect + +### 1. Summary +This release introduces the core schema for teacher class management: classes, enrollments, and schedules. + +### 2. Changes + +#### 2.1 Tables: Classes Domain +* **Action**: `CREATE TABLE` +* **Tables**: + * `classes` + * `class_enrollments` + * `class_schedule` +* **Reason**: Support teacher-owned classes, student enrollment lists, and weekly schedules. + +#### 2.2 Enum: Enrollment Status +* **Action**: `ADD ENUM` +* **Enum**: `class_enrollment_status` = (`active`, `inactive`) +* **Reason**: Provide a stable status field for filtering active enrollments. + +#### 2.3 Foreign Keys & Indexes +* **Action**: `ADD FOREIGN KEY`, `CREATE INDEX` +* **Key Relationships**: + * `classes.teacher_id` -> `users.id` (cascade delete) + * `class_enrollments.class_id` -> `classes.id` (cascade delete) + * `class_enrollments.student_id` -> `users.id` (cascade delete) + * `class_schedule.class_id` -> `classes.id` (cascade delete) +* **Indexes**: + * `classes_teacher_idx`, `classes_grade_idx` + * `class_enrollments_class_idx`, `class_enrollments_student_idx` + * `class_schedule_class_idx`, `class_schedule_class_day_idx` + +### 3. Migration Strategy +* **Up**: Run standard Drizzle migration. Ensure `DATABASE_URL` points to the intended schema (e.g., `next_edu`). +* **Down**: Not provided. Dropping these tables is destructive and should be handled explicitly per environment. + +### 4. Impact Analysis +* **Performance**: Indexes align with common query patterns (teacher listing, enrollment filtering, per-class schedule). +* **Data Integrity**: High. Foreign keys enforce ownership and membership integrity across teacher/classes/students. + +## v1.4.0 - Classes Domain Enhancements (School Name & Subject Teachers) +**Date:** 2026-01-07 +**Migration ID:** `0005_add_class_school_subject_teachers` +**Author:** Frontend/Fullstack Engineering + +### 1. Summary +This release extends the Classes domain to support school-level sorting and per-subject teacher assignment defaults. + +### 2. Changes + +#### 2.1 Table: `classes` +* **Action**: `ADD COLUMN` +* **Field**: `school_name` (varchar(255), nullable) +* **Reason**: Enable sorting/grouping by school name, then grade, then class name. + +#### 2.2 Table: `class_subject_teachers` +* **Action**: `CREATE TABLE` +* **Primary Key**: (`class_id`, `subject`) +* **Columns**: + * `class_id` (varchar(128), FK -> `classes.id`, cascade delete) + * `subject` (enum: `语文/数学/英语/美术/体育/科学/社会/音乐`) + * `teacher_id` (varchar(128), FK -> `users.id`, set null on delete) + * `created_at`, `updated_at` +* **Reason**: Maintain a stable default “subject list” per class while allowing admin/teacher to assign the actual teacher per subject. + +### 3. Migration Strategy +* **Up**: Run standard Drizzle migration. +* **Down**: Not provided. Dropping assignment history is destructive. + +### 4. Impact Analysis +* **Performance**: Minimal. Table is small (8 rows per class) and indexed by class/teacher. +* **Data Integrity**: High. Composite PK prevents duplicates per class/subject; FKs enforce referential integrity. + +## v1.4.1 - Classes Domain Enhancements (School/Grade Normalization) +**Date:** 2026-01-07 +**Migration ID:** `0006_faithful_king_bedlam` +**Author:** Frontend/Fullstack Engineering + +### 1. Summary +This release extends the `classes` table to support normalized school and grade references. + +### 2. Changes + +#### 2.1 Table: `classes` +* **Action**: `ADD COLUMN` +* **Fields**: + * `school_id` (varchar(128), nullable) + * `grade_id` (varchar(128), nullable) +* **Reason**: Enable filtering and sorting by canonical school/grade entities instead of relying on free-text fields. + +### 3. Migration Strategy +* **Up**: Run standard Drizzle migration. +* **Down**: Not provided. Dropping columns is destructive. + +### 4. Impact Analysis +* **Performance**: Minimal. Indexing and joins can be added as usage evolves. +* **Data Integrity**: Medium. Existing rows remain valid (nullable fields); application-level validation can enforce consistency. + +## v1.5.0 - Classes Domain Feature (Invitation Code) +**Date:** 2026-01-08 +**Migration ID:** `0007_add_class_invitation_code` +**Author:** Frontend/Fullstack Engineering + +### 1. Summary +This release introduces a 6-digit invitation code on `classes` to support join-by-code enrollment. + +### 2. Changes + +#### 2.1 Table: `classes` +* **Action**: `ADD COLUMN` + `ADD UNIQUE CONSTRAINT` +* **Field**: `invitation_code` (varchar(6), nullable, unique) +* **Reason**: Allow students to enroll into a class using a short code, while ensuring uniqueness across all classes. + +### 3. Migration Strategy +* **Up**: Run standard Drizzle migration. +* **Backfill**: Optional. Existing classes can keep `NULL` or be populated via application-level actions. +* **Down**: Not provided. Removing a unique constraint/column is destructive. + +### 4. Impact Analysis +* **Performance**: Minimal. Uniqueness is enforced via an index. +* **Data Integrity**: High. Unique constraint prevents code collisions and simplifies server-side enrollment checks. diff --git a/docs/db/seed-data.md b/docs/db/seed-data.md index 90be4f9..472fbd6 100644 --- a/docs/db/seed-data.md +++ b/docs/db/seed-data.md @@ -59,6 +59,12 @@ Demonstrates the new **JSON Structure** field (`exams.structure`). ] ``` +### 2.5 Classes / Enrollment / Schedule +Seeds the teacher class management domain. +* **Classes**: Creates at least one class owned by a teacher user. +* **Enrollments**: Links students to classes via `class_enrollments` (default status: `active`). +* **Schedule**: Populates `class_schedule` with weekday + start/end times for timetable validation. + ## 3. How to Run ### Prerequisites diff --git a/docs/design/002_teacher_dashboard_implementation.md b/docs/design/002_teacher_dashboard_implementation.md index f5924fd..3ac81f0 100644 --- a/docs/design/002_teacher_dashboard_implementation.md +++ b/docs/design/002_teacher_dashboard_implementation.md @@ -94,7 +94,120 @@ React 的 hydration 过程对 HTML 有效性要求极高。将 `div` 放入 `p` 2. `teacher-stats.tsx` 3. `teacher-schedule.tsx` -## 5. 下一步计划 -- 将 Mock Data 对接到真实的 API 端点 (React Server Actions)。 -- 实现 "Quick Actions" (快捷操作) 的具体功能。 -- 为 Submissions 和 Schedule 添加 "View All" (查看全部) 跳转导航。 +## 5. 更新记录(2026-01-04) +- 教师仪表盘从 Mock Data 切换为真实数据查询:`/teacher/dashboard` 组合 `getTeacherClasses`、`getClassSchedule`、`getHomeworkSubmissions({ creatorId })` 渲染 KPI / 今日课表 / 最近提交。 +- Quick Actions 落地为真实路由跳转(创建作业、查看列表等)。 +- Schedule / Submissions 增加 “View All” 跳转到对应列表页(并携带筛选参数)。 + +--- + +## 6. 教师端班级管理模块(真实数据接入记录) + +**日期**: 2025-12-31 +**范围**: 教师端「我的班级 / 学生 / 课表」页面与 MySQL(Drizzle) 真数据对接 + +### 6.1 页面入口与路由 + +班级管理相关页面位于: +- `src/app/(dashboard)/teacher/classes/my/page.tsx` +- `src/app/(dashboard)/teacher/classes/students/page.tsx` +- `src/app/(dashboard)/teacher/classes/schedule/page.tsx` + +为避免构建期/预渲染阶段访问数据库导致失败,以上页面显式启用动态渲染: +- `export const dynamic = "force-dynamic"` + +### 6.2 模块结构(Vertical Slice) + +班级模块采用垂直切片架构,代码位于 `src/modules/classes/`: +``` +src/modules/classes/ +├── components/ +│ ├── my-classes-grid.tsx +│ ├── students-filters.tsx +│ ├── students-table.tsx +│ ├── schedule-filters.tsx +│ └── schedule-view.tsx +├── data-access.ts +└── types.ts +``` + +其中 `data-access.ts` 负责班级、学生、课表三类查询的服务端数据读取,并作为页面层唯一的数据入口。 + +### 6.3 数据库表与迁移 + +新增班级领域表: +- `classes` +- `class_enrollments` +- `class_schedule` + +对应 Drizzle Schema: +- `src/shared/db/schema.ts` +- `src/shared/db/relations.ts` + +对应迁移文件: +- `drizzle/0003_petite_newton_destine.sql` + +外键关系(核心): +- `classes.teacher_id` -> `users.id` +- `class_enrollments.class_id` -> `classes.id` +- `class_enrollments.student_id` -> `users.id` +- `class_schedule.class_id` -> `classes.id` + +索引(核心): +- `classes_teacher_idx`, `classes_grade_idx` +- `class_enrollments_class_idx`, `class_enrollments_student_idx` +- `class_schedule_class_idx`, `class_schedule_class_day_idx` + +### 6.4 Seed 数据 + +Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面渲染与关联关系: +- `scripts/seed.ts` +- 运行命令:`npm run db:seed` + +### 6.5 开发过程中的问题与处理 + +- 端口占用(EADDRINUSE):开发服务器端口被占用时,通过更换端口启动规避(例如 `next dev -p `)。 +- Next dev 锁文件:出现 `.next/dev/lock` 无法获取锁时,需要确保只有一个 dev 实例在运行,并清理残留 lock。 +- 头像资源 404:移除 Header 中硬编码的本地头像资源引用,避免 `public/avatars/...` 不存在导致的 404 噪音(见 `src/modules/layout/components/site-header.tsx`)。 +- 班级人数统计查询失败:`class_enrollments` 表实际列名为 `class_enrollment_status`,修复查询中引用的列名以恢复教师端班级列表渲染。 + +### 6.6 班级详情页(聚合视图 + Schedule Builder + Homework 统计) + +**日期**: 2026-01-04 +**入口**: `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx` + +聚合数据在单次 RSC 请求内并发获取: +- 学生:`getClassStudents({ classId })` +- 课表:`getClassSchedule({ classId })` +- 作业统计:`getClassHomeworkInsights({ classId, limit })`(包含 latest、历史列表、overallScores、以及每次作业的 scoreStats:avg/median) + +页面呈现: +- 顶部 KPI 卡片:学生数、课表条目数、作业数、整体 avg/median +- Latest homework:目标人数、提交数、批改数、avg/median,直达作业与提交列表 +- Students / Schedule 预览:提供 View all 跳转到完整列表页 +- Homework history 表格:支持通过 URL query `?hw=all|active|overdue` 过滤作业记录,并展示每条作业的 avg/median + +课表编辑能力复用既有 Builder: +- 组件:`src/modules/classes/components/schedule-view.tsx`(新增/编辑/删除课表项) +- 数据变更:`src/modules/classes/actions.ts` + +### 6.7 班级邀请码(6 位码)加入与管理 + +**日期**: 2026-01-08 +**范围**: 为班级新增 6 位邀请码,支持学生通过输入邀请码加入班级;教师可查看与刷新邀请码 + +#### 6.7.1 数据结构 +- 表:`classes` +- 字段:`invitation_code`(varchar(6),unique,可为空) +- 迁移:`drizzle/0007_add_class_invitation_code.sql` + +#### 6.7.2 教师端能力 +- 在「我的班级」卡片中展示邀请码。 +- 提供“刷新邀请码”操作:生成新的 6 位码并写入数据库(确保唯一性)。 + +#### 6.7.3 学生端能力 +- 提供“通过邀请码加入班级”的入口,输入 6 位码后完成报名。 +- 写库操作设计为幂等:重复提交同一个邀请码不会生成重复报名记录,已有记录会被更新为有效状态。 + +#### 6.7.4 Seed 支持 +- `scripts/seed.ts` 为示例班级补充 `invitationCode`,便于在开发环境直接验证加入流程。 diff --git a/docs/design/003_textbooks_module_implementation.md b/docs/design/003_textbooks_module_implementation.md index 7cead9a..a15977b 100644 --- a/docs/design/003_textbooks_module_implementation.md +++ b/docs/design/003_textbooks_module_implementation.md @@ -1,6 +1,7 @@ # Textbooks Module Implementation Details **Date**: 2025-12-23 +**Updated**: 2025-12-31 **Author**: DevOps Architect **Module**: Textbooks (`src/modules/textbooks`) @@ -27,6 +28,13 @@ src/ │ ├── page.tsx │ └── loading.tsx │ +│ └── student/ +│ └── learning/ +│ └── textbooks/ # 学生端只读阅读(Server Components) +│ ├── page.tsx # 列表页(复用筛选与卡片) +│ └── [id]/ # 详情页(阅读器) +│ └── page.tsx +│ ├── modules/ │ └── textbooks/ # 业务模块 │ ├── actions.ts # Server Actions (增删改) @@ -34,6 +42,7 @@ src/ │ ├── types.ts # 类型定义 (Schema-aligned) │ └── components/ # 模块私有组件 │ ├── textbook-content-layout.tsx # [核心] 三栏布局工作台 +│ ├── textbook-reader.tsx # [新增] 学生端只读阅读器(URL state) │ ├── chapter-sidebar-list.tsx # 递归章节树 │ ├── knowledge-point-panel.tsx # 知识点管理面板 │ ├── create-chapter-dialog.tsx # 章节创建弹窗 @@ -64,6 +73,11 @@ src/ * **Optimistic UI**: 虽然使用 Server Actions,但通过本地状态 (`useState`) 实现了操作的即时反馈(如保存正文后立即退出编辑模式)。 * **Feedback**: 使用 `sonner` (`toast`) 提供操作成功或失败的提示。 +### 3.3 学生端阅读体验(Read-Only Reader) +* **两栏阅读**:左侧章节树,右侧正文渲染(Markdown)。 +* **URL State**:选中章节通过 `?chapterId=` 写入 URL,支持刷新/分享后保持定位(nuqs)。 +* **只读边界**:学生端不暴露创建/删除/编辑/知识点管理入口,避免误用教师工作台能力。 + ## 4. 数据流与逻辑 (Data Flow) ### 4.1 Server Actions @@ -71,18 +85,22 @@ src/ * `createChapterAction`: 创建章节(支持嵌套)。 * `updateChapterContentAction`: 更新正文内容。 * `createKnowledgePointAction`: 创建知识点并自动关联当前章节。 +* `deleteKnowledgePointAction`: 删除知识点并刷新详情页数据。 * `updateTextbookAction`: 更新教材元数据(Title, Subject, Grade, Publisher)。 * `deleteTextbookAction`: 删除教材及其关联数据。 * `delete...Action`: 处理删除逻辑。 ### 4.2 数据访问层 (Data Access) -* **Mock Implementation**: 目前在 `data-access.ts` 中使用内存数组模拟数据库操作,并人为增加了延迟 (`setTimeout`) 以测试 Loading 状态。 -* **Type Safety**: 定义了严格的 TypeScript 类型 (`Chapter`, `KnowledgePoint`, `UpdateTextbookInput`),确保前后端数据契约一致。 +* **DB Implementation**: 教材模块已接入真实数据库访问,`data-access.ts` 使用 `drizzle-orm` 直接查询并返回教材、章节、知识点数据。 +* **章节树构建**: 章节采用父子关系存储,通过一次性拉取后在内存中构建嵌套树结构,避免 N+1 查询。 +* **级联删除**: 删除章节时会同时删除其子章节以及关联的知识点,确保数据一致性。 +* **Type Safety**: 定义严格的 TypeScript 类型(如 `Chapter`, `KnowledgePoint`, `UpdateTextbookInput`),保证数据契约与 UI 组件一致。 ## 5. 组件复用 * 使用了 `src/shared/components/ui` 中的 Shadcn 组件: * `Dialog`, `ScrollArea`, `Card`, `Button`, `Input`, `Textarea`, `Select`. * `Collapsible` 用于实现递归章节树。 + * `AlertDialog` 用于危险操作的二次确认(删除章节/删除知识点)。 * 图标库统一使用 `lucide-react`. ## 6. Settings 功能实现 (New) @@ -92,7 +110,39 @@ src/ * **Edit**: 修改教材的基本信息。 * **Delete**: 提供红色删除按钮,二次确认后执行删除并跳转回列表页。 -## 7. 后续计划 (Next Steps) -* [ ] **富文本编辑器**: 集成 Tiptap 替换现有的 Markdown Textarea,支持更丰富的格式。 -* [ ] **拖拽排序**: 实现章节树的拖拽排序 (`dnd-kit`)。 -* [ ] **数据库对接**: 将 `data-access.ts` 中的 Mock 逻辑替换为真实的 `drizzle-orm` 数据库调用。 +## 7. 关键更新记录 (Changelog) + +### 7.1 数据与页面 +* 教材模块从 Mock 切换为真实 DB:新增教材/章节/知识点的数据访问与 Server Actions 刷新策略。 +* 列表页支持过滤/搜索:通过 query 参数驱动,统一空状态反馈。 + +### 7.2 章节侧边栏与弹窗 +* 修复子章节创建弹窗“闪现后消失”:改为受控 Dialog 状态管理。 +* 修复移动端操作按钮不可见/被遮挡:调整布局与可见性策略,确保小屏可点。 +* 删除章节使用确认弹窗并提供删除中状态。 + +### 7.3 Markdown 阅读体验 +* 阅读模式使用 `react-markdown` 渲染章节内容,支持 GFM(表格/任务列表等)。 +* 启用 Typography(`prose`)排版样式,使 `h1/h2/...` 在视觉上有明显层级差异。 +* 修复阅读模式内容区无法滚动:为 flex 容器补齐 `min-h-0` 等必要约束。 + +### 7.4 知识点删除交互 +* 删除知识点从浏览器 `confirm()` 升级为 `AlertDialog`: + * 显示目标名称、危险样式按钮 + * 删除中禁用交互并显示 loading 文案 + * 删除成功后刷新页面数据 + +### 7.5 学生端 Textbooks 列表与阅读页(New) +* 新增学生端路由: + * `/student/learning/textbooks`:教材列表页(RSC),复用筛选组件(nuqs)与卡片布局。 + * `/student/learning/textbooks/[id]`:教材阅读页(RSC + client 阅读器容器),章节选择与阅读不跳页。 +* 复用与适配: + * `TextbookCard` 增加可配置跳转基地址,避免学生端卡片误跳到教师端详情页。 + * 新增 `TextbookReader`(client)用于只读阅读体验:左侧章节树 + 右侧正文渲染,章节定位 URL 化(`chapterId`)。 +* 质量门禁: + * 通过 `npm run lint / typecheck / build`。 + +## 8. 后续计划 (Next Steps) +* [ ] **富文本编辑器**: 集成编辑器替换当前 Markdown Textarea,提升编辑体验。 +* [ ] **拖拽排序**: 实现章节树拖拽排序与持久化。 +* [ ] **知识点能力增强**: 支持编辑、排序、分层(如需要)。 diff --git a/docs/design/006_homework_module_implementation.md b/docs/design/006_homework_module_implementation.md index 1124ef2..50df14b 100644 --- a/docs/design/006_homework_module_implementation.md +++ b/docs/design/006_homework_module_implementation.md @@ -81,7 +81,7 @@ - `getHomeworkAssignments`:作业列表(可按 creatorId/ids) - `getHomeworkAssignmentById`:作业详情(含目标人数、提交数统计) -- `getHomeworkSubmissions`:提交列表(可按 assignmentId) +- `getHomeworkSubmissions`:提交列表(可按 assignmentId/classId/creatorId) - `getHomeworkSubmissionDetails`:提交详情(题目内容 + 学生答案 + 分值/顺序) ### 4.2 学生侧查询 @@ -151,3 +151,120 @@ - `npm run typecheck`: 通过 - `npm run lint`: 0 errors(仓库其他位置存在 warnings,与本模块新增功能无直接关联) + +--- + +## 9. 部署与环境变量(CI/CD) + +### 9.1 本地开发 + +- 本地开发使用项目根目录的 `.env` 提供 `DATABASE_URL` +- `.env` 仅用于本机开发,不应写入真实生产库凭据 + +### 9.2 CI 构建与部署(Gitea) + +工作流位于:[ci.yml](file:///c:/Users/xiner/Desktop/CICD/.gitea/workflows/ci.yml) + +- 构建阶段(`npm run build`)不依赖数据库连接:作业相关页面在构建时不会静态预渲染执行查库 +- 部署阶段通过 `docker run -e DATABASE_URL=...` 在运行时注入数据库连接串 +- 需要在 Gitea 仓库 Secrets 配置 `DATABASE_URL`(生产环境 MySQL 连接串) +- CI 中关闭 Next.js telemetry:设置 `NEXT_TELEMETRY_DISABLED=1` + +### 9.3 Next.js 渲染策略(避免 build 阶段查库) + +作业模块相关页面在渲染时会进行数据库查询,因此显式标记为动态渲染以避免构建期预渲染触发数据库连接: + +- 教师端作业列表:[assignments/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx) + +--- + +## 10. 实现更新(2026-01-05) + +### 10.1 教师端作业详情页组件化(按 Vertical Slice 拆分) + +将 `/teacher/homework/assignments/[id]` 页面调整为“只负责组装”,把可复用展示逻辑下沉到模块内组件: + +- 页面组装:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx) +- 题目错误概览卡片(overview):[homework-assignment-question-error-overview-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-question-error-overview-card.tsx) +- 题目错误明细卡片(details):[homework-assignment-question-error-details-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-question-error-details-card.tsx) +- 试卷预览/错题工作台容器卡片:[homework-assignment-exam-content-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-content-card.tsx) + +### 10.2 题目点击联动:试卷预览 ↔ 错题详情 + +在“试卷预览”中点击题目后,右侧联动展示该题的统计与错答列表(按学生逐条展示,不做合并): + +- 工作台(选择题目、拼装左右面板):[homework-assignment-exam-error-explorer.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-error-explorer.tsx) +- 试卷预览面板(可选中题目):[homework-assignment-exam-preview-pane.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-preview-pane.tsx) +- 错题详情面板(错误人数/错误率/错答列表):[homework-assignment-question-error-detail-panel.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-question-error-detail-panel.tsx) + +### 10.3 统计数据增强:返回逐学生错答 + +为满足“错答列表逐条展示学生姓名 + 答案”的需求,作业统计查询返回每题的错答明细(包含学生信息): + +- 数据访问:[getHomeworkAssignmentAnalytics](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts) +- 类型定义:[types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/types.ts) + +### 10.4 加载优化:Client Wrapper 动态分包 + +由于 `next/dynamic({ ssr: false })` 不能在 Server Component 内使用,工作台动态加载通过 Client wrapper 进行隔离: + +- Client wrapper:[homework-assignment-exam-error-explorer-lazy.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-error-explorer-lazy.tsx) +- 入口卡片(Server Component,渲染 wrapper):[homework-assignment-exam-content-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-content-card.tsx) + +### 10.5 校验 + +- `npm run lint`: 通过 +- `npm run typecheck`: 通过 +- `npm run build`: 通过 + +--- + +## 11. 学生成绩图表与排名(2026-01-06) + +### 11.1 目标 + +在学生主页(Dashboard)展示: + +- 最近已批改作业的成绩趋势(百分比折线) +- 最近若干次已批改作业明细(标题、得分、时间) +- 班级排名(基于班级内作业总体得分百分比) + +### 11.2 数据访问与计算口径 + +数据由 Homework 模块统一提供聚合查询,避免页面层拼 SQL: + +- 新增查询:[getStudentDashboardGrades](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts) + - `trend`:取该学生所有 `graded` 提交中“每个 assignment 最新一次”的集合,按时间升序取最近 10 个 + - `recent`:对 `trend` 再按时间降序取最近 5 条,用于表格展示 + - `maxScore`:通过 `homework_assignment_questions` 汇总每个 assignment 的总分(SUM(score)) + - `percentage`:`score / maxScore * 100` + - `ranking`: + - 班级选择:取该学生最早创建的一条 active enrollment 作为当前班级 + - 班级作业集合:班级内所有学生的 targets 合并得到 assignment 集合 + - 计分口径:班级内“每个学生 × 每个 assignment”取最新一次 graded 提交,累加得分与满分,得到总体百分比 + - 排名:按总体百分比降序排序(百分比相同按 studentId 作为稳定排序因子) + +### 11.3 类型定义 + +为 Dashboard 聚合数据提供显式类型: + +- [types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/types.ts) + - `StudentHomeworkScoreAnalytics` + - `StudentRanking` + - `StudentDashboardGradeProps` + +### 11.4 页面与组件接入 + +- 学生主页页面负责“取数 + 计算基础计数 + 传参”: + - [student/dashboard/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/student/dashboard/page.tsx) + - 取数:`getStudentDashboardGrades(student.id)` + - 传入:`` +- 展示组件负责渲染卡片: + - [student-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/dashboard/components/student-view.tsx) + - 趋势图:使用内联 `svg polyline` 渲染折线,避免引入额外图表依赖 + +### 11.5 校验 + +- `npm run lint`: 通过 +- `npm run typecheck`: 通过 +- `npm run build`: 通过 diff --git a/docs/design/007_school_module_implementation.md b/docs/design/007_school_module_implementation.md new file mode 100644 index 0000000..440897e --- /dev/null +++ b/docs/design/007_school_module_implementation.md @@ -0,0 +1,164 @@ +# 学校基础数据模块(School)实现文档与更新记录 + +**日期**: 2026-01-07 +**作者**: Frontend Team +**状态**: 已实现 + +## 1. 范围 + +本文档覆盖管理端「School」域的基础数据维护页面(Schools / Departments / Academic Year / Grades),并记录相关实现约束与关键更新,遵循 [003_frontend_engineering_standards.md](file:///c:/Users/xiner/Desktop/CICD/docs/architecture/003_frontend_engineering_standards.md) 的工程规范(Vertical Slice、Server/Client 边界、质量门禁)。 + +## 2. 路由入口(Admin) + +School 域路由位于 `src/app/(dashboard)/admin/school/*`,均显式声明 `export const dynamic = "force-dynamic"` 以避免构建期预渲染触发数据库访问。 + +- `/admin/school`:入口重定向到 Classes(当前落点不在 `school` 模块内) + 实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/page.tsx) +- `/admin/school/schools`:学校维护(增删改) + 实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/schools/page.tsx) +- `/admin/school/departments`:部门维护(增删改) + 实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/departments/page.tsx) +- `/admin/school/academic-year`:学年维护(增删改 + 设为当前学年) + 实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/academic-year/page.tsx) +- `/admin/school/grades`:年级维护(增删改 + 指派年级组长/教研组长) + 实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/grades/page.tsx) +- `/admin/school/grades/insights`:年级维度作业统计(跨班级聚合) + 实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/grades/insights/page.tsx) + +## 3. 模块结构(Vertical Slice) + +School 模块位于 `src/modules/school`: + +``` +src/modules/school/ +├── components/ +│ ├── schools-view.tsx +│ ├── departments-view.tsx +│ ├── academic-year-view.tsx +│ └── grades-view.tsx +├── actions.ts +├── data-access.ts +├── schema.ts +└── types.ts +``` + +边界约束: +- `data-access.ts` 包含 `import "server-only"`,仅用于服务端查询与 DTO 组装。 +- `actions.ts` 包含 `"use server"`,写操作统一通过 Server Actions 并 `revalidatePath`。 +- `components/*` 为 Client 交互层(表单、Dialog、筛选、行级操作),调用 Server Actions 并用 `sonner` toast 反馈。 + +## 4. 数据访问(data-access.ts) + +实现:[data-access.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/data-access.ts) + +- `getSchools(): Promise` +- `getDepartments(): Promise` +- `getAcademicYears(): Promise` +- `getGrades(): Promise` + - join `schools` 获取 `school.name` + - 收集 `gradeHeadId/teachingHeadId` 并批量查询 `users` 以组装 `StaffOption` +- `getStaffOptions(): Promise` + - 角色过滤 `teacher/admin` + - 排序 `name/email`,用于 Select 列表可用性 +- `getGradesForStaff(staffId: string): Promise` + - 用于按负责人(年级组长/教研组长)反查关联年级 + +返回 DTO 类型定义位于:[types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/types.ts) + +## 5. 写操作(actions.ts) + +实现:[actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/actions.ts) + +通用约束: +- 输入校验:统一使用 [schema.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/schema.ts) 的 Zod schema +- 返回结构:统一使用 [ActionState](file:///c:/Users/xiner/Desktop/CICD/src/shared/types/action-state.ts) +- 刷新策略:对目标页面路径执行 `revalidatePath` + +Departments: +- `createDepartmentAction(formData)` +- `updateDepartmentAction(departmentId, formData)` +- `deleteDepartmentAction(departmentId)` + +Academic Year: +- `createAcademicYearAction(formData)` +- `updateAcademicYearAction(academicYearId, formData)` +- `deleteAcademicYearAction(academicYearId)` + - 当 `isActive=true` 时,通过事务把其它学年置为非激活,保证唯一激活学年 + +Schools: +- `createSchoolAction(formData)` +- `updateSchoolAction(schoolId, formData)` +- `deleteSchoolAction(schoolId)` + - 删除后会同时刷新 `/admin/school/schools` 与 `/admin/school/grades` + +Grades: +- `createGradeAction(formData)` +- `updateGradeAction(gradeId, formData)` +- `deleteGradeAction(gradeId)` + +## 6. UI 组件(components/*) + +Schools: +- 实现:[schools-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/schools-view.tsx) +- 交互:列表 + Dialog 表单(新增/编辑)+ 删除确认(AlertDialog) + +Departments: +- 实现:[departments-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/departments-view.tsx) +- 交互:列表 + Dialog 表单(新增/编辑)+ 删除确认(AlertDialog) + +Academic Year: +- 实现:[academic-year-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/academic-year-view.tsx) +- 交互:列表 + Dialog 表单(新增/编辑)+ 删除确认(AlertDialog)+ 设为当前学年(isActive) + +Grades: +- 实现:[grades-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/grades-view.tsx) +- 交互:列表展示 + URL 驱动筛选(搜索/学校/负责人/排序)+ Dialog 表单(新增/编辑)+ 删除确认(AlertDialog) +- 负责人指派: + - 年级组长(gradeHeadId) + - 教研组长(teachingHeadId) + +## 7. 关键交互与规则(Grades) + +页面入口(RSC 组装)在服务端并发拉取三类数据: +- 年级列表:`getGrades()` +- 学校选项:`getSchools()` +- 负责人候选:`getStaffOptions()` + +实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/grades/page.tsx) + +### 7.1 URL State(筛选/排序) + +Grades 列表页的筛选状态 URL 化(`nuqs`): +- `q`:关键字(匹配 grade/school) +- `school`:学校过滤(`all` 或具体 schoolId) +- `head`:负责人过滤(全部 / 两者缺失 / 缺年级组长 / 缺教研组长) +- `sort`:排序(默认/名称/更新时间等) + +实现:[grades-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/grades-view.tsx) + +### 7.2 表单校验 + +Grades 的新增/编辑表单在客户端做轻量校验: +- 必填:`schoolId`、`name` +- `order`:非负整数 +- 去重:同一学校下年级名称不允许重复(忽略大小写 + 规范化空格) + +说明: +- 服务端写入前仍会经过 `UpsertGradeSchema` 校验(schema.ts),避免仅依赖客户端校验。 + +### 7.3 负责人选择(Radix Select) + +Radix Select 约束:`SelectItem` 的 `value` 不能为 `""`(空字符串),否则会触发运行时错误。 + +当前实现策略: +- UI 中 “未设置” 选项使用占位值 `__none__` +- 在 `onValueChange` 中将 `__none__` 映射回 `""` 存入本地表单 state +- 提交时依旧传递空字符串,由 `UpsertGradeSchema` 将其归一为 `null` + +实现:[grades-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/grades-view.tsx) + +## 2. 更新记录(2026-01-07) + +- 修复 Add Grades 弹窗报错:将 4 处 `` 替换为占位值 `__none__`,并在 `onValueChange` 中映射回 `\"\"`,保持“可清空选择/显示 placeholder”的行为不变。 +- 修复新建年级按钮不可用:创建/编辑表单在状态变化时触发实时校验更新,避免校验状态滞后导致提交被禁用。 +- 质量门禁:本地通过 `npm run lint` 与 `npm run typecheck`。 diff --git a/drizzle/0003_petite_newton_destine.sql b/drizzle/0003_petite_newton_destine.sql new file mode 100644 index 0000000..43646f0 --- /dev/null +++ b/drizzle/0003_petite_newton_destine.sql @@ -0,0 +1,43 @@ +CREATE TABLE `class_enrollments` ( + `class_id` varchar(128) NOT NULL, + `student_id` varchar(128) NOT NULL, + `class_enrollment_status` enum('active','inactive') NOT NULL DEFAULT 'active', + `created_at` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `class_enrollments_class_id_student_id_pk` PRIMARY KEY(`class_id`,`student_id`) +); +--> statement-breakpoint +CREATE TABLE `class_schedule` ( + `id` varchar(128) NOT NULL, + `class_id` varchar(128) NOT NULL, + `weekday` int NOT NULL, + `start_time` varchar(5) NOT NULL, + `end_time` varchar(5) NOT NULL, + `course` varchar(255) NOT NULL, + `location` varchar(100), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `class_schedule_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `classes` ( + `id` varchar(128) NOT NULL, + `name` varchar(255) NOT NULL, + `grade` varchar(50) NOT NULL, + `homeroom` varchar(50), + `room` varchar(50), + `teacher_id` varchar(128) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `classes_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `class_enrollments` ADD CONSTRAINT `ce_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `class_enrollments` ADD CONSTRAINT `ce_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `class_schedule` ADD CONSTRAINT `cs_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `classes` ADD CONSTRAINT `classes_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `class_enrollments_class_idx` ON `class_enrollments` (`class_id`);--> statement-breakpoint +CREATE INDEX `class_enrollments_student_idx` ON `class_enrollments` (`student_id`);--> statement-breakpoint +CREATE INDEX `class_schedule_class_idx` ON `class_schedule` (`class_id`);--> statement-breakpoint +CREATE INDEX `class_schedule_class_day_idx` ON `class_schedule` (`class_id`,`weekday`);--> statement-breakpoint +CREATE INDEX `classes_teacher_idx` ON `classes` (`teacher_id`);--> statement-breakpoint +CREATE INDEX `classes_grade_idx` ON `classes` (`grade`); \ No newline at end of file diff --git a/drizzle/0004_add_chapter_content_and_kp_chapter.sql b/drizzle/0004_add_chapter_content_and_kp_chapter.sql new file mode 100644 index 0000000..d9b23e0 --- /dev/null +++ b/drizzle/0004_add_chapter_content_and_kp_chapter.sql @@ -0,0 +1,3 @@ +ALTER TABLE `chapters` ADD `content` text;--> statement-breakpoint +ALTER TABLE `knowledge_points` ADD `chapter_id` varchar(128);--> statement-breakpoint +CREATE INDEX `kp_chapter_id_idx` ON `knowledge_points` (`chapter_id`); \ No newline at end of file diff --git a/drizzle/0005_add_class_school_subject_teachers.sql b/drizzle/0005_add_class_school_subject_teachers.sql new file mode 100644 index 0000000..746a9ca --- /dev/null +++ b/drizzle/0005_add_class_school_subject_teachers.sql @@ -0,0 +1,52 @@ +CREATE TABLE `academic_years` ( + `id` varchar(128) NOT NULL, + `name` varchar(100) NOT NULL, + `start_date` timestamp NOT NULL, + `end_date` timestamp NOT NULL, + `is_active` boolean NOT NULL DEFAULT false, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `academic_years_id` PRIMARY KEY(`id`), + CONSTRAINT `academic_years_name_unique` UNIQUE(`name`) +); +--> statement-breakpoint +CREATE TABLE `class_subject_teachers` ( + `class_id` varchar(128) NOT NULL, + `subject` enum('语文','数学','英语','美术','体育','科学','社会','音乐') NOT NULL, + `teacher_id` varchar(128), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `class_subject_teachers_class_id_subject_pk` PRIMARY KEY(`class_id`,`subject`) +); +--> statement-breakpoint +CREATE TABLE `classrooms` ( + `id` varchar(128) NOT NULL, + `name` varchar(255) NOT NULL, + `building` varchar(100), + `floor` int, + `capacity` int, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `classrooms_id` PRIMARY KEY(`id`), + CONSTRAINT `classrooms_name_unique` UNIQUE(`name`) +); +--> statement-breakpoint +CREATE TABLE `departments` ( + `id` varchar(128) NOT NULL, + `name` varchar(255) NOT NULL, + `description` text, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `departments_id` PRIMARY KEY(`id`), + CONSTRAINT `departments_name_unique` UNIQUE(`name`) +); +--> statement-breakpoint +ALTER TABLE `classes` ADD `school_name` varchar(255);--> statement-breakpoint +ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `class_subject_teachers_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `cst_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `academic_years_name_idx` ON `academic_years` (`name`);--> statement-breakpoint +CREATE INDEX `academic_years_active_idx` ON `academic_years` (`is_active`);--> statement-breakpoint +CREATE INDEX `class_subject_teachers_class_idx` ON `class_subject_teachers` (`class_id`);--> statement-breakpoint +CREATE INDEX `class_subject_teachers_teacher_idx` ON `class_subject_teachers` (`teacher_id`);--> statement-breakpoint +CREATE INDEX `classrooms_name_idx` ON `classrooms` (`name`);--> statement-breakpoint +CREATE INDEX `departments_name_idx` ON `departments` (`name`); \ No newline at end of file diff --git a/drizzle/0006_faithful_king_bedlam.sql b/drizzle/0006_faithful_king_bedlam.sql new file mode 100644 index 0000000..691861e --- /dev/null +++ b/drizzle/0006_faithful_king_bedlam.sql @@ -0,0 +1,38 @@ +CREATE TABLE `grades` ( + `id` varchar(128) NOT NULL, + `school_id` varchar(128) NOT NULL, + `name` varchar(100) NOT NULL, + `order` int NOT NULL DEFAULT 0, + `grade_head_id` varchar(128), + `teaching_head_id` varchar(128), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `grades_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `schools` ( + `id` varchar(128) NOT NULL, + `name` varchar(255) NOT NULL, + `code` varchar(50), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `schools_id` PRIMARY KEY(`id`), + CONSTRAINT `schools_name_unique` UNIQUE(`name`), + CONSTRAINT `schools_code_unique` UNIQUE(`code`) +); +--> statement-breakpoint +ALTER TABLE `classes` ADD `school_id` varchar(128);--> statement-breakpoint +ALTER TABLE `classes` ADD `grade_id` varchar(128);--> statement-breakpoint +ALTER TABLE `grades` ADD CONSTRAINT `g_s_fk` FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `grades` ADD CONSTRAINT `g_gh_fk` FOREIGN KEY (`grade_head_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `grades` ADD CONSTRAINT `g_th_fk` FOREIGN KEY (`teaching_head_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `grades_school_idx` ON `grades` (`school_id`);--> statement-breakpoint +CREATE INDEX `grades_school_name_uniq` ON `grades` (`school_id`,`name`);--> statement-breakpoint +CREATE INDEX `grades_grade_head_idx` ON `grades` (`grade_head_id`);--> statement-breakpoint +CREATE INDEX `grades_teaching_head_idx` ON `grades` (`teaching_head_id`);--> statement-breakpoint +CREATE INDEX `schools_name_idx` ON `schools` (`name`);--> statement-breakpoint +CREATE INDEX `schools_code_idx` ON `schools` (`code`);--> statement-breakpoint +ALTER TABLE `classes` ADD CONSTRAINT `c_s_fk` FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `classes` ADD CONSTRAINT `c_g_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `classes_school_idx` ON `classes` (`school_id`);--> statement-breakpoint +CREATE INDEX `classes_grade_id_idx` ON `classes` (`grade_id`); \ No newline at end of file diff --git a/drizzle/0007_add_class_invitation_code.sql b/drizzle/0007_add_class_invitation_code.sql new file mode 100644 index 0000000..033267b --- /dev/null +++ b/drizzle/0007_add_class_invitation_code.sql @@ -0,0 +1,3 @@ +ALTER TABLE `classes` ADD `invitation_code` varchar(6); +--> statement-breakpoint +ALTER TABLE `classes` ADD CONSTRAINT `classes_invitation_code_unique` UNIQUE(`invitation_code`); diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..6bc4088 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2192 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "b020bc0f-36bb-45fc-aad9-6cc6e6c99426", + "prevId": "e6118000-4093-4c16-a01c-e33a2a5f0875", + "tables": { + "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": {} + }, + "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 + }, + "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": {} + }, + "classes": { + "name": "classes", + "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 + }, + "grade": { + "name": "grade", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "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 + }, + "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 + } + }, + "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" + } + }, + "compositePrimaryKeys": { + "classes_id": { + "name": "classes_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 + }, + "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 + }, + "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": {}, + "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" + } + }, + "compositePrimaryKeys": { + "exams_id": { + "name": "exams_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_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 + }, + "parent_id": { + "name": "parent_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 + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_points_id": { + "name": "knowledge_points_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'student'" + }, + "password": { + "name": "password", + "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": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "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/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..4623d70 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2213 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "97232350-6c6c-4c3e-98d6-c34d33d55a4d", + "prevId": "b020bc0f-36bb-45fc-aad9-6cc6e6c99426", + "tables": { + "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": {} + }, + "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": {} + }, + "classes": { + "name": "classes", + "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 + }, + "grade": { + "name": "grade", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "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 + }, + "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 + } + }, + "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" + } + }, + "compositePrimaryKeys": { + "classes_id": { + "name": "classes_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 + }, + "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 + }, + "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": {}, + "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" + } + }, + "compositePrimaryKeys": { + "exams_id": { + "name": "exams_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_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 + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'student'" + }, + "password": { + "name": "password", + "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": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "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/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..62ca73a --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2564 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "abd9c192-f000-4024-a017-3d662125141b", + "prevId": "97232350-6c6c-4c3e-98d6-c34d33d55a4d", + "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": {} + }, + "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": { + "name": "subject", + "type": "enum('语文','数学','英语','美术','体育','科学','社会','音乐')", + "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 + } + }, + "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" + } + }, + "compositePrimaryKeys": { + "class_subject_teachers_class_id_subject_pk": { + "name": "class_subject_teachers_class_id_subject_pk", + "columns": [ + "class_id", + "subject" + ] + } + }, + "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 + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade": { + "name": "grade", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "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 + }, + "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 + } + }, + "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" + } + }, + "compositePrimaryKeys": { + "classes_id": { + "name": "classes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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": {} + }, + "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": {} + }, + "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 + }, + "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 + }, + "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": {}, + "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" + } + }, + "compositePrimaryKeys": { + "exams_id": { + "name": "exams_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_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 + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'student'" + }, + "password": { + "name": "password", + "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": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "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/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..8973e5b --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2848 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "3b23e056-3d79-4ea9-a03e-d1b5d56bafda", + "prevId": "abd9c192-f000-4024-a017-3d662125141b", + "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": {} + }, + "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": { + "name": "subject", + "type": "enum('语文','数学','英语','美术','体育','科学','社会','音乐')", + "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 + } + }, + "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" + } + }, + "compositePrimaryKeys": { + "class_subject_teachers_class_id_subject_pk": { + "name": "class_subject_teachers_class_id_subject_pk", + "columns": [ + "class_id", + "subject" + ] + } + }, + "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 + }, + "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": {}, + "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": {} + }, + "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": {} + }, + "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 + }, + "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 + }, + "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": {}, + "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" + } + }, + "compositePrimaryKeys": { + "exams_id": { + "name": "exams_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_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 + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'student'" + }, + "password": { + "name": "password", + "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": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "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 b5eb1b4..507dbe5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,41 @@ "when": 1767145757594, "tag": "0002_equal_wolfpack", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1767166769676, + "tag": "0003_petite_newton_destine", + "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1767169003334, + "tag": "0004_add_chapter_content_and_kp_chapter", + "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1767751916045, + "tag": "0005_add_class_school_subject_teachers", + "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1767760693171, + "tag": "0006_faithful_king_bedlam", + "breakpoints": true + }, + { + "idx": 7, + "version": "5", + "when": 1767782500000, + "tag": "0007_add_class_invitation_code", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 08abd88..b205c79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,9 @@ "react": "19.2.1", "react-dom": "19.2.1", "react-hook-form": "^7.69.0", + "react-markdown": "^10.1.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -49,6 +52,7 @@ "devDependencies": { "@faker-js/faker": "^10.1.0", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.16", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -4528,6 +4532,19 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tanstack/query-core": { "version": "5.90.12", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", @@ -4598,13 +4615,39 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4619,6 +4662,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.26", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", @@ -4633,7 +4691,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4649,6 +4706,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.49.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", @@ -4918,6 +4981,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -5494,6 +5563,16 @@ "node": ">= 0.4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5665,6 +5744,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5682,6 +5771,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5729,6 +5858,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5758,11 +5897,23 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -5830,7 +5981,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5844,6 +5994,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5896,6 +6059,15 @@ "node": ">=0.10" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5912,6 +6084,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6797,6 +6982,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6807,6 +7002,12 @@ "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7254,6 +7455,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -7271,6 +7512,16 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/iconv-lite": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", @@ -7324,6 +7575,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7339,6 +7596,30 @@ "node": ">= 0.4" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7497,6 +7778,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7556,6 +7847,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -7609,6 +7910,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -8228,6 +8541,16 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -8285,6 +8608,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8295,6 +8628,302 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8305,6 +8934,569 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -8346,7 +9538,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mysql2": { @@ -8796,6 +9987,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8881,6 +10097,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/preact": { "version": "10.24.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", @@ -9017,6 +10247,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9092,6 +10332,33 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -9205,6 +10472,87 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -9609,6 +10957,16 @@ "source-map": "^0.6.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -9752,6 +11110,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -9775,6 +11147,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -9924,6 +11314,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -10124,6 +11534,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -10252,6 +11749,41 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10437,6 +11969,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index f5eba5f..6d7cfce 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", - "db:seed": "npx tsx scripts/seed.ts" + "db:seed": "npx tsx scripts/seed.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -43,6 +45,9 @@ "react": "19.2.1", "react-dom": "19.2.1", "react-hook-form": "^7.69.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "remark-breaks": "^4.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -51,6 +56,7 @@ }, "devDependencies": { "@faker-js/faker": "^10.1.0", + "@tailwindcss/typography": "^0.5.16", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", diff --git a/scripts/seed.ts b/scripts/seed.ts index 80607e5..8efbec0 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -3,8 +3,16 @@ import { db } from "../src/shared/db"; import { users, roles, usersToRoles, questions, knowledgePoints, questionsToKnowledgePoints, - exams, examQuestions, examSubmissions, submissionAnswers, - textbooks, chapters + exams, examQuestions, + homeworkAssignments, + homeworkAssignmentQuestions, + homeworkAssignmentTargets, + homeworkSubmissions, + homeworkAnswers, + textbooks, chapters, + schools, + grades, + classes, classEnrollments, classSchedule } from "../src/shared/db/schema"; import { createId } from "@paralleldrive/cuid2"; import { faker } from "@faker-js/faker"; @@ -30,9 +38,12 @@ async function seed() { try { await db.execute(sql`SET FOREIGN_KEY_CHECKS = 0;`); const tables = [ + "class_schedule", "class_enrollments", "classes", + "homework_answers", "homework_submissions", "homework_assignment_targets", "homework_assignment_questions", "homework_assignments", "submission_answers", "exam_submissions", "exam_questions", "exams", "questions_to_knowledge_points", "questions", "knowledge_points", "chapters", "textbooks", + "grades", "schools", "users_to_roles", "roles", "users", "accounts", "sessions" ]; for (const table of tables) { @@ -52,14 +63,16 @@ async function seed() { admin: "role_admin", teacher: "role_teacher", student: "role_student", - grade_head: "role_grade_head" + grade_head: "role_grade_head", + teaching_head: "role_teaching_head" }; await db.insert(roles).values([ { id: roleMap.admin, name: "admin", description: "System Administrator" }, { id: roleMap.teacher, name: "teacher", description: "Academic Instructor" }, { id: roleMap.student, name: "student", description: "Learner" }, - { id: roleMap.grade_head, name: "grade_head", description: "Head of Grade Year" } + { id: roleMap.grade_head, name: "grade_head", description: "Head of Grade Year" }, + { id: roleMap.teaching_head, name: "teaching_head", description: "Teaching Research Lead" } ]); // Users @@ -98,6 +111,107 @@ async function seed() { { userId: "user_student_1", roleId: roleMap.student }, ]); + const extraStudentIds: string[] = []; + for (let i = 0; i < 12; i++) { + const studentId = createId(); + extraStudentIds.push(studentId); + await db.insert(users).values({ + id: studentId, + name: faker.person.fullName(), + email: faker.internet.email().toLowerCase(), + role: "student", + image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${studentId}`, + }); + await db.insert(usersToRoles).values({ userId: studentId, roleId: roleMap.student }); + } + + const schoolId = "school_nextedu" + const grade10Id = "grade_10" + + await db.insert(schools).values([ + { id: schoolId, name: "Next_Edu School", code: "NEXTEDU" }, + { id: "school_demo_2", name: "Demo School No.2", code: "DEMO2" }, + ]) + + await db.insert(grades).values([ + { + id: grade10Id, + schoolId, + name: "Grade 10", + order: 10, + gradeHeadId: "user_teacher_math", + teachingHeadId: "user_teacher_math", + }, + ]) + + await db.insert(classes).values([ + { + id: "class_10_3", + schoolName: "Next_Edu School", + schoolId, + name: "Grade 10 · Class 3", + grade: "Grade 10", + gradeId: grade10Id, + homeroom: "10-3", + room: "Room 304", + invitationCode: "100003", + teacherId: "user_teacher_math", + }, + { + id: "class_10_7", + schoolName: "Next_Edu School", + schoolId, + name: "Grade 10 · Class 7", + grade: "Grade 10", + gradeId: grade10Id, + homeroom: "10-7", + room: "Room 201", + invitationCode: "100007", + teacherId: "user_teacher_math", + }, + ]); + + await db.insert(classEnrollments).values([ + { classId: "class_10_3", studentId: "user_student_1", status: "active" }, + ...extraStudentIds.slice(0, 8).map((studentId) => ({ classId: "class_10_3", studentId, status: "active" as const })), + ...extraStudentIds.slice(8, 12).map((studentId) => ({ classId: "class_10_7", studentId, status: "active" as const })), + ]); + + await db.insert(classSchedule).values([ + { id: "cs_001", classId: "class_10_3", weekday: 1, startTime: "09:00", endTime: "09:45", course: "Mathematics", location: "Room 304" }, + { id: "cs_002", classId: "class_10_3", weekday: 3, startTime: "14:00", endTime: "14:45", course: "Physics", location: "Lab A" }, + { id: "cs_003", classId: "class_10_7", weekday: 2, startTime: "11:00", endTime: "11:45", course: "Mathematics", location: "Room 201" }, + ]); + + await db.insert(textbooks).values([ + { + id: "tb_01", + title: "Advanced Mathematics Grade 10", + subject: "Mathematics", + grade: "Grade 10", + publisher: "Next Education Press", + }, + ]) + + await db.insert(chapters).values([ + { + id: "ch_01", + textbookId: "tb_01", + title: "Chapter 1: Real Numbers", + order: 1, + parentId: null, + content: "# Chapter 1: Real Numbers\n\nIn this chapter, we will explore the properties of real numbers...", + }, + { + id: "ch_01_01", + textbookId: "tb_01", + title: "1.1 Introduction to Real Numbers", + order: 1, + parentId: "ch_01", + content: "## 1.1 Introduction\n\nReal numbers include rational and irrational numbers.", + }, + ]) + // --- 2. Knowledge Graph (Tree) --- console.log("🧠 Seeding Knowledge Graph..."); @@ -111,103 +225,273 @@ async function seed() { { id: kpLinearId, name: "Linear Equations", parentId: kpAlgebraId, level: 2 }, ]); + await db.insert(knowledgePoints).values([ + { + id: "kp_01", + name: "Real Numbers", + description: "Definition and properties of real numbers", + level: 1, + order: 1, + chapterId: "ch_01", + }, + { + id: "kp_02", + name: "Rational Numbers", + description: "Numbers that can be expressed as a fraction", + level: 2, + order: 1, + chapterId: "ch_01_01", + }, + ]) + // --- 3. Question Bank (Rich Content) --- console.log("📚 Seeding Question Bank..."); - // 3.1 Simple Single Choice - const qSimpleId = createId(); - await db.insert(questions).values({ - id: qSimpleId, - authorId: "user_teacher_math", - type: "single_choice", - difficulty: 1, - content: { - text: "What is 2 + 2?", - options: [ - { id: "A", text: "3", isCorrect: false }, - { id: "B", text: "4", isCorrect: true }, - { id: "C", text: "5", isCorrect: false } - ] - } - }); - - // Link to KP - await db.insert(questionsToKnowledgePoints).values({ - questionId: qSimpleId, - knowledgePointId: kpLinearId // Just for demo - }); - - // 3.2 Composite Question (Reading Comprehension) - const qParentId = createId(); - const qChild1Id = createId(); - const qChild2Id = createId(); - - // Parent (Passage) - await db.insert(questions).values({ - id: qParentId, - authorId: "user_teacher_math", - type: "composite", - difficulty: 3, - content: { - text: "Read the following passage about Algebra...\n(Long text here)...", - assets: [] - } - }); - - // Children - await db.insert(questions).values([ + const mathExamQuestions: Array<{ + id: string; + type: "single_choice" | "text" | "judgment"; + difficulty: number; + content: unknown; + score: number; + }> = [ { - id: qChild1Id, - authorId: "user_teacher_math", - parentId: qParentId, // <--- Key: Nested + id: createId(), type: "single_choice", - difficulty: 2, + difficulty: 1, + score: 4, content: { - text: "What is the main topic?", + text: "1) What is 2 + 2?", options: [ - { id: "A", text: "Geometry", isCorrect: false }, - { id: "B", text: "Algebra", isCorrect: true } - ] - } + { id: "A", text: "3", isCorrect: false }, + { id: "B", text: "4", isCorrect: true }, + { id: "C", text: "5", isCorrect: false }, + { id: "D", text: "6", isCorrect: false }, + ], + }, }, { - id: qChild2Id, - authorId: "user_teacher_math", - parentId: qParentId, - type: "text", - difficulty: 4, + id: createId(), + type: "single_choice", + difficulty: 2, + score: 4, content: { - text: "Explain the concept of variables.", - } - } - ]); + text: "2) If f(x) = 2x + 1, then f(3) = ?", + options: [ + { id: "A", text: "5", isCorrect: false }, + { id: "B", text: "7", isCorrect: true }, + { id: "C", text: "8", isCorrect: false }, + { id: "D", text: "10", isCorrect: false }, + ], + }, + }, + { + id: createId(), + type: "single_choice", + difficulty: 2, + score: 4, + content: { + text: "3) Solve 3x - 5 = 7. What is x?", + options: [ + { id: "A", text: "3", isCorrect: false }, + { id: "B", text: "4", isCorrect: true }, + { id: "C", text: "5", isCorrect: false }, + { id: "D", text: "6", isCorrect: false }, + ], + }, + }, + { + id: createId(), + type: "single_choice", + difficulty: 3, + score: 4, + content: { + text: "4) Which is a factor of x^2 - 9?", + options: [ + { id: "A", text: "(x - 3)", isCorrect: true }, + { id: "B", text: "(x + 9)", isCorrect: false }, + { id: "C", text: "(x - 9)", isCorrect: false }, + { id: "D", text: "(x^2 + 9)", isCorrect: false }, + ], + }, + }, + { + id: createId(), + type: "single_choice", + difficulty: 2, + score: 4, + content: { + text: "5) If a^2 = 49 and a > 0, then a = ?", + options: [ + { id: "A", text: "-7", isCorrect: false }, + { id: "B", text: "0", isCorrect: false }, + { id: "C", text: "7", isCorrect: true }, + { id: "D", text: "49", isCorrect: false }, + ], + }, + }, + { + id: createId(), + type: "single_choice", + difficulty: 3, + score: 4, + content: { + text: "6) Simplify (x^2 y)(x y^3).", + options: [ + { id: "A", text: "x^2 y^3", isCorrect: false }, + { id: "B", text: "x^3 y^4", isCorrect: true }, + { id: "C", text: "x^3 y^3", isCorrect: false }, + { id: "D", text: "x^4 y^4", isCorrect: false }, + ], + }, + }, + { + id: createId(), + type: "single_choice", + difficulty: 2, + score: 4, + content: { + text: "7) The slope of the line y = -3x + 2 is:", + options: [ + { id: "A", text: "2", isCorrect: false }, + { id: "B", text: "-3", isCorrect: true }, + { id: "C", text: "3", isCorrect: false }, + { id: "D", text: "-2", isCorrect: false }, + ], + }, + }, + { + id: createId(), + type: "single_choice", + difficulty: 1, + score: 4, + content: { + text: "8) The probability of getting heads in one fair coin toss is:", + options: [ + { id: "A", text: "0", isCorrect: false }, + { id: "B", text: "1/4", isCorrect: false }, + { id: "C", text: "1/2", isCorrect: true }, + { id: "D", text: "1", isCorrect: false }, + ], + }, + }, + { + id: createId(), + type: "single_choice", + difficulty: 2, + score: 4, + content: { + text: "9) In an arithmetic sequence with a1 = 2 and d = 3, a5 = ?", + options: [ + { id: "A", text: "11", isCorrect: false }, + { id: "B", text: "12", isCorrect: false }, + { id: "C", text: "14", isCorrect: true }, + { id: "D", text: "17", isCorrect: false }, + ], + }, + }, + { + id: createId(), + type: "single_choice", + difficulty: 2, + score: 4, + content: { + text: "10) The solution set of x^2 = 0 is:", + options: [ + { id: "A", text: "{0}", isCorrect: true }, + { id: "B", text: "{1}", isCorrect: false }, + { id: "C", text: "{-1, 1}", isCorrect: false }, + { id: "D", text: "Empty set", isCorrect: false }, + ], + }, + }, + + { id: createId(), type: "text", difficulty: 1, score: 4, content: { text: "11) Fill in the blank: √81 = ____.", correctAnswer: "9" } }, + { id: createId(), type: "text", difficulty: 2, score: 4, content: { text: "12) Fill in the blank: (a - b)^2 = ____.", correctAnswer: ["a^2 - 2ab + b^2", "a² - 2ab + b²"] } }, + { id: createId(), type: "text", difficulty: 1, score: 4, content: { text: "13) Fill in the blank: 2^5 = ____.", correctAnswer: "32" } }, + { id: createId(), type: "text", difficulty: 2, score: 4, content: { text: "14) Fill in the blank: The area of a circle with radius r is ____.", correctAnswer: ["πr^2", "pi r^2", "πr²"] } }, + { id: createId(), type: "text", difficulty: 1, score: 4, content: { text: "15) Fill in the blank: If x = -2, then x^3 = ____.", correctAnswer: "-8" } }, + + { id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "16) If x > y, then x + 1 > y + 1.", correctAnswer: true } }, + { id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "17) The graph of y = 2x is a parabola.", correctAnswer: false } }, + { id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "18) The sum of interior angles of a triangle is 180°.", correctAnswer: true } }, + { id: createId(), type: "judgment", difficulty: 2, score: 2, content: { text: "19) (x + y)^2 = x^2 + y^2.", correctAnswer: false } }, + { id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "20) 0 is a positive number.", correctAnswer: false } }, + + { id: createId(), type: "text", difficulty: 3, score: 10, content: { text: "21) Solve the system: x + y = 5, x - y = 1." } }, + { id: createId(), type: "text", difficulty: 3, score: 10, content: { text: "22) Expand and simplify: (2x - 3)(x + 4)." } }, + { id: createId(), type: "text", difficulty: 3, score: 10, content: { text: "23) In a right triangle with legs 6 and 8, find the hypotenuse and the area." } }, + ]; + + await db.insert(questions).values( + mathExamQuestions.map((q) => ({ + id: q.id, + type: q.type, + difficulty: q.difficulty, + content: q.content, + authorId: "user_teacher_math", + })) + ); + + await db.insert(questionsToKnowledgePoints).values({ + questionId: mathExamQuestions[0].id, + knowledgePointId: kpLinearId + }); // --- 4. Exams (New Structure) --- console.log("📝 Seeding Exams..."); const examId = createId(); + + const makeGroup = (title: string, children: unknown[]) => ({ + id: createId(), + type: "group", + title, + children, + }); + + const makeQuestionNode = (questionId: string, score: number) => ({ + id: createId(), + type: "question", + questionId, + score, + }); + + const choiceIds = mathExamQuestions.slice(0, 10).map((q) => q.id); + const fillIds = mathExamQuestions.slice(10, 15).map((q) => q.id); + const judgmentIds = mathExamQuestions.slice(15, 20).map((q) => q.id); + const shortAnswerIds = mathExamQuestions.slice(20, 23).map((q) => q.id); + const examStructure = [ - { - type: "group", - title: "Part 1: Basics", - children: [ - { type: "question", questionId: qSimpleId, score: 10 } - ] - }, - { - type: "group", - title: "Part 2: Reading", - children: [ - // For composite questions, we usually add the parent, and the system fetches children - { type: "question", questionId: qParentId, score: 20 } - ] - } + makeGroup( + "第一部分:单项选择题(共10题,每题4分,共40分)", + choiceIds.map((id) => makeQuestionNode(id, 4)) + ), + makeGroup( + "第二部分:填空题(共5题,每题4分,共20分)", + fillIds.map((id) => makeQuestionNode(id, 4)) + ), + makeGroup( + "第三部分:判断题(共5题,每题2分,共10分)", + judgmentIds.map((id) => makeQuestionNode(id, 2)) + ), + makeGroup( + "第四部分:解答题(共3题,每题10分,共30分)", + shortAnswerIds.map((id) => makeQuestionNode(id, 10)) + ), ]; await db.insert(exams).values({ id: examId, - title: "Algebra Mid-Term 2025", - description: "Comprehensive assessment", + title: "Grade 10 Mathematics Final Exam (Seed)", + description: JSON.stringify({ + subject: "Mathematics", + grade: "Grade 10", + difficulty: 3, + totalScore: 100, + durationMin: 120, + questionCount: 23, + tags: ["seed", "math", "grade10", "final"], + }), creatorId: "user_teacher_math", status: "published", startTime: new Date(), @@ -215,15 +499,118 @@ async function seed() { }); // Link questions physically (Source of Truth) - await db.insert(examQuestions).values([ - { examId, questionId: qSimpleId, score: 10, order: 0 }, - { examId, questionId: qParentId, score: 20, order: 1 }, - // Note: Child questions are often implicitly included or explicitly added depending on logic. - // For this seed, we assume linking Parent is enough for the relation, - // but let's link children too for completeness if the query strategy requires it. - { examId, questionId: qChild1Id, score: 0, order: 2 }, - { examId, questionId: qChild2Id, score: 0, order: 3 }, - ]); + const orderedQuestionIds = [...choiceIds, ...fillIds, ...judgmentIds, ...shortAnswerIds]; + const scoreById = new Map(mathExamQuestions.map((q) => [q.id, q.score] as const)); + + await db.insert(examQuestions).values( + orderedQuestionIds.map((questionId, order) => ({ + examId, + questionId, + score: scoreById.get(questionId) ?? 0, + order, + })) + ); + + console.log("📌 Seeding Homework Assignments..."); + + const assignmentId = createId(); + const now = new Date(); + const dueAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const lateDueAt = new Date(now.getTime() + 9 * 24 * 60 * 60 * 1000); + + await db.insert(homeworkAssignments).values({ + id: assignmentId, + sourceExamId: examId, + title: "Grade 10 Mathematics Final - Homework (Seed)", + description: "Auto-generated homework assignment from seeded math exam.", + structure: examStructure as unknown, + status: "published", + creatorId: "user_teacher_math", + availableAt: now, + dueAt, + allowLate: true, + lateDueAt, + maxAttempts: 2, + }); + + await db.insert(homeworkAssignmentQuestions).values( + orderedQuestionIds.map((questionId, order) => ({ + assignmentId, + questionId, + score: scoreById.get(questionId) ?? 0, + order, + })) + ); + + const targetStudentIds = ["user_student_1", ...extraStudentIds.slice(0, 4)]; + await db.insert(homeworkAssignmentTargets).values( + targetStudentIds.map((studentId) => ({ + assignmentId, + studentId, + })) + ); + + const scoreForQuestion = (questionId: string) => scoreById.get(questionId) ?? 0; + const buildAnswer = (questionId: string, type: string) => { + if (type === "single_choice") return { answer: "B" }; + if (type === "judgment") return { answer: true }; + return { answer: "Seed answer" }; + }; + + const questionTypeById = new Map(mathExamQuestions.map((q) => [q.id, q.type] as const)); + + const submissionIds: string[] = []; + for (let i = 0; i < 3; i++) { + const studentId = targetStudentIds[i]; + const submissionId = createId(); + submissionIds.push(submissionId); + const submittedAt = new Date(now.getTime() - (i + 1) * 24 * 60 * 60 * 1000); + const status = i === 0 ? "graded" : i === 1 ? "graded" : "submitted"; + + const perQuestionScores = orderedQuestionIds.map((qid, idx) => { + const max = scoreForQuestion(qid); + if (status !== "graded") return null; + if (max <= 0) return 0; + if (idx % 7 === 0) return Math.max(0, max - 1); + return max; + }); + + const totalScore = + status === "graded" + ? perQuestionScores.reduce((sum, s) => sum + Number(s ?? 0), 0) + : null; + + await db.insert(homeworkSubmissions).values({ + id: submissionId, + assignmentId, + studentId, + attemptNo: 1, + score: totalScore, + status, + startedAt: submittedAt, + submittedAt, + isLate: false, + createdAt: submittedAt, + updatedAt: submittedAt, + }); + + await db.insert(homeworkAnswers).values( + orderedQuestionIds.map((questionId, idx) => { + const questionType = questionTypeById.get(questionId) ?? "text"; + const score = status === "graded" ? (perQuestionScores[idx] ?? 0) : null; + return { + id: createId(), + submissionId, + questionId, + answerContent: buildAnswer(questionId, questionType), + score, + feedback: status === "graded" ? (score === scoreForQuestion(questionId) ? "Good" : "Check calculation") : null, + createdAt: submittedAt, + updatedAt: submittedAt, + }; + }) + ); + } const end = performance.now(); console.log(`✅ Seed completed in ${(end - start).toFixed(2)}ms`); diff --git a/src/app/(auth)/error.tsx b/src/app/(auth)/error.tsx index a558f03..7816187 100644 --- a/src/app/(auth)/error.tsx +++ b/src/app/(auth)/error.tsx @@ -1,20 +1,9 @@ "use client" -import { useEffect } from "react" import { Button } from "@/shared/components/ui/button" import { AlertCircle } from "lucide-react" -export default function AuthError({ - error, - reset, -}: { - error: Error & { digest?: string } - reset: () => void -}) { - useEffect(() => { - console.error(error) - }, [error]) - +export default function AuthError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { return (
diff --git a/src/app/(dashboard)/admin/dashboard/page.tsx b/src/app/(dashboard)/admin/dashboard/page.tsx index 4769f2c..a5b770f 100644 --- a/src/app/(dashboard)/admin/dashboard/page.tsx +++ b/src/app/(dashboard)/admin/dashboard/page.tsx @@ -1,5 +1,9 @@ -import { AdminDashboard } from "@/modules/dashboard/components/admin-view" +import { AdminDashboardView } from "@/modules/dashboard/components/admin-dashboard/admin-dashboard" +import { getAdminDashboardData } from "@/modules/dashboard/data-access" -export default function AdminDashboardPage() { - return +export const dynamic = "force-dynamic" + +export default async function AdminDashboardPage() { + const data = await getAdminDashboardData() + return } diff --git a/src/app/(dashboard)/admin/school/academic-year/page.tsx b/src/app/(dashboard)/admin/school/academic-year/page.tsx new file mode 100644 index 0000000..4d9459f --- /dev/null +++ b/src/app/(dashboard)/admin/school/academic-year/page.tsx @@ -0,0 +1,18 @@ +import { AcademicYearClient } from "@/modules/school/components/academic-year-view" +import { getAcademicYears } from "@/modules/school/data-access" + +export const dynamic = "force-dynamic" + +export default async function AdminAcademicYearPage() { + const years = await getAcademicYears() + return ( +
+
+

Academic Year

+

Manage academic year ranges and the active year.

+
+ +
+ ) +} + diff --git a/src/app/(dashboard)/admin/school/classes/page.tsx b/src/app/(dashboard)/admin/school/classes/page.tsx new file mode 100644 index 0000000..c413a8a --- /dev/null +++ b/src/app/(dashboard)/admin/school/classes/page.tsx @@ -0,0 +1,19 @@ +import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access" +import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view" + +export const dynamic = "force-dynamic" + +export default async function AdminSchoolClassesPage() { + const [classes, teachers] = await Promise.all([getAdminClasses(), getTeacherOptions()]) + + return ( +
+
+

Classes

+

Manage classes and assign teachers.

+
+ +
+ ) +} + diff --git a/src/app/(dashboard)/admin/school/departments/page.tsx b/src/app/(dashboard)/admin/school/departments/page.tsx new file mode 100644 index 0000000..d1105e8 --- /dev/null +++ b/src/app/(dashboard)/admin/school/departments/page.tsx @@ -0,0 +1,17 @@ +import { DepartmentsClient } from "@/modules/school/components/departments-view" +import { getDepartments } from "@/modules/school/data-access" + +export const dynamic = "force-dynamic" + +export default async function AdminDepartmentsPage() { + const departments = await getDepartments() + return ( +
+
+

Departments

+

Manage school departments.

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/grades/insights/page.tsx b/src/app/(dashboard)/admin/school/grades/insights/page.tsx new file mode 100644 index 0000000..0f8e016 --- /dev/null +++ b/src/app/(dashboard)/admin/school/grades/insights/page.tsx @@ -0,0 +1,231 @@ +import Link from "next/link" + +import { getGrades } from "@/modules/school/data-access" +import { getGradeHomeworkInsights } from "@/modules/classes/data-access" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" +import { BarChart3 } from "lucide-react" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + if (typeof v === "string") return v + if (Array.isArray(v)) return v[0] + return undefined +} + +const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-") + +export default async function AdminGradeInsightsPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams + const gradeId = getParam(params, "gradeId") + + const grades = await getGrades() + const selected = gradeId && gradeId !== "all" ? gradeId : "" + + const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null + + return ( +
+
+
+

Grade Insights

+

Homework statistics aggregated across all classes in a grade.

+
+ +
+ + + + Filters + + {grades.length} + + + +
+ + + +
+
+
+ + {!selected ? ( + + ) : !insights ? ( + + ) : insights.assignments.length === 0 ? ( + + ) : ( +
+
+ + + Classes + + +
{insights.classCount}
+
+ {insights.grade.school.name} / {insights.grade.name} +
+
+
+ + + Students + + +
{insights.studentCounts.total}
+
+ Active {insights.studentCounts.active} • Inactive {insights.studentCounts.inactive} +
+
+
+ + + Overall Avg + + +
{fmt(insights.overallScores.avg)}
+
Across graded homework
+
+
+ + + Latest Avg + + +
{fmt(insights.latest?.scoreStats.avg ?? null)}
+
{insights.latest ? insights.latest.title : "-"}
+
+
+
+ + + + Latest homework + + {insights.assignments.length} + + + +
+ + + + Assignment + Status + Created + Targeted + Submitted + Graded + Avg + Median + + + + {insights.assignments.map((a) => ( + + {a.title} + + + {a.status} + + + {formatDate(a.createdAt)} + {a.targetCount} + {a.submittedCount} + {a.gradedCount} + {fmt(a.scoreStats.avg)} + {fmt(a.scoreStats.median)} + + ))} + +
+
+
+
+ + + + Class ranking + + {insights.classes.length} + + + +
+ + + + Class + Students + Latest Avg + Prev Avg + Δ + Overall Avg + + + + {insights.classes.map((c) => ( + + + {c.class.name} + {c.class.homeroom ? • {c.class.homeroom} : null} + + {c.studentCounts.total} + {fmt(c.latestAvg)} + {fmt(c.prevAvg)} + {fmt(c.deltaAvg)} + {fmt(c.overallScores.avg)} + + ))} + +
+
+
+
+
+ )} +
+ ) +} + diff --git a/src/app/(dashboard)/admin/school/grades/page.tsx b/src/app/(dashboard)/admin/school/grades/page.tsx new file mode 100644 index 0000000..07f1d4a --- /dev/null +++ b/src/app/(dashboard)/admin/school/grades/page.tsx @@ -0,0 +1,19 @@ +import { GradesClient } from "@/modules/school/components/grades-view" +import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access" + +export const dynamic = "force-dynamic" + +export default async function AdminGradesPage() { + const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()]) + + return ( +
+
+

Grades

+

Manage grades and assign grade heads.

+
+ +
+ ) +} + diff --git a/src/app/(dashboard)/admin/school/page.tsx b/src/app/(dashboard)/admin/school/page.tsx new file mode 100644 index 0000000..f185014 --- /dev/null +++ b/src/app/(dashboard)/admin/school/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function AdminSchoolPage() { + redirect("/admin/school/classes") +} diff --git a/src/app/(dashboard)/admin/school/schools/page.tsx b/src/app/(dashboard)/admin/school/schools/page.tsx new file mode 100644 index 0000000..81fe1b5 --- /dev/null +++ b/src/app/(dashboard)/admin/school/schools/page.tsx @@ -0,0 +1,18 @@ +import { SchoolsClient } from "@/modules/school/components/schools-view" +import { getSchools } from "@/modules/school/data-access" + +export const dynamic = "force-dynamic" + +export default async function AdminSchoolsPage() { + const schools = await getSchools() + return ( +
+
+

Schools

+

Manage schools for multi-school setups.

+
+ +
+ ) +} + diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 31faf44..484b2ec 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,72 +1,16 @@ -"use client" +import { redirect } from "next/navigation" +import { auth } from "@/auth" -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { Button } from "@/shared/components/ui/button" -import Link from "next/link" -import { Shield, GraduationCap, Users, User } from "lucide-react" +export const dynamic = "force-dynamic" -// In a real app, this would be a server component that redirects based on session -// But for this demo/dev environment, we keep the manual selection or add auto-redirect logic if we had auth state. +export default async function DashboardPage() { + const session = await auth() + if (!session?.user) redirect("/login") -export default function DashboardPage() { - // Mock Auth Logic (Optional: Uncomment to test auto-redirect) - /* - const router = useRouter(); - useEffect(() => { - // const role = "teacher"; // Fetch from auth hook - // if (role) router.push(`/${role}/dashboard`); - }, []); - */ + const role = String(session.user.role ?? "teacher") - return ( -
-
-

Welcome to Next_Edu

-

Select your role to view the corresponding dashboard.

-

- [DEV MODE] In production, you would be redirected automatically based on your login session. -

-
- -
- - - - - - - - - - - - -
-
- ) + if (role === "admin") redirect("/admin/dashboard") + if (role === "student") redirect("/student/dashboard") + if (role === "parent") redirect("/parent/dashboard") + redirect("/teacher/dashboard") } diff --git a/src/app/(dashboard)/error.tsx b/src/app/(dashboard)/error.tsx index 30a18de..8458acc 100644 --- a/src/app/(dashboard)/error.tsx +++ b/src/app/(dashboard)/error.tsx @@ -1,23 +1,10 @@ "use client" -import { useEffect } from "react" import { AlertCircle } from "lucide-react" -import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" -export default function Error({ - error, - reset, -}: { - error: Error & { digest?: string } - reset: () => void -}) { - useEffect(() => { - // Log the error to an error reporting service - console.error(error) - }, [error]) - +export default function Error({ reset }: { error: Error & { digest?: string }; reset: () => void }) { return (
{ + const day = d.getDay() + return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7 +} + +export default async function ProfilePage() { + const session = await auth() + if (!session?.user) redirect("/login") + + const name = session.user.name ?? "User" + const email = session.user.email ?? "-" + const role = String(session.user.role ?? "teacher") + const userId = String(session.user.id ?? "").trim() + + const studentData = + role === "student" && userId + ? await (async () => { + const [classes, schedule, assignmentsAll, grades] = await Promise.all([ + getStudentClasses(userId), + getStudentSchedule(userId), + getStudentHomeworkAssignments(userId), + getStudentDashboardGrades(userId), + ]) + + const now = new Date() + const in7Days = new Date(now) + in7Days.setDate(in7Days.getDate() + 7) + + const dueSoonCount = assignmentsAll.filter((a) => { + if (!a.dueAt) return false + const due = new Date(a.dueAt) + return due >= now && due <= in7Days && a.progressStatus !== "graded" + }).length + + const overdueCount = assignmentsAll.filter((a) => { + if (!a.dueAt) return false + const due = new Date(a.dueAt) + return due < now && a.progressStatus !== "graded" + }).length + + const gradedCount = assignmentsAll.filter((a) => a.progressStatus === "graded").length + + const upcomingAssignments = [...assignmentsAll] + .sort((a, b) => { + const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY + const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY + if (aTime !== bTime) return aTime - bTime + return a.id.localeCompare(b.id) + }) + .slice(0, 8) + + const todayWeekday = toWeekday(now) + const todayScheduleItems = schedule + .filter((s) => s.weekday === todayWeekday) + .map((s) => ({ + id: s.id, + classId: s.classId, + className: s.className, + course: s.course, + startTime: s.startTime, + endTime: s.endTime, + location: s.location ?? null, + })) + + return { + enrolledClassCount: classes.length, + dueSoonCount, + overdueCount, + gradedCount, + todayScheduleItems, + upcomingAssignments, + grades, + } + })() + : null + + return ( +
+
+
+

Profile

+
Your account information.
+
+
+ +
+
+ + + + Account + Signed-in user details from session. + + +
+
{name}
+ + {role} + +
+
{email}
+
+
+ + {studentData ? ( +
+
+

Student

+
Your learning overview.
+
+ + + +
+ + +
+ +
+ + +
+
+ ) : null} +
+ ) +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..e9c2a5b --- /dev/null +++ b/src/app/(dashboard)/settings/page.tsx @@ -0,0 +1,21 @@ +import { redirect } from "next/navigation" + +import { auth } from "@/auth" +import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view" +import { StudentSettingsView } from "@/modules/settings/components/student-settings-view" +import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view" + +export const dynamic = "force-dynamic" + +export default async function SettingsPage() { + const session = await auth() + if (!session?.user) redirect("/login") + + const role = String(session.user.role ?? "teacher") + + if (role === "admin") return + if (role === "student") return + if (role === "teacher") return + + redirect("/dashboard") +} diff --git a/src/app/(dashboard)/student/dashboard/loading.tsx b/src/app/(dashboard)/student/dashboard/loading.tsx new file mode 100644 index 0000000..d6ad44c --- /dev/null +++ b/src/app/(dashboard)/student/dashboard/loading.tsx @@ -0,0 +1,61 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + + ))} +
+ +
+ + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + + + + + + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + +
+
+ ) +} + diff --git a/src/app/(dashboard)/student/dashboard/page.tsx b/src/app/(dashboard)/student/dashboard/page.tsx index a093ee3..8dded85 100644 --- a/src/app/(dashboard)/student/dashboard/page.tsx +++ b/src/app/(dashboard)/student/dashboard/page.tsx @@ -1,5 +1,88 @@ -import { StudentDashboard } from "@/modules/dashboard/components/student-view" +import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view" +import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access" +import { getDemoStudentUser, getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Inbox } from "lucide-react" -export default function StudentDashboardPage() { - return +export const dynamic = "force-dynamic" + +const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => { + const day = d.getDay() + return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7 +} + +export default async function StudentDashboardPage() { + const student = await getDemoStudentUser() + if (!student) { + return ( +
+ +
+ ) + } + + const [classes, schedule, assignments, grades] = await Promise.all([ + getStudentClasses(student.id), + getStudentSchedule(student.id), + getStudentHomeworkAssignments(student.id), + getStudentDashboardGrades(student.id), + ]) + + const now = new Date() + const in7Days = new Date(now) + in7Days.setDate(in7Days.getDate() + 7) + + const dueSoonCount = assignments.filter((a) => { + if (!a.dueAt) return false + const due = new Date(a.dueAt) + return due >= now && due <= in7Days && a.progressStatus !== "graded" + }).length + + const overdueCount = assignments.filter((a) => { + if (!a.dueAt) return false + const due = new Date(a.dueAt) + return due < now && a.progressStatus !== "graded" + }).length + + const gradedCount = assignments.filter((a) => a.progressStatus === "graded").length + + const todayWeekday = toWeekday(now) + const todayScheduleItems = schedule + .filter((s) => s.weekday === todayWeekday) + .map((s) => ({ + id: s.id, + classId: s.classId, + className: s.className, + course: s.course, + startTime: s.startTime, + endTime: s.endTime, + location: s.location ?? null, + })) + .sort((a, b) => a.startTime.localeCompare(b.startTime)) + + const upcomingAssignments = [...assignments] + .sort((a, b) => { + const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY + const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY + return aDue - bDue + }) + .slice(0, 6) + + return ( + + ) } diff --git a/src/app/(dashboard)/student/learning/courses/loading.tsx b/src/app/(dashboard)/student/learning/courses/loading.tsx new file mode 100644 index 0000000..c4a76bd --- /dev/null +++ b/src/app/(dashboard)/student/learning/courses/loading.tsx @@ -0,0 +1,29 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + + + ))} +
+
+ ) +} + diff --git a/src/app/(dashboard)/student/learning/courses/page.tsx b/src/app/(dashboard)/student/learning/courses/page.tsx new file mode 100644 index 0000000..1d676d5 --- /dev/null +++ b/src/app/(dashboard)/student/learning/courses/page.tsx @@ -0,0 +1,40 @@ +import { Inbox } from "lucide-react" + +import { getStudentClasses } from "@/modules/classes/data-access" +import { getDemoStudentUser } from "@/modules/homework/data-access" +import { StudentCoursesView } from "@/modules/student/components/student-courses-view" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export const dynamic = "force-dynamic" + +export default async function StudentCoursesPage() { + const student = await getDemoStudentUser() + if (!student) { + return ( +
+
+

Courses

+

Your enrolled classes.

+
+ +
+ ) + } + + const classes = await getStudentClasses(student.id) + + return ( +
+
+

Courses

+

Your enrolled classes.

+
+ +
+ ) +} + diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx new file mode 100644 index 0000000..0d21f66 --- /dev/null +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx @@ -0,0 +1,78 @@ +import Link from "next/link" +import { notFound } from "next/navigation" + +import { ArrowLeft, BookOpen, Inbox } from "lucide-react" + +import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" +import { TextbookReader } from "@/modules/textbooks/components/textbook-reader" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { getDemoStudentUser } from "@/modules/homework/data-access" + +export const dynamic = "force-dynamic" + +export default async function StudentTextbookDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const student = await getDemoStudentUser() + if (!student) { + return ( +
+
+
+

Textbook

+

Read chapters and review content.

+
+
+ +
+ ) + } + + const { id } = await params + + const [textbook, chapters] = await Promise.all([getTextbookById(id), getChaptersByTextbookId(id)]) + + if (!textbook) notFound() + + return ( +
+
+ +
+
+ {textbook.subject} + + {textbook.grade ?? "-"} + +
+

{textbook.title}

+
+
+ +
+ {chapters.length === 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/src/app/(dashboard)/student/learning/textbooks/page.tsx b/src/app/(dashboard)/student/learning/textbooks/page.tsx new file mode 100644 index 0000000..f36b0e2 --- /dev/null +++ b/src/app/(dashboard)/student/learning/textbooks/page.tsx @@ -0,0 +1,80 @@ +import { BookOpen, Inbox } from "lucide-react" + +import { getTextbooks } from "@/modules/textbooks/data-access" +import { TextbookCard } from "@/modules/textbooks/components/textbook-card" +import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters" +import { getDemoStudentUser } from "@/modules/homework/data-access" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Button } from "@/shared/components/ui/button" +import Link from "next/link" + +export const dynamic = "force-dynamic" + +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 StudentTextbooksPage({ + searchParams, +}: { + searchParams: Promise +}) { + const [student, sp] = await Promise.all([getDemoStudentUser(), searchParams]) + + if (!student) { + return ( +
+
+
+

Textbooks

+

Browse your course textbooks.

+
+
+ +
+ ) + } + + const q = getParam(sp, "q") || undefined + const subject = getParam(sp, "subject") || undefined + const grade = getParam(sp, "grade") || undefined + + const textbooks = await getTextbooks(q, subject, grade) + const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all")) + + return ( +
+
+
+

Textbooks

+

Browse your course textbooks.

+
+ +
+ + + + {textbooks.length === 0 ? ( + + ) : ( +
+ {textbooks.map((textbook) => ( + + ))} +
+ )} +
+ ) +} + diff --git a/src/app/(dashboard)/student/schedule/loading.tsx b/src/app/(dashboard)/student/schedule/loading.tsx new file mode 100644 index 0000000..e59c48e --- /dev/null +++ b/src/app/(dashboard)/student/schedule/loading.tsx @@ -0,0 +1,32 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + {Array.from({ length: 3 }).map((__, j) => ( + + ))} + + + ))} +
+
+ ) +} + diff --git a/src/app/(dashboard)/student/schedule/page.tsx b/src/app/(dashboard)/student/schedule/page.tsx new file mode 100644 index 0000000..7440997 --- /dev/null +++ b/src/app/(dashboard)/student/schedule/page.tsx @@ -0,0 +1,55 @@ +import { Inbox } from "lucide-react" + +import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access" +import { getDemoStudentUser } from "@/modules/homework/data-access" +import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters" +import { StudentScheduleView } from "@/modules/student/components/student-schedule-view" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +export default async function StudentSchedulePage({ + searchParams, +}: { + searchParams: Promise +}) { + const student = await getDemoStudentUser() + if (!student) { + return ( +
+
+

Schedule

+

Your weekly timetable.

+
+ +
+ ) + } + + const [sp, classes, schedule] = await Promise.all([ + searchParams, + getStudentClasses(student.id), + getStudentSchedule(student.id), + ]) + + const classIdParam = sp.classId + const classId = typeof classIdParam === "string" ? classIdParam : Array.isArray(classIdParam) ? classIdParam[0] : "all" + const filteredItems = + classId && classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule + + return ( +
+
+
+

Schedule

+

Your weekly timetable.

+
+ +
+ +
+ ) +} + diff --git a/src/app/(dashboard)/teacher/classes/insights/page.tsx b/src/app/(dashboard)/teacher/classes/insights/page.tsx new file mode 100644 index 0000000..5a9de0f --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/insights/page.tsx @@ -0,0 +1,259 @@ +import Link from "next/link" +import { Suspense } from "react" +import { BarChart3 } from "lucide-react" + +import { getClassHomeworkInsights, getTeacherClasses } from "@/modules/classes/data-access" +import { InsightsFilters } from "@/modules/classes/components/insights-filters" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Skeleton } from "@/shared/components/ui/skeleton" +import { Badge } from "@/shared/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Button } from "@/shared/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" + +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 formatNumber = (v: number | null, digits = 1) => { + if (typeof v !== "number" || Number.isNaN(v)) return "-" + return v.toFixed(digits) +} + +function InsightsResultsFallback() { + return ( +
+
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+
+ + +
+
+ ))} +
+
+
+ +
+
+ {Array.from({ length: 8 }).map((_, idx) => ( + + ))} +
+
+
+ ) +} + +async function InsightsResults({ searchParams }: { searchParams: Promise }) { + const params = await searchParams + const classId = getParam(params, "classId") + + if (!classId || classId === "all") { + return ( + + ) + } + + const insights = await getClassHomeworkInsights({ classId, limit: 50 }) + if (!insights) { + return ( + + ) + } + + const hasAssignments = insights.assignments.length > 0 + + if (!hasAssignments) { + return ( + + ) + } + + const latest = insights.latest + + return ( +
+
+ + + Students + + +
{insights.studentCounts.total}
+
+ Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive} +
+
+
+ + + Assignments + + +
{insights.assignments.length}
+
Latest: {latest ? formatDate(latest.createdAt) : "-"}
+
+
+ + + Overall scores + + +
{formatNumber(insights.overallScores.avg, 1)}
+
+ Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count} +
+
+
+
+ + {latest && ( + + +
+ Latest assignment +
+ {latest.title} + + {latest.status} + + · + {formatDate(latest.createdAt)} + {latest.dueAt ? ( + <> + · + Due {formatDate(latest.dueAt)} + + ) : null} +
+
+
+ + +
+
+ +
+
Targeted
+
{latest.targetCount}
+
+
+
Submitted
+
{latest.submittedCount}
+
+
+
Graded
+
{latest.gradedCount}
+
+
+
Average
+
{formatNumber(latest.scoreStats.avg, 1)}
+
+
+
Median
+
{formatNumber(latest.scoreStats.median, 1)}
+
+
+
+ )} + +
+ + + + Assignment + Status + Due + Targeted + Submitted + Graded + Avg + Median + Min + Max + + + + {insights.assignments.map((a) => ( + + + + {a.title} + +
Created {formatDate(a.createdAt)}
+
+ + + {a.status} + + + {a.dueAt ? formatDate(a.dueAt) : "-"} + {a.targetCount} + {a.submittedCount} + {a.gradedCount} + {formatNumber(a.scoreStats.avg, 1)} + {formatNumber(a.scoreStats.median, 1)} + {formatNumber(a.scoreStats.min, 0)} + {formatNumber(a.scoreStats.max, 0)} +
+ ))} +
+
+
+
+ ) +} + +export default async function ClassInsightsPage({ searchParams }: { searchParams: Promise }) { + const classes = await getTeacherClasses() + + return ( +
+
+
+

Class Insights

+

Latest homework and historical score statistics for a class.

+
+
+ +
+ }> + + + + }> + + +
+
+ ) +} + diff --git a/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx b/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx new file mode 100644 index 0000000..2eb38c2 --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx @@ -0,0 +1,315 @@ +import Link from "next/link" +import { notFound } from "next/navigation" + +import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access" +import { ScheduleView } from "@/modules/classes/components/schedule-view" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" + +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 formatNumber = (v: number | null, digits = 1) => { + if (typeof v !== "number" || Number.isNaN(v)) return "-" + return v.toFixed(digits) +} + +export default async function ClassDetailPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise +}) { + const { id } = await params + const sp = await searchParams + const hw = getParam(sp, "hw") + const hwFilter = hw === "active" || hw === "overdue" ? hw : "all" + + const [insights, students, schedule] = await Promise.all([ + getClassHomeworkInsights({ classId: id, limit: 50 }), + getClassStudents({ classId: id }), + getClassSchedule({ classId: id }), + ]) + + if (!insights) return notFound() + + const latest = insights.latest + const filteredAssignments = insights.assignments.filter((a) => { + if (hwFilter === "all") return true + if (hwFilter === "overdue") return a.isOverdue + if (hwFilter === "active") return a.isActive + return true + }) + const hasAssignments = filteredAssignments.length > 0 + const scheduleBuilderClasses = [ + { + id: insights.class.id, + name: insights.class.name, + grade: insights.class.grade, + homeroom: insights.class.homeroom ?? null, + room: insights.class.room ?? null, + studentCount: insights.studentCounts.total, + }, + ] + + return ( +
+
+
+
+ + {insights.class.grade} + {insights.studentCounts.total} students +
+

{insights.class.name}

+
+ {insights.class.room ? `Room: ${insights.class.room}` : "Room: Not set"} + {insights.class.homeroom ? ` · Homeroom: ${insights.class.homeroom}` : null} +
+
+ +
+ + + +
+
+ +
+ + + Students + + +
{insights.studentCounts.total}
+
+ Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive} +
+
+
+ + + Schedule items + + +
{schedule.length}
+
Weekly timetable entries
+
+
+ + + Assignments + + +
{insights.assignments.length}
+
{latest ? `Latest ${formatDate(latest.createdAt)}` : "No homework yet"}
+
+
+ + + Overall avg + + +
{formatNumber(insights.overallScores.avg, 1)}
+
+ Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count} +
+
+
+
+ + {latest ? ( + + +
+ Latest homework +
+ {latest.title} + + {latest.status} + + · + {formatDate(latest.createdAt)} + {latest.dueAt ? ( + <> + · + Due {formatDate(latest.dueAt)} + + ) : null} +
+
+
+ + +
+
+ +
+
Targeted
+
{latest.targetCount}
+
+
+
Submitted
+
{latest.submittedCount}
+
+
+
Graded
+
{latest.gradedCount}
+
+
+
Average
+
{formatNumber(latest.scoreStats.avg, 1)}
+
+
+
Median
+
{formatNumber(latest.scoreStats.median, 1)}
+
+
+
+ ) : null} + +
+ + + Students (preview) + + + + {students.length === 0 ? ( +
No students enrolled.
+ ) : ( +
+ + + + Name + Email + Status + + + + {students.slice(0, 8).map((s) => ( + + {s.name} + {s.email} + + + {s.status} + + + + ))} + +
+
+ )} +
+
+ + + + Schedule + + + + + + +
+ + + + Homework history +
+ + + + + +
+
+ + {!hasAssignments ? ( +
No homework assignments yet.
+ ) : ( +
+ + + + Assignment + Status + Due + Targeted + Submitted + Graded + Avg + Median + + + + {filteredAssignments.map((a) => ( + + + + {a.title} + +
Created {formatDate(a.createdAt)}
+
+ + + {a.status} + + + {a.dueAt ? formatDate(a.dueAt) : "-"} + {a.targetCount} + {a.submittedCount} + {a.gradedCount} + {formatNumber(a.scoreStats.avg, 1)} + {formatNumber(a.scoreStats.median, 1)} +
+ ))} +
+
+
+ )} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/classes/my/loading.tsx b/src/app/(dashboard)/teacher/classes/my/loading.tsx new file mode 100644 index 0000000..3c4278b --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/my/loading.tsx @@ -0,0 +1,32 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, idx) => ( +
+
+ + +
+ +
+ + +
+
+ + +
+
+ ))} +
+
+ ) +} + diff --git a/src/app/(dashboard)/teacher/classes/my/page.tsx b/src/app/(dashboard)/teacher/classes/my/page.tsx index 39f1d24..620a297 100644 --- a/src/app/(dashboard)/teacher/classes/my/page.tsx +++ b/src/app/(dashboard)/teacher/classes/my/page.tsx @@ -1,10 +1,18 @@ -import { EmptyState } from "@/shared/components/ui/empty-state" -import { Users } from "lucide-react" +import { getTeacherClasses } from "@/modules/classes/data-access" +import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid" + +export const dynamic = "force-dynamic" export default function MyClassesPage() { + return +} + +async function MyClassesPageImpl() { + const classes = await getTeacherClasses() + return ( -
-
+
+

My Classes

@@ -12,11 +20,8 @@ export default function MyClassesPage() {

- + +
) } diff --git a/src/app/(dashboard)/teacher/classes/schedule/loading.tsx b/src/app/(dashboard)/teacher/classes/schedule/loading.tsx new file mode 100644 index 0000000..c831af5 --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/schedule/loading.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ +
+ {Array.from({ length: 6 }).map((_, idx) => ( +
+
+ + +
+
+ + + +
+
+ ))} +
+
+ ) +} + diff --git a/src/app/(dashboard)/teacher/classes/schedule/page.tsx b/src/app/(dashboard)/teacher/classes/schedule/page.tsx index 4655efb..09714ab 100644 --- a/src/app/(dashboard)/teacher/classes/schedule/page.tsx +++ b/src/app/(dashboard)/teacher/classes/schedule/page.tsx @@ -1,10 +1,73 @@ -import { EmptyState } from "@/shared/components/ui/empty-state" +import { Suspense } from "react" import { Calendar } from "lucide-react" -export default function SchedulePage() { +import { getClassSchedule, getTeacherClasses } from "@/modules/classes/data-access" +import { ScheduleFilters } from "@/modules/classes/components/schedule-filters" +import { ScheduleView } from "@/modules/classes/components/schedule-view" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Skeleton } from "@/shared/components/ui/skeleton" + +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 +} + +async function ScheduleResults({ searchParams }: { searchParams: Promise }) { + const params = await searchParams + const classId = getParam(params, "classId") + + const classes = await getTeacherClasses() + const schedule = await getClassSchedule({ + classId: classId && classId !== "all" ? classId : undefined, + }) + + const hasFilters = Boolean(classId && classId !== "all") + + if (schedule.length === 0) { + return ( + + ) + } + + return +} + +function ScheduleResultsFallback() { return ( -
-
+
+ {Array.from({ length: 6 }).map((_, idx) => ( +
+
+ + +
+
+ + + +
+
+ ))} +
+ ) +} + +export default async function SchedulePage({ searchParams }: { searchParams: Promise }) { + const classes = await getTeacherClasses() + + return ( +
+

Schedule

@@ -12,11 +75,16 @@ export default function SchedulePage() {

- + +
+ }> + + + + }> + + +
) } diff --git a/src/app/(dashboard)/teacher/classes/students/loading.tsx b/src/app/(dashboard)/teacher/classes/students/loading.tsx new file mode 100644 index 0000000..293ff87 --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/students/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ +
+
+ {Array.from({ length: 10 }).map((_, idx) => ( + + ))} +
+
+
+ ) +} + diff --git a/src/app/(dashboard)/teacher/classes/students/page.tsx b/src/app/(dashboard)/teacher/classes/students/page.tsx index 55f5463..3615225 100644 --- a/src/app/(dashboard)/teacher/classes/students/page.tsx +++ b/src/app/(dashboard)/teacher/classes/students/page.tsx @@ -1,10 +1,74 @@ -import { EmptyState } from "@/shared/components/ui/empty-state" +import { Suspense } from "react" import { User } from "lucide-react" -export default function StudentsPage() { +import { getClassStudents, getTeacherClasses } from "@/modules/classes/data-access" +import { StudentsFilters } from "@/modules/classes/components/students-filters" +import { StudentsTable } from "@/modules/classes/components/students-table" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Skeleton } from "@/shared/components/ui/skeleton" + +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 +} + +async function StudentsResults({ searchParams }: { searchParams: Promise }) { + const params = await searchParams + + const q = getParam(params, "q") || undefined + const classId = getParam(params, "classId") + + const filteredStudents = await getClassStudents({ + q, + classId: classId && classId !== "all" ? classId : undefined, + }) + + const hasFilters = Boolean(q || (classId && classId !== "all")) + + if (filteredStudents.length === 0) { + return ( + + ) + } + return ( -
-
+
+ +
+ ) +} + +function StudentsResultsFallback() { + return ( +
+
+ +
+
+ {Array.from({ length: 8 }).map((_, idx) => ( + + ))} +
+
+ ) +} + +export default async function StudentsPage({ searchParams }: { searchParams: Promise }) { + const classes = await getTeacherClasses() + + return ( +
+

Students

@@ -12,11 +76,16 @@ export default function StudentsPage() {

- + +
+ }> + + + + }> + + +
) } diff --git a/src/app/(dashboard)/teacher/dashboard/page.tsx b/src/app/(dashboard)/teacher/dashboard/page.tsx index 7dd66fd..977b902 100644 --- a/src/app/(dashboard)/teacher/dashboard/page.tsx +++ b/src/app/(dashboard)/teacher/dashboard/page.tsx @@ -1,28 +1,27 @@ -import { TeacherStats } from "@/modules/dashboard/components/teacher-stats"; -import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule"; -import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions"; -import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions"; +import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view" +import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access"; +import { getHomeworkAssignments, getHomeworkSubmissions } from "@/modules/homework/data-access"; + +export const dynamic = "force-dynamic"; + +export default async function TeacherDashboardPage() { + const teacherId = await getTeacherIdForMutations(); + + const [classes, schedule, assignments, submissions] = await Promise.all([ + getTeacherClasses({ teacherId }), + getClassSchedule({ teacherId }), + getHomeworkAssignments({ creatorId: teacherId }), + getHomeworkSubmissions({ creatorId: teacherId }), + ]); -export default function TeacherDashboardPage() { return ( -
-
-

Teacher Dashboard

-
- -
-
- - {/* Overview Stats */} - - -
- {/* Left Column: Schedule (3/7 width) */} - - - {/* Right Column: Recent Activity (4/7 width) */} - -
-
- ); + + ) } diff --git a/src/app/(dashboard)/teacher/grades/insights/page.tsx b/src/app/(dashboard)/teacher/grades/insights/page.tsx new file mode 100644 index 0000000..655fdcc --- /dev/null +++ b/src/app/(dashboard)/teacher/grades/insights/page.tsx @@ -0,0 +1,244 @@ +import { getTeacherIdForMutations } from "@/modules/classes/data-access" +import { getGradeHomeworkInsights } from "@/modules/classes/data-access" +import { getGradesForStaff } from "@/modules/school/data-access" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { BarChart3 } from "lucide-react" +import { formatDate } from "@/shared/lib/utils" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + if (typeof v === "string") return v + if (Array.isArray(v)) return v[0] + return undefined +} + +const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-") + +export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams + const gradeId = getParam(params, "gradeId") + + const teacherId = await getTeacherIdForMutations() + const grades = await getGradesForStaff(teacherId) + const allowedIds = new Set(grades.map((g) => g.id)) + const selected = gradeId && gradeId !== "all" && allowedIds.has(gradeId) ? gradeId : "" + + const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null + + if (grades.length === 0) { + return ( +
+
+

Grade Insights

+

View grade-level homework statistics for grades you lead.

+
+ +
+ ) + } + + return ( +
+
+

Grade Insights

+

Homework statistics aggregated across all classes in a grade.

+
+ + + + Filters + + {grades.length} + + + +
+ + + +
+
+
+ + {!selected ? ( + + ) : !insights ? ( + + ) : insights.assignments.length === 0 ? ( + + ) : ( +
+
+ + + Classes + + +
{insights.classCount}
+
+ {insights.grade.school.name} / {insights.grade.name} +
+
+
+ + + Students + + +
{insights.studentCounts.total}
+
+ Active {insights.studentCounts.active} • Inactive {insights.studentCounts.inactive} +
+
+
+ + + Overall Avg + + +
{fmt(insights.overallScores.avg)}
+
Across graded homework
+
+
+ + + Latest Avg + + +
{fmt(insights.latest?.scoreStats.avg ?? null)}
+
{insights.latest ? insights.latest.title : "-"}
+
+
+
+ + + + Homework timeline + + {insights.assignments.length} + + + +
+ + + + Assignment + Status + Created + Targeted + Submitted + Graded + Avg + Median + + + + {insights.assignments.map((a) => ( + + {a.title} + + + {a.status} + + + {formatDate(a.createdAt)} + {a.targetCount} + {a.submittedCount} + {a.gradedCount} + {fmt(a.scoreStats.avg)} + {fmt(a.scoreStats.median)} + + ))} + +
+
+
+
+ + + + Class ranking + + {insights.classes.length} + + + +
+ + + + Class + Students + Latest Avg + Prev Avg + Δ + Overall Avg + + + + {insights.classes.map((c) => ( + + + {c.class.name} + {c.class.homeroom ? • {c.class.homeroom} : null} + + {c.studentCounts.total} + {fmt(c.latestAvg)} + {fmt(c.prevAvg)} + {fmt(c.deltaAvg)} + {fmt(c.overallScores.avg)} + + ))} + +
+
+
+
+
+ )} +
+ ) +} + diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx index 0441f24..fefaae0 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx @@ -1,6 +1,9 @@ import Link from "next/link" import { notFound } from "next/navigation" -import { getHomeworkAssignmentById } from "@/modules/homework/data-access" +import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access" +import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card" +import { HomeworkAssignmentQuestionErrorDetailsCard } from "@/modules/homework/components/homework-assignment-question-error-details-card" +import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -10,9 +13,11 @@ export const dynamic = "force-dynamic" export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params - const assignment = await getHomeworkAssignmentById(id) + const analytics = await getHomeworkAssignmentAnalytics(id) - if (!assignment) return notFound() + if (!analytics) return notFound() + + const { assignment, questions, gradedSampleCount } = analytics return (
@@ -69,12 +74,28 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}
- Late: {assignment.allowLate ? (assignment.lateDueAt ? formatDate(assignment.lateDueAt) : "Allowed") : "Not allowed"} + Late:{" "} + {assignment.allowLate + ? assignment.lateDueAt + ? formatDate(assignment.lateDueAt) + : "Allowed" + : "Not allowed"}
+ +
+ + +
+ +
) } diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx index bbfdc86..d6d91e8 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx @@ -28,10 +28,22 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa

Submissions

{assignment.title}

+
+ Exam: {assignment.sourceExamTitle} + + Targets: {assignment.targetCount} + + Submitted: {assignment.submittedCount} + + Graded: {assignment.gradedCount} +
+
diff --git a/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx index c5f6294..cc5f5bf 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx @@ -1,12 +1,13 @@ import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-assignment-form" import { getExams } from "@/modules/exams/data-access" +import { getTeacherClasses } from "@/modules/classes/data-access" import { EmptyState } from "@/shared/components/ui/empty-state" import { FileQuestion } from "lucide-react" export const dynamic = "force-dynamic" export default async function CreateHomeworkAssignmentPage() { - const exams = await getExams({}) + const [exams, classes] = await Promise.all([getExams({}), getTeacherClasses()]) const options = exams.map((e) => ({ id: e.id, title: e.title })) return ( @@ -25,8 +26,15 @@ export default async function CreateHomeworkAssignmentPage() { icon={FileQuestion} action={{ label: "Create Exam", href: "/teacher/exams/create" }} /> + ) : classes.length === 0 ? ( + ) : ( - + )}
) diff --git a/src/app/(dashboard)/teacher/homework/assignments/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/page.tsx index fc03035..66c5dbe 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/page.tsx @@ -12,13 +12,28 @@ import { } from "@/shared/components/ui/table" import { formatDate } from "@/shared/lib/utils" import { getHomeworkAssignments } from "@/modules/homework/data-access" +import { getTeacherClasses } from "@/modules/classes/data-access" import { PenTool, PlusCircle } from "lucide-react" export const dynamic = "force-dynamic" -export default async function AssignmentsPage() { - const assignments = await getHomeworkAssignments() +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 AssignmentsPage({ searchParams }: { searchParams: Promise }) { + const sp = await searchParams + const classId = getParam(sp, "classId") || undefined + + const [assignments, classes] = await Promise.all([ + getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }), + classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]), + ]) const hasAssignments = assignments.length > 0 + const className = classId && classId !== "all" ? classes.find((c) => c.id === classId)?.name : undefined return (
@@ -26,25 +41,41 @@ export default async function AssignmentsPage() {

Assignments

- Manage homework assignments. + {classId && classId !== "all" ? `Filtered by class: ${className ?? classId}` : "Manage homework assignments."}

- +
+ {classId && classId !== "all" ? ( + + ) : null} + +
{!hasAssignments ? ( ) : ( diff --git a/src/app/(dashboard)/teacher/homework/submissions/page.tsx b/src/app/(dashboard)/teacher/homework/submissions/page.tsx index c3a4fa2..7bb919b 100644 --- a/src/app/(dashboard)/teacher/homework/submissions/page.tsx +++ b/src/app/(dashboard)/teacher/homework/submissions/page.tsx @@ -10,14 +10,16 @@ import { TableRow, } from "@/shared/components/ui/table" import { formatDate } from "@/shared/lib/utils" -import { getHomeworkSubmissions } from "@/modules/homework/data-access" +import { getHomeworkAssignmentReviewList } from "@/modules/homework/data-access" import { Inbox } from "lucide-react" +import { getTeacherIdForMutations } from "@/modules/classes/data-access" export const dynamic = "force-dynamic" export default async function SubmissionsPage() { - const submissions = await getHomeworkSubmissions() - const hasSubmissions = submissions.length > 0 + const creatorId = await getTeacherIdForMutations() + const assignments = await getHomeworkAssignmentReviewList({ creatorId }) + const hasAssignments = assignments.length > 0 return (
@@ -25,15 +27,15 @@ export default async function SubmissionsPage() {

Submissions

- Review student homework submissions. + Review homework by assignment.

- {!hasSubmissions ? ( + {!hasAssignments ? ( ) : ( @@ -42,29 +44,31 @@ export default async function SubmissionsPage() { Assignment - Student Status - Submitted - Score + Due + Targets + Submitted + Graded - {submissions.map((s) => ( - + {assignments.map((a) => ( + - - {s.assignmentTitle} + + {a.title} +
{a.sourceExamTitle}
- {s.studentName} - {s.status} + {a.status} - {s.isLate ? Late : null} - {s.submittedAt ? formatDate(s.submittedAt) : "-"} - {typeof s.score === "number" ? s.score : "-"} + {a.dueAt ? formatDate(a.dueAt) : "-"} + {a.targetCount} + {a.submittedCount} + {a.gradedCount}
))}
diff --git a/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx b/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx index 90a987d..365e6c3 100644 --- a/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx +++ b/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx @@ -1,12 +1,14 @@ import { notFound } from "next/navigation"; -import { ArrowLeft, Edit } from "lucide-react"; +import { ArrowLeft } from "lucide-react"; import Link from "next/link"; import { Button } from "@/shared/components/ui/button"; import { Badge } from "@/shared/components/ui/badge"; -import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByChapterId } from "@/modules/textbooks/data-access"; +import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"; import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout"; import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"; +export const dynamic = "force-dynamic" + export default async function TextbookDetailPage({ params, }: { @@ -14,36 +16,16 @@ export default async function TextbookDetailPage({ }) { const { id } = await params; - const [textbook, chapters] = await Promise.all([ + const [textbook, chapters, knowledgePoints] = await Promise.all([ getTextbookById(id), getChaptersByTextbookId(id), + getKnowledgePointsByTextbookId(id), ]); if (!textbook) { notFound(); } - // Fetch all KPs for these chapters. In a real app, this might be optimized to fetch only needed or use a different query strategy. - // For now, we simulate fetching KPs for all chapters to pass down, or we could fetch on demand. - // Given the layout loads everything client-side for interactivity, let's fetch all KPs associated with any chapter in this textbook. - // We'll need to extend the data access for this specific query pattern or loop. - // For simplicity in this mock, let's assume getKnowledgePointsByChapterId can handle fetching all KPs for a textbook if we had such a function, - // or we iterate. Let's create a helper to get all KPs for the textbook's chapters. - - // Actually, let's update data-access to support getting KPs by Textbook ID directly or just fetch all for mock. - // Since we don't have getKnowledgePointsByTextbookId, we will map over chapters. - - const allKnowledgePoints = (await Promise.all( - chapters.map(c => getKnowledgePointsByChapterId(c.id)) - )).flat(); - - // Also need to get KPs for children chapters if any - const childrenKPs = (await Promise.all( - chapters.flatMap(c => c.children || []).map(child => getKnowledgePointsByChapterId(child.id)) - )).flat(); - - const knowledgePoints = [...allKnowledgePoints, ...childrenKPs]; - return (
{/* Header / Nav (Fixed height) */} diff --git a/src/app/(dashboard)/teacher/textbooks/page.tsx b/src/app/(dashboard)/teacher/textbooks/page.tsx index bd36688..86dd1c2 100644 --- a/src/app/(dashboard)/teacher/textbooks/page.tsx +++ b/src/app/(dashboard)/teacher/textbooks/page.tsx @@ -1,20 +1,53 @@ -import { Search, Filter } from "lucide-react"; -import { Button } from "@/shared/components/ui/button"; -import { Input } from "@/shared/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/components/ui/select"; +import { Suspense } from "react" +import { BookOpen } from "lucide-react" import { TextbookCard } from "@/modules/textbooks/components/textbook-card"; import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog"; import { getTextbooks } from "@/modules/textbooks/data-access"; +import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters" +import { EmptyState } from "@/shared/components/ui/empty-state" -export default async function TextbooksPage() { - // In a real app, we would parse searchParams here - const textbooks = await getTextbooks(); +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 +} + +async function TextbooksResults({ searchParams }: { searchParams: Promise }) { + const params = await searchParams + + const q = getParam(params, "q") || undefined + const subject = getParam(params, "subject") + const grade = getParam(params, "grade") + + const textbooks = await getTextbooks(q, subject || undefined, grade || undefined) + + const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all")) + + if (textbooks.length === 0) { + return ( + + ) + } + + return ( +
+ {textbooks.map((textbook) => ( + + ))} +
+ ) +} + +export default async function TextbooksPage({ searchParams }: { searchParams: Promise }) { return (
@@ -29,50 +62,13 @@ export default async function TextbooksPage() {
- {/* Toolbar */} -
-
- - -
-
- - - -
-
+ }> + + - {/* Grid Content */} -
- {textbooks.map((textbook) => ( - - ))} -
+ }> + +
); } diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..92a42d0 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,4 @@ +import { handlers } from "@/auth" + +export const { GET, POST } = handlers + diff --git a/src/app/globals.css b/src/app/globals.css index 69a102a..dee0cc6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @plugin "tailwindcss-animate"; +@plugin "@tailwindcss/typography"; @custom-variant dark (&:where(.dark, .dark *)); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c8cba9f..0cb0779 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { ThemeProvider } from "@/shared/components/theme-provider"; import { Toaster } from "@/shared/components/ui/sonner"; import { NuqsAdapter } from 'nuqs/adapters/next/app' +import { AuthSessionProvider } from "@/shared/components/auth-session-provider" import "./globals.css"; export const metadata: Metadata = { @@ -25,9 +26,11 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - - {children} - + + + {children} + + diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..c17568e --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,61 @@ +import NextAuth from "next-auth" +import Credentials from "next-auth/providers/credentials" + +export const { handlers, auth, signIn, signOut } = NextAuth({ + session: { strategy: "jwt" }, + pages: { signIn: "/login" }, + providers: [ + Credentials({ + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + authorize: async (credentials) => { + const email = String(credentials?.email ?? "").trim().toLowerCase() + const password = String(credentials?.password ?? "") + if (!email || !password) return null + + const [{ eq }, { db }, { users }] = await Promise.all([ + import("drizzle-orm"), + import("@/shared/db"), + import("@/shared/db/schema"), + ]) + + const user = await db.query.users.findFirst({ + where: eq(users.email, email), + }) + if (!user) return null + + const storedPassword = user.password ?? null + if (storedPassword) { + if (storedPassword !== password) return null + } else if (process.env.NODE_ENV === "production") { + return null + } + + return { + id: user.id, + name: user.name ?? undefined, + email: user.email, + role: (user.role ?? "student") as string, + } + }, + }), + ], + callbacks: { + jwt: async ({ token, user }) => { + if (user) { + token.id = (user as { id: string }).id + token.role = (user as { role?: string }).role ?? "student" + } + return token + }, + session: async ({ session, token }) => { + if (session.user) { + session.user.id = String(token.id ?? "") + session.user.role = String(token.role ?? "student") + } + return session + }, + }, +}) diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..55cd378 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server" +import type { NextAuthRequest } from "next-auth" + +import { auth } from "./auth" + +function roleHome(role: string) { + if (role === "admin") return "/admin/dashboard" + if (role === "student") return "/student/dashboard" + if (role === "parent") return "/parent/dashboard" + return "/teacher/dashboard" +} + +export default auth((req: NextAuthRequest) => { + const { pathname } = req.nextUrl + const session = req.auth + + if (!session?.user) { + const url = req.nextUrl.clone() + url.pathname = "/login" + url.searchParams.set("callbackUrl", pathname) + return NextResponse.redirect(url) + } + + const role = String(session.user.role ?? "teacher") + + if (pathname.startsWith("/admin/") && role !== "admin") { + return NextResponse.redirect(new URL(roleHome(role), req.url)) + } + if (pathname.startsWith("/teacher/") && role !== "teacher") { + return NextResponse.redirect(new URL(roleHome(role), req.url)) + } + if (pathname.startsWith("/student/") && role !== "student") { + return NextResponse.redirect(new URL(roleHome(role), req.url)) + } + if (pathname.startsWith("/parent/") && role !== "parent") { + return NextResponse.redirect(new URL(roleHome(role), req.url)) + } + + return NextResponse.next() +}) + +export const config = { + matcher: ["/dashboard", "/admin/:path*", "/teacher/:path*", "/student/:path*", "/parent/:path*", "/settings/:path*", "/profile"], +} diff --git a/src/modules/auth/components/auth-layout.tsx b/src/modules/auth/components/auth-layout.tsx index 8f75823..e31fc47 100644 --- a/src/modules/auth/components/auth-layout.tsx +++ b/src/modules/auth/components/auth-layout.tsx @@ -1,4 +1,3 @@ -import Link from "next/link" import { GraduationCap } from "lucide-react" interface AuthLayoutProps { diff --git a/src/modules/auth/components/login-form.tsx b/src/modules/auth/components/login-form.tsx index c50aab9..a9a9c6f 100644 --- a/src/modules/auth/components/login-form.tsx +++ b/src/modules/auth/components/login-form.tsx @@ -2,6 +2,8 @@ import * as React from "react" import Link from "next/link" +import { useRouter, useSearchParams } from "next/navigation" +import { signIn } from "next-auth/react" import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" @@ -12,14 +14,32 @@ type LoginFormProps = React.HTMLAttributes export function LoginForm({ className, ...props }: LoginFormProps) { const [isLoading, setIsLoading] = React.useState(false) + const router = useRouter() + const searchParams = useSearchParams() async function onSubmit(event: React.SyntheticEvent) { event.preventDefault() setIsLoading(true) - setTimeout(() => { - setIsLoading(false) - }, 3000) + const form = event.currentTarget as HTMLFormElement + const formData = new FormData(form) + const email = String(formData.get("email") ?? "") + const password = String(formData.get("password") ?? "") + const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard" + + const result = await signIn("credentials", { + redirect: false, + email, + password, + callbackUrl, + }) + + setIsLoading(false) + + if (!result?.error) { + router.push(result?.url ?? callbackUrl) + router.refresh() + } } return ( @@ -38,6 +58,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) { DEFAULT_CLASS_SUBJECTS.includes(v as ClassSubject) + +export async function createTeacherClassAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + const schoolName = formData.get("schoolName") + const schoolId = formData.get("schoolId") + const name = formData.get("name") + const grade = formData.get("grade") + const gradeId = formData.get("gradeId") + const homeroom = formData.get("homeroom") + const room = formData.get("room") + + if (typeof name !== "string" || name.trim().length === 0) { + return { success: false, message: "Class name is required" } + } + if (typeof grade !== "string" || grade.trim().length === 0) { + return { success: false, message: "Grade is required" } + } + + try { + const id = await createTeacherClass({ + schoolName: typeof schoolName === "string" ? schoolName : null, + schoolId: typeof schoolId === "string" ? schoolId : null, + name, + grade, + gradeId: typeof gradeId === "string" ? gradeId : null, + homeroom: typeof homeroom === "string" ? homeroom : null, + room: typeof room === "string" ? room : null, + }) + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class created successfully", data: id } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to create class" } + } +} + +export async function updateTeacherClassAction( + classId: string, + prevState: ActionState | null, + formData: FormData +): Promise { + const schoolName = formData.get("schoolName") + const schoolId = formData.get("schoolId") + const name = formData.get("name") + const grade = formData.get("grade") + const gradeId = formData.get("gradeId") + const homeroom = formData.get("homeroom") + const room = formData.get("room") + + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Missing class id" } + } + + try { + await updateTeacherClass(classId, { + schoolName: typeof schoolName === "string" ? schoolName : undefined, + schoolId: typeof schoolId === "string" ? schoolId : undefined, + name: typeof name === "string" ? name : undefined, + grade: typeof grade === "string" ? grade : undefined, + gradeId: typeof gradeId === "string" ? gradeId : undefined, + homeroom: typeof homeroom === "string" ? homeroom : undefined, + room: typeof room === "string" ? room : undefined, + }) + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class updated successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to update class" } + } +} + +export async function deleteTeacherClassAction(classId: string): Promise { + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Missing class id" } + } + + try { + await deleteTeacherClass(classId) + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class deleted successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" } + } +} + +export async function enrollStudentByEmailAction( + classId: string, + prevState: ActionState | null, + formData: FormData +): Promise { + const email = formData.get("email") + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Please select a class" } + } + if (typeof email !== "string" || email.trim().length === 0) { + return { success: false, message: "Student email is required" } + } + + try { + await enrollStudentByEmail(classId, email) + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/my") + return { success: true, message: "Student added successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to add student" } + } +} + +export async function joinClassByInvitationCodeAction( + prevState: ActionState<{ classId: string }> | null, + formData: FormData +): Promise> { + const code = formData.get("code") + if (typeof code !== "string" || code.trim().length === 0) { + return { success: false, message: "Invitation code is required" } + } + + const session = await auth() + if (!session?.user?.id || String(session.user.role ?? "") !== "student") { + return { success: false, message: "Unauthorized" } + } + + try { + const classId = await enrollStudentByInvitationCode(session.user.id, code) + revalidatePath("/student/learning/courses") + revalidatePath("/student/schedule") + revalidatePath("/profile") + return { success: true, message: "Joined class successfully", data: { classId } } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to join class" } + } +} + +export async function ensureClassInvitationCodeAction(classId: string): Promise> { + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Missing class id" } + } + + try { + const code = await ensureClassInvitationCode(classId) + revalidatePath("/teacher/classes/my") + revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`) + return { success: true, message: "Invitation code ready", data: { code } } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" } + } +} + +export async function regenerateClassInvitationCodeAction(classId: string): Promise> { + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Missing class id" } + } + + try { + const code = await regenerateClassInvitationCode(classId) + revalidatePath("/teacher/classes/my") + revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`) + return { success: true, message: "Invitation code updated", data: { code } } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" } + } +} + +export async function setStudentEnrollmentStatusAction( + classId: string, + studentId: string, + status: "active" | "inactive" +): Promise { + if (!classId?.trim() || !studentId?.trim()) { + return { success: false, message: "Missing enrollment info" } + } + + try { + await setStudentEnrollmentStatus(classId, studentId, status) + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/my") + return { success: true, message: "Student updated successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to update student" } + } +} + +export async function createClassScheduleItemAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + const classId = formData.get("classId") + const weekday = formData.get("weekday") + const startTime = formData.get("startTime") + const endTime = formData.get("endTime") + const course = formData.get("course") + const location = formData.get("location") + + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Please select a class" } + } + if (typeof weekday !== "string" || weekday.trim().length === 0) { + return { success: false, message: "Weekday is required" } + } + const weekdayNum = Number(weekday) + if (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7) { + return { success: false, message: "Invalid weekday" } + } + if (typeof course !== "string" || course.trim().length === 0) { + return { success: false, message: "Course is required" } + } + if (typeof startTime !== "string" || typeof endTime !== "string") { + return { success: false, message: "Time is required" } + } + + try { + const id = await createClassScheduleItem({ + classId, + weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7, + startTime, + endTime, + course, + location: typeof location === "string" ? location : null, + }) + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Schedule item created successfully", data: id } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to create schedule item" } + } +} + +export async function updateClassScheduleItemAction( + scheduleId: string, + prevState: ActionState | null, + formData: FormData +): Promise { + const classId = formData.get("classId") + const weekday = formData.get("weekday") + const startTime = formData.get("startTime") + const endTime = formData.get("endTime") + const course = formData.get("course") + const location = formData.get("location") + + if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) { + return { success: false, message: "Missing schedule id" } + } + + const weekdayNum = typeof weekday === "string" && weekday.trim().length > 0 ? Number(weekday) : undefined + if (weekdayNum !== undefined && (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7)) { + return { success: false, message: "Invalid weekday" } + } + + try { + await updateClassScheduleItem(scheduleId, { + classId: typeof classId === "string" ? classId : undefined, + weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined, + startTime: typeof startTime === "string" ? startTime : undefined, + endTime: typeof endTime === "string" ? endTime : undefined, + course: typeof course === "string" ? course : undefined, + location: typeof location === "string" ? location : undefined, + }) + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Schedule item updated successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to update schedule item" } + } +} + +export async function deleteClassScheduleItemAction(scheduleId: string): Promise { + if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) { + return { success: false, message: "Missing schedule id" } + } + + try { + await deleteClassScheduleItem(scheduleId) + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Schedule item deleted successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to delete schedule item" } + } +} + +export async function createAdminClassAction( + prevState: ActionState | undefined, + formData: FormData +): Promise> { + const schoolName = formData.get("schoolName") + const schoolId = formData.get("schoolId") + const name = formData.get("name") + const grade = formData.get("grade") + const gradeId = formData.get("gradeId") + const teacherId = formData.get("teacherId") + const homeroom = formData.get("homeroom") + const room = formData.get("room") + + if (typeof name !== "string" || name.trim().length === 0) { + return { success: false, message: "Class name is required" } + } + if (typeof grade !== "string" || grade.trim().length === 0) { + return { success: false, message: "Grade is required" } + } + if (typeof teacherId !== "string" || teacherId.trim().length === 0) { + return { success: false, message: "Teacher is required" } + } + + try { + const id = await createAdminClass({ + schoolName: typeof schoolName === "string" ? schoolName : null, + schoolId: typeof schoolId === "string" ? schoolId : null, + name, + grade, + gradeId: typeof gradeId === "string" ? gradeId : null, + teacherId, + homeroom: typeof homeroom === "string" ? homeroom : null, + room: typeof room === "string" ? room : null, + }) + revalidatePath("/admin/school/classes") + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class created successfully", data: id } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to create class" } + } +} + +export async function updateAdminClassAction( + classId: string, + prevState: ActionState | undefined, + formData: FormData +): Promise { + const schoolName = formData.get("schoolName") + const schoolId = formData.get("schoolId") + const name = formData.get("name") + const grade = formData.get("grade") + const gradeId = formData.get("gradeId") + const teacherId = formData.get("teacherId") + const homeroom = formData.get("homeroom") + const room = formData.get("room") + const subjectTeachers = formData.get("subjectTeachers") + + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Missing class id" } + } + + try { + await updateAdminClass(classId, { + schoolName: typeof schoolName === "string" ? schoolName : undefined, + schoolId: typeof schoolId === "string" ? schoolId : undefined, + name: typeof name === "string" ? name : undefined, + grade: typeof grade === "string" ? grade : undefined, + gradeId: typeof gradeId === "string" ? gradeId : undefined, + teacherId: typeof teacherId === "string" ? teacherId : undefined, + homeroom: typeof homeroom === "string" ? homeroom : undefined, + room: typeof room === "string" ? room : undefined, + }) + + if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) { + const parsed = JSON.parse(subjectTeachers) as unknown + if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers") + + await setClassSubjectTeachers({ + classId, + assignments: parsed.flatMap((item) => { + if (!item || typeof item !== "object") return [] + const subject = (item as { subject?: unknown }).subject + const teacherId = (item as { teacherId?: unknown }).teacherId + + if (typeof subject !== "string" || !isClassSubject(subject)) return [] + + if (teacherId === null || typeof teacherId === "undefined") { + return [{ subject, teacherId: null }] + } + + if (typeof teacherId !== "string") return [] + const trimmed = teacherId.trim() + return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }] + }), + }) + } + + revalidatePath("/admin/school/classes") + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class updated successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to update class" } + } +} + +export async function deleteAdminClassAction(classId: string): Promise { + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Missing class id" } + } + + try { + await deleteAdminClass(classId) + revalidatePath("/admin/school/classes") + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class deleted successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" } + } +} diff --git a/src/modules/classes/components/admin-classes-view.tsx b/src/modules/classes/components/admin-classes-view.tsx new file mode 100644 index 0000000..59442de --- /dev/null +++ b/src/modules/classes/components/admin-classes-view.tsx @@ -0,0 +1,434 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" + +import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types" +import { DEFAULT_CLASS_SUBJECTS } from "../types" +import { createAdminClassAction, deleteAdminClassAction, updateAdminClassAction } from "../actions" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Badge } from "@/shared/components/ui/badge" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" +import { formatDate } from "@/shared/lib/utils" + +export function AdminClassesClient({ + classes, + teachers, +}: { + classes: AdminClassListItem[] + teachers: TeacherOption[] +}) { + const router = useRouter() + const [isWorking, setIsWorking] = useState(false) + const [createOpen, setCreateOpen] = useState(false) + const [editItem, setEditItem] = useState(null) + const [deleteItem, setDeleteItem] = useState(null) + + const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers]) + const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId) + const [editTeacherId, setEditTeacherId] = useState("") + const [editSubjectTeachers, setEditSubjectTeachers] = useState>([]) + + useEffect(() => { + if (!createOpen) return + setCreateTeacherId(defaultTeacherId) + }, [createOpen, defaultTeacherId]) + + useEffect(() => { + if (!editItem) return + setEditTeacherId(editItem.teacher.id) + setEditSubjectTeachers( + DEFAULT_CLASS_SUBJECTS.map((s) => ({ + subject: s, + teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null, + })) + ) + }, [editItem]) + + const handleCreate = async (formData: FormData) => { + setIsWorking(true) + try { + const res = await createAdminClassAction(undefined, formData) + if (res.success) { + toast.success(res.message) + setCreateOpen(false) + router.refresh() + } else { + toast.error(res.message || "Failed to create class") + } + } catch { + toast.error("Failed to create class") + } finally { + setIsWorking(false) + } + } + + const handleUpdate = async (formData: FormData) => { + if (!editItem) return + setIsWorking(true) + try { + const res = await updateAdminClassAction(editItem.id, undefined, formData) + if (res.success) { + toast.success(res.message) + setEditItem(null) + router.refresh() + } else { + toast.error(res.message || "Failed to update class") + } + } catch { + toast.error("Failed to update class") + } finally { + setIsWorking(false) + } + } + + const handleDelete = async () => { + if (!deleteItem) return + setIsWorking(true) + try { + const res = await deleteAdminClassAction(deleteItem.id) + if (res.success) { + toast.success(res.message) + setDeleteItem(null) + router.refresh() + } else { + toast.error(res.message || "Failed to delete class") + } + } catch { + toast.error("Failed to delete class") + } finally { + setIsWorking(false) + } + } + + const setSubjectTeacher = (subject: string, teacherId: string | null) => { + setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p))) + } + + const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => { + const pairs = list + .filter((x) => x.teacher) + .map((x) => `${x.subject}:${x.teacher?.name ?? ""}`) + .filter((x) => x.length > 0) + return pairs.length > 0 ? pairs.join(",") : "-" + } + + return ( + <> +
+ +
+ + + + All classes + + {classes.length} + + + + {classes.length === 0 ? ( + + ) : ( + + + + School + Name + Grade + Homeroom + Room + 班主任 + 任课老师 + Students + Updated + + + + + {classes.map((c) => ( + + {c.schoolName ?? "-"} + {c.name} + {c.grade} + {c.homeroom ?? "-"} + {c.room ?? "-"} + {c.teacher.name} + {formatSubjectTeachers(c.subjectTeachers)} + {c.studentCount} + {formatDate(c.updatedAt)} + + + + + + + setEditItem(c)}> + + Edit + + + setDeleteItem(c)} + > + + Delete + + + + + + ))} + +
+ )} +
+
+ + + + + New class + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + + + + +
+
+
+ + { + if (isWorking) return + if (!open) setEditItem(null) + }} + > + + + Edit class + + {editItem ? ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+
任课老师
+
+ {DEFAULT_CLASS_SUBJECTS.map((subject) => { + const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null + return ( +
+ +
+ +
+
+ ) + })} +
+ +
+ + + + + +
+ ) : null} +
+
+ + { + if (!open) setDeleteItem(null) + }} + > + + + Delete class + This will permanently delete {deleteItem?.name || "this class"}. + + + Cancel + + Delete + + + + + + ) +} diff --git a/src/modules/classes/components/insights-filters.tsx b/src/modules/classes/components/insights-filters.tsx new file mode 100644 index 0000000..d0bf9b9 --- /dev/null +++ b/src/modules/classes/components/insights-filters.tsx @@ -0,0 +1,40 @@ +"use client" + +import { useQueryState, parseAsString } from "nuqs" +import { X } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" +import type { TeacherClass } from "../types" + +export function InsightsFilters({ classes }: { classes: TeacherClass[] }) { + const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all")) + + return ( +
+
+ + + {classId !== "all" && ( + + )} +
+
+ ) +} + diff --git a/src/modules/classes/components/my-classes-grid.tsx b/src/modules/classes/components/my-classes-grid.tsx new file mode 100644 index 0000000..93243c8 --- /dev/null +++ b/src/modules/classes/components/my-classes-grid.tsx @@ -0,0 +1,533 @@ +"use client" + +import Link from "next/link" +import { useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { Calendar, Copy, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Users } from "lucide-react" +import { toast } from "sonner" +import { parseAsString, useQueryState } from "nuqs" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Button } from "@/shared/components/ui/button" +import { Badge } from "@/shared/components/ui/badge" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { cn } from "@/shared/lib/utils" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shared/components/ui/dialog" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" +import type { TeacherClass } from "../types" +import { + createTeacherClassAction, + deleteTeacherClassAction, + ensureClassInvitationCodeAction, + regenerateClassInvitationCodeAction, + updateTeacherClassAction, +} from "../actions" + +export function MyClassesGrid({ classes }: { classes: TeacherClass[] }) { + const router = useRouter() + const [isWorking, setIsWorking] = useState(false) + const [createOpen, setCreateOpen] = useState(false) + + const [q, setQ] = useQueryState("q", parseAsString.withDefault("")) + const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all")) + + const gradeOptions = useMemo(() => { + const set = new Set() + for (const c of classes) set.add(c.grade) + return Array.from(set).sort((a, b) => a.localeCompare(b)) + }, [classes]) + + const filteredClasses = useMemo(() => { + const needle = q.trim().toLowerCase() + return classes.filter((c) => { + const gradeOk = grade === "all" ? true : c.grade === grade + const qOk = needle.length === 0 ? true : c.name.toLowerCase().includes(needle) + return gradeOk && qOk + }) + }, [classes, grade, q]) + + const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade]) + + const handleCreate = async (formData: FormData) => { + setIsWorking(true) + try { + const res = await createTeacherClassAction(null, formData) + if (res.success) { + toast.success(res.message) + setCreateOpen(false) + router.refresh() + } else { + toast.error(res.message || "Failed to create class") + } + } catch { + toast.error("Failed to create class") + } finally { + setIsWorking(false) + } + } + + return ( +
+
+
+
+ setQ(e.target.value || null)} + /> +
+ + {(q || grade !== "all") && ( + + )} +
+ { + if (isWorking) return + setCreateOpen(open) + }} + > + + + + + + Create class + Add a new class to start managing students. + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+
+ +
+ {classes.length === 0 ? ( + setCreateOpen(true) }} + className="h-[360px] bg-card sm:col-span-2 lg:col-span-3" + /> + ) : filteredClasses.length === 0 ? ( + { + setQ(null) + setGrade(null) + }}} + className="h-[360px] bg-card sm:col-span-2 lg:col-span-3" + /> + ) : ( + filteredClasses.map((c) => ( + + )) + )} +
+
+ ) +} + +function ClassCard({ + c, + isWorking, + onWorkingChange, +}: { + c: TeacherClass + isWorking: boolean + onWorkingChange: (v: boolean) => void +}) { + const router = useRouter() + const [showEdit, setShowEdit] = useState(false) + const [showDelete, setShowDelete] = useState(false) + + const handleEnsureCode = async () => { + onWorkingChange(true) + try { + const res = await ensureClassInvitationCodeAction(c.id) + if (res.success) { + toast.success(res.message || "Invitation code ready") + router.refresh() + } else { + toast.error(res.message || "Failed to generate invitation code") + } + } catch { + toast.error("Failed to generate invitation code") + } finally { + onWorkingChange(false) + } + } + + const handleRegenerateCode = async () => { + onWorkingChange(true) + try { + const res = await regenerateClassInvitationCodeAction(c.id) + if (res.success) { + toast.success(res.message || "Invitation code updated") + router.refresh() + } else { + toast.error(res.message || "Failed to regenerate invitation code") + } + } catch { + toast.error("Failed to regenerate invitation code") + } finally { + onWorkingChange(false) + } + } + + const handleCopyCode = async () => { + const code = c.invitationCode ?? "" + if (!code) return + try { + await navigator.clipboard.writeText(code) + toast.success("Copied invitation code") + } catch { + toast.error("Failed to copy") + } + } + + const handleEdit = async (formData: FormData) => { + onWorkingChange(true) + try { + const res = await updateTeacherClassAction(c.id, null, formData) + if (res.success) { + toast.success(res.message) + setShowEdit(false) + router.refresh() + } else { + toast.error(res.message || "Failed to update class") + } + } catch { + toast.error("Failed to update class") + } finally { + onWorkingChange(false) + } + } + + const handleDelete = async () => { + onWorkingChange(true) + try { + const res = await deleteTeacherClassAction(c.id) + if (res.success) { + toast.success(res.message) + setShowDelete(false) + router.refresh() + } else { + toast.error(res.message || "Failed to delete class") + } + } catch { + toast.error("Failed to delete class") + } finally { + onWorkingChange(false) + } + } + + return ( + + +
+
+ + + {c.name} + + +
+ {c.room ? `Room: ${c.room}` : "Room: Not set"} +
+
+ +
+ {c.grade} + + + + + + setShowEdit(true)}> + + Edit + + + setShowDelete(true)} + > + + Delete + + + +
+
+
+ + +
+
{c.studentCount} students
+ {c.homeroom ? {c.homeroom} : null} +
+
+
+
Invitation code
+
{c.invitationCode ?? "-"}
+
+
+ {c.invitationCode ? ( + <> + + + + ) : ( + + )} +
+
+
+ + +
+
+ + { + if (isWorking) return + setShowEdit(open) + }} + > + + + Edit class + Update basic class information. + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + { + if (isWorking) return + setShowDelete(open) + }} + > + + + Delete class? + + This will permanently delete {c.name} and remove all + enrollments. + + + + Cancel + + {isWorking ? "Deleting..." : "Delete"} + + + + +
+ ) +} diff --git a/src/modules/classes/components/schedule-filters.tsx b/src/modules/classes/components/schedule-filters.tsx new file mode 100644 index 0000000..0c92818 --- /dev/null +++ b/src/modules/classes/components/schedule-filters.tsx @@ -0,0 +1,195 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { useQueryState, parseAsString } from "nuqs" +import { Plus, X } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/shared/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shared/components/ui/dialog" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import type { TeacherClass } from "../types" +import { createClassScheduleItemAction } from "../actions" + +export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) { + const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all")) + + const router = useRouter() + const [open, setOpen] = useState(false) + const [isWorking, setIsWorking] = useState(false) + + const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes]) + const [createClassId, setCreateClassId] = useState(defaultClassId) + const [weekday, setWeekday] = useState("1") + + useEffect(() => { + if (!open) return + setCreateClassId(defaultClassId) + setWeekday("1") + }, [open, defaultClassId]) + + const handleCreate = async (formData: FormData) => { + setIsWorking(true) + try { + formData.set("classId", createClassId) + const res = await createClassScheduleItemAction(null, formData) + if (res.success) { + toast.success(res.message) + setOpen(false) + router.refresh() + } else { + toast.error(res.message || "Failed to create schedule item") + } + } catch { + toast.error("Failed to create schedule item") + } finally { + setIsWorking(false) + } + } + + return ( +
+
+ + + {classId !== "all" && ( + + )} +
+ + { + if (isWorking) return + setOpen(v) + }} + > + + + + + + Add schedule item + Create a class schedule entry. + +
+
+
+ +
+ +
+
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+
+
+
+ ) +} diff --git a/src/modules/classes/components/schedule-view.tsx b/src/modules/classes/components/schedule-view.tsx new file mode 100644 index 0000000..6c5de77 --- /dev/null +++ b/src/modules/classes/components/schedule-view.tsx @@ -0,0 +1,465 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { Clock, MapPin, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" +import { toast } from "sonner" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { cn } from "@/shared/lib/utils" +import { Button } from "@/shared/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" +import type { ClassScheduleItem, TeacherClass } from "../types" +import { + createClassScheduleItemAction, + deleteClassScheduleItemAction, + updateClassScheduleItemAction, +} from "../actions" + +const WEEKDAYS: Array<{ key: ClassScheduleItem["weekday"]; label: string }> = [ + { key: 1, label: "Mon" }, + { key: 2, label: "Tue" }, + { key: 3, label: "Wed" }, + { key: 4, label: "Thu" }, + { key: 5, label: "Fri" }, + { key: 6, label: "Sat" }, + { key: 7, label: "Sun" }, +] + +export function ScheduleView({ + schedule, + classes, +}: { + schedule: ClassScheduleItem[] + classes: TeacherClass[] +}) { + const router = useRouter() + const [isWorking, setIsWorking] = useState(false) + const [editItem, setEditItem] = useState(null) + const [deleteItem, setDeleteItem] = useState(null) + + const [createWeekday, setCreateWeekday] = useState(1) + const [createOpen, setCreateOpen] = useState(false) + const [createClassId, setCreateClassId] = useState("") + + const [editClassId, setEditClassId] = useState("") + const [editWeekday, setEditWeekday] = useState("1") + + const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes]) + const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes]) + + useEffect(() => { + if (!editItem) return + setEditClassId(editItem.classId) + setEditWeekday(String(editItem.weekday)) + }, [editItem]) + + useEffect(() => { + if (!createOpen) return + setCreateClassId(defaultClassId) + }, [createOpen, defaultClassId]) + + const byDay = new Map() + for (const d of WEEKDAYS) byDay.set(d.key, []) + for (const item of schedule) byDay.get(item.weekday)?.push(item) + + const handleCreate = async (formData: FormData) => { + setIsWorking(true) + try { + formData.set("classId", createClassId || defaultClassId) + formData.set("weekday", String(createWeekday)) + const res = await createClassScheduleItemAction(null, formData) + if (res.success) { + toast.success(res.message) + setCreateOpen(false) + router.refresh() + } else { + toast.error(res.message || "Failed to create schedule item") + } + } catch { + toast.error("Failed to create schedule item") + } finally { + setIsWorking(false) + } + } + + const handleUpdate = async (formData: FormData) => { + if (!editItem) return + setIsWorking(true) + try { + formData.set("classId", editClassId) + formData.set("weekday", editWeekday) + const res = await updateClassScheduleItemAction(editItem.id, null, formData) + if (res.success) { + toast.success(res.message) + setEditItem(null) + router.refresh() + } else { + toast.error(res.message || "Failed to update schedule item") + } + } catch { + toast.error("Failed to update schedule item") + } finally { + setIsWorking(false) + } + } + + const handleDelete = async () => { + if (!deleteItem) return + setIsWorking(true) + try { + const res = await deleteClassScheduleItemAction(deleteItem.id) + if (res.success) { + toast.success(res.message) + setDeleteItem(null) + router.refresh() + } else { + toast.error(res.message || "Failed to delete schedule item") + } + } catch { + toast.error("Failed to delete schedule item") + } finally { + setIsWorking(false) + } + } + + return ( +
+ {WEEKDAYS.map((d) => { + const items = byDay.get(d.key) ?? [] + return ( + + +
+ {d.label} + + {items.length} items + +
+ +
+ + {items.length === 0 ? ( +
No classes scheduled.
+ ) : ( +
+ {items.map((item) => ( +
+
+
+
{item.course}
+
+
+ {classNameById.get(item.classId) ?? "Class"} + + + + + + setEditItem(item)}> + + Edit + + + setDeleteItem(item)} + > + + Delete + + + +
+
+
+ + + {item.startTime}–{item.endTime} + + {item.location ? ( + + + {item.location} + + ) : null} +
+
+ ))} +
+ )} +
+
+ ) + })} + + { + if (isWorking) return + setCreateOpen(v) + }} + > + + + Add schedule item + Create a class schedule entry. + +
+
+
+ +
+ + +
+
+ +
+ + w.key === createWeekday)?.label ?? ""} readOnly className="col-span-3" /> + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+
+
+ + { + if (isWorking) return + if (!v) setEditItem(null) + }} + > + + + Edit schedule item + Update this schedule entry. + + {editItem ? ( +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+ ) : null} +
+
+ + { + if (isWorking) return + if (!v) setDeleteItem(null) + }} + > + + + Delete schedule item? + + {deleteItem ? ( + <> + This will permanently delete {deleteItem.course}{" "} + ({deleteItem.startTime}–{deleteItem.endTime}). + + ) : null} + + + + Cancel + + {isWorking ? "Deleting..." : "Delete"} + + + + +
+ ) +} + diff --git a/src/modules/classes/components/students-filters.tsx b/src/modules/classes/components/students-filters.tsx new file mode 100644 index 0000000..787ed1d --- /dev/null +++ b/src/modules/classes/components/students-filters.tsx @@ -0,0 +1,169 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { useQueryState, parseAsString } from "nuqs" +import { Search, UserPlus, X } from "lucide-react" +import { toast } from "sonner" + +import { Input } from "@/shared/components/ui/input" +import { Button } from "@/shared/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shared/components/ui/dialog" +import { Label } from "@/shared/components/ui/label" +import type { TeacherClass } from "../types" +import { enrollStudentByEmailAction } from "../actions" + +export function StudentsFilters({ classes }: { classes: TeacherClass[] }) { + const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) + const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all")) + + const router = useRouter() + const [open, setOpen] = useState(false) + const [isWorking, setIsWorking] = useState(false) + + const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes]) + const [enrollClassId, setEnrollClassId] = useState(defaultClassId) + + useEffect(() => { + if (!open) return + setEnrollClassId(defaultClassId) + }, [open, defaultClassId]) + + const handleEnroll = async (formData: FormData) => { + setIsWorking(true) + try { + const res = await enrollStudentByEmailAction(enrollClassId, null, formData) + if (res.success) { + toast.success(res.message) + setOpen(false) + router.refresh() + } else { + toast.error(res.message || "Failed to add student") + } + } catch { + toast.error("Failed to add student") + } finally { + setIsWorking(false) + } + } + + return ( +
+
+
+ + setSearch(e.target.value || null)} + /> +
+ + + + {(search || classId !== "all") && ( + + )} +
+ + { + if (isWorking) return + setOpen(v) + }} + > + + + + + + Add student + Enroll a student by email to a class. + +
+
+
+ +
+ +
+
+
+ + +
+
+ + + +
+
+
+
+ ) +} + diff --git a/src/modules/classes/components/students-table.tsx b/src/modules/classes/components/students-table.tsx new file mode 100644 index 0000000..80c9b11 --- /dev/null +++ b/src/modules/classes/components/students-table.tsx @@ -0,0 +1,158 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { MoreHorizontal, UserCheck, UserX } from "lucide-react" +import { toast } from "sonner" + +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { cn } from "@/shared/lib/utils" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" +import type { ClassStudent } from "../types" +import { setStudentEnrollmentStatusAction } from "../actions" + +export function StudentsTable({ students }: { students: ClassStudent[] }) { + const router = useRouter() + const [workingKey, setWorkingKey] = useState(null) + const [removeTarget, setRemoveTarget] = useState(null) + + const setStatus = async (student: ClassStudent, status: "active" | "inactive") => { + const key = `${student.classId}:${student.id}:${status}` + setWorkingKey(key) + try { + const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status) + if (res.success) { + toast.success(res.message) + router.refresh() + } else { + toast.error(res.message || "Failed to update student") + } + } catch { + toast.error("Failed to update student") + } finally { + setWorkingKey(null) + } + } + + return ( + <> + + + + Student + Email + Class + Status + Actions + + + + {students.map((s) => ( + + {s.name} + {s.email} + {s.className} + + + {s.status === "active" ? "Active" : "Inactive"} + + + + + + + + + {s.status !== "active" ? ( + setStatus(s, "active")} disabled={workingKey !== null}> + + Set active + + ) : ( + setStatus(s, "inactive")} disabled={workingKey !== null}> + + Set inactive + + )} + + setRemoveTarget(s)} + className="text-destructive focus:text-destructive" + disabled={s.status === "inactive" || workingKey !== null} + > + + Remove from class + + + + + + ))} + +
+ + { + if (workingKey !== null) return + if (!open) setRemoveTarget(null) + }} + > + + + Remove student from class? + + {removeTarget ? ( + <> + This will set {removeTarget.name} to inactive in{" "} + {removeTarget.className}. + + ) : null} + + + + Cancel + { + if (!removeTarget) return + setRemoveTarget(null) + setStatus(removeTarget, "inactive") + }} + > + Remove + + + + + + ) +} diff --git a/src/modules/classes/data-access.ts b/src/modules/classes/data-access.ts new file mode 100644 index 0000000..9bb4f3b --- /dev/null +++ b/src/modules/classes/data-access.ts @@ -0,0 +1,1541 @@ +import "server-only"; + +import { randomInt } from "node:crypto" +import { cache } from "react" +import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm" +import { createId } from "@paralleldrive/cuid2" + +import { db } from "@/shared/db" +import { + classes, + classEnrollments, + classSchedule, + classSubjectTeachers, + grades, + homeworkAssignmentQuestions, + homeworkAssignmentTargets, + homeworkAssignments, + homeworkSubmissions, + schools, + users, +} from "@/shared/db/schema" +import { DEFAULT_CLASS_SUBJECTS } from "./types" +import type { + AdminClassListItem, + ClassScheduleItem, + ClassStudent, + ClassHomeworkInsights, + ClassHomeworkAssignmentStats, + ClassSubject, + ClassSubjectTeacherAssignment, + GradeHomeworkClassSummary, + GradeHomeworkInsights, + ScoreStats, + CreateClassScheduleItemInput, + CreateTeacherClassInput, + StudentEnrolledClass, + StudentScheduleItem, + TeacherOption, + TeacherClass, + UpdateClassScheduleItemInput, + UpdateTeacherClassInput, +} from "./types" + +const getDefaultTeacherId = cache(async () => { + const [row] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.role, "teacher")) + .orderBy(asc(users.createdAt)) + .limit(1) + + return row?.id +}) + +const isDuplicateInvitationCodeError = (err: unknown) => { + if (!err) return false + const msg = err instanceof Error ? err.message : String(err) + const m = msg.toLowerCase() + return m.includes("duplicate") && (m.includes("invitation") || m.includes("invitation_code")) +} + +const generateInvitationCode = () => { + const n = randomInt(0, 1_000_000) + return String(n).padStart(6, "0") +} + +const generateUniqueInvitationCode = async (): Promise => { + for (let attempt = 0; attempt < 40; attempt += 1) { + const code = generateInvitationCode() + const [existing] = await db + .select({ id: classes.id }) + .from(classes) + .where(eq(classes.invitationCode, code)) + .limit(1) + if (!existing) return code + } + throw new Error("Failed to generate invitation code") +} + +export const getTeacherIdForMutations = async (): Promise => { + const teacherId = await getDefaultTeacherId() + if (!teacherId) throw new Error("No teacher available") + return teacherId +} + +const normalizeSortText = (v: string | null | undefined) => (typeof v === "string" ? v.trim().toLowerCase() : "") + +const parseFirstInt = (v: string) => { + const m = v.match(/\d+/) + return m ? Number(m[0]) : null +} + +const compareGradeLabel = (a: string, b: string) => { + const aNum = parseFirstInt(a) + const bNum = parseFirstInt(b) + if (typeof aNum === "number" && typeof bNum === "number" && aNum !== bNum) return aNum - bNum + return a.localeCompare(b) +} + +const compareClassLike = ( + a: { schoolName?: string | null; grade: string; name: string; homeroom?: string | null; room?: string | null }, + b: { schoolName?: string | null; grade: string; name: string; homeroom?: string | null; room?: string | null } +) => { + const schoolCmp = normalizeSortText(a.schoolName).localeCompare(normalizeSortText(b.schoolName)) + if (schoolCmp !== 0) return schoolCmp + + const gradeCmp = compareGradeLabel(a.grade, b.grade) + if (gradeCmp !== 0) return gradeCmp + + const nameCmp = normalizeSortText(a.name).localeCompare(normalizeSortText(b.name)) + if (nameCmp !== 0) return nameCmp + + const hrCmp = normalizeSortText(a.homeroom).localeCompare(normalizeSortText(b.homeroom)) + if (hrCmp !== 0) return hrCmp + + return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room)) +} + +export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise => { + const teacherId = params?.teacherId ?? (await getDefaultTeacherId()) + if (!teacherId) return [] + + const rows = await (async () => { + try { + return await db + .select({ + id: classes.id, + schoolName: classes.schoolName, + name: classes.name, + grade: classes.grade, + homeroom: classes.homeroom, + room: classes.room, + invitationCode: classes.invitationCode, + studentCount: sql`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`, + }) + .from(classes) + .leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id)) + .where(eq(classes.teacherId, teacherId)) + .groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode) + .orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) + } catch { + return await db + .select({ + id: classes.id, + schoolName: sql`NULL`.as("schoolName"), + name: classes.name, + grade: classes.grade, + homeroom: classes.homeroom, + room: classes.room, + invitationCode: sql`NULL`.as("invitationCode"), + studentCount: sql`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`, + }) + .from(classes) + .leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id)) + .where(eq(classes.teacherId, teacherId)) + .groupBy(classes.id, classes.name, classes.grade, classes.homeroom, classes.room) + .orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) + } + })() + + const list = rows.map((r) => ({ + id: r.id, + schoolName: r.schoolName, + name: r.name, + grade: r.grade, + homeroom: r.homeroom, + room: r.room, + invitationCode: r.invitationCode ?? null, + studentCount: Number(r.studentCount ?? 0), + })) + + list.sort(compareClassLike) + return list +}) + +export const getTeacherOptions = cache(async (): Promise => { + const rows = await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .where(eq(users.role, "teacher")) + .orderBy(asc(users.createdAt)) + + return rows.map((r) => ({ + id: r.id, + name: r.name ?? "Unnamed", + email: r.email, + })) +}) + +export const getAdminClasses = cache(async (): Promise => { + const [rows, subjectRows] = await Promise.all([ + (async () => { + try { + return await db + .select({ + id: classes.id, + schoolName: classes.schoolName, + schoolId: classes.schoolId, + name: classes.name, + grade: classes.grade, + gradeId: classes.gradeId, + homeroom: classes.homeroom, + room: classes.room, + invitationCode: classes.invitationCode, + teacherId: users.id, + teacherName: users.name, + teacherEmail: users.email, + studentCount: sql`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`, + createdAt: classes.createdAt, + updatedAt: classes.updatedAt, + }) + .from(classes) + .innerJoin(users, eq(users.id, classes.teacherId)) + .leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id)) + .groupBy( + classes.id, + classes.schoolName, + classes.schoolId, + classes.name, + classes.grade, + classes.gradeId, + classes.homeroom, + classes.room, + classes.invitationCode, + users.id, + users.name, + users.email, + classes.createdAt, + classes.updatedAt + ) + .orderBy( + asc(classes.schoolName), + asc(classes.grade), + asc(classes.name), + asc(classes.homeroom), + asc(classes.room) + ) + } catch { + return await db + .select({ + id: classes.id, + schoolName: sql`NULL`.as("schoolName"), + schoolId: sql`NULL`.as("schoolId"), + name: classes.name, + grade: classes.grade, + gradeId: sql`NULL`.as("gradeId"), + homeroom: classes.homeroom, + room: classes.room, + invitationCode: sql`NULL`.as("invitationCode"), + teacherId: users.id, + teacherName: users.name, + teacherEmail: users.email, + studentCount: sql`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`, + createdAt: classes.createdAt, + updatedAt: classes.updatedAt, + }) + .from(classes) + .innerJoin(users, eq(users.id, classes.teacherId)) + .leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id)) + .groupBy( + classes.id, + classes.name, + classes.grade, + classes.homeroom, + classes.room, + users.id, + users.name, + users.email, + classes.createdAt, + classes.updatedAt + ) + .orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) + } + })(), + db + .select({ + classId: classSubjectTeachers.classId, + subject: classSubjectTeachers.subject, + teacherId: users.id, + teacherName: users.name, + teacherEmail: users.email, + }) + .from(classSubjectTeachers) + .leftJoin(users, eq(users.id, classSubjectTeachers.teacherId)) + .orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)), + ]) + + const subjectsByClassId = new Map>() + for (const r of subjectRows) { + const subject = r.subject as ClassSubject + if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue + const teacher = + typeof r.teacherId === "string" && r.teacherId.length > 0 + ? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" } + : null + const bySubject = subjectsByClassId.get(r.classId) ?? new Map() + bySubject.set(subject, teacher) + subjectsByClassId.set(r.classId, bySubject) + } + + const list = rows.map((r) => { + const bySubject = subjectsByClassId.get(r.id) + const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({ + subject, + teacher: bySubject?.get(subject) ?? null, + })) + + return { + id: r.id, + schoolName: r.schoolName, + schoolId: r.schoolId, + name: r.name, + grade: r.grade, + gradeId: r.gradeId, + homeroom: r.homeroom, + room: r.room, + invitationCode: r.invitationCode ?? null, + teacher: { + id: r.teacherId, + name: r.teacherName ?? "Unnamed", + email: r.teacherEmail, + }, + subjectTeachers, + studentCount: Number(r.studentCount ?? 0), + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + } + }) + + list.sort(compareClassLike) + return list +}) + +export const getStudentClasses = cache(async (studentId: string): Promise => { + const id = studentId.trim() + if (!id) return [] + + const rows = await (async () => { + try { + return await db + .select({ + id: classes.id, + schoolName: classes.schoolName, + name: classes.name, + grade: classes.grade, + homeroom: classes.homeroom, + room: classes.room, + }) + .from(classEnrollments) + .innerJoin(classes, eq(classes.id, classEnrollments.classId)) + .where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active"))) + .orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) + } catch { + return await db + .select({ + id: classes.id, + schoolName: sql`NULL`.as("schoolName"), + name: classes.name, + grade: classes.grade, + homeroom: classes.homeroom, + room: classes.room, + }) + .from(classEnrollments) + .innerJoin(classes, eq(classes.id, classEnrollments.classId)) + .where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active"))) + .orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) + } + })() + + const list = rows.map((r) => ({ + id: r.id, + schoolName: r.schoolName, + name: r.name, + grade: r.grade, + homeroom: r.homeroom, + room: r.room, + })) + + list.sort(compareClassLike) + return list +}) + +export const getStudentSchedule = cache(async (studentId: string): Promise => { + const id = studentId.trim() + if (!id) return [] + + const rows = await db + .select({ + id: classSchedule.id, + classId: classSchedule.classId, + className: classes.name, + weekday: classSchedule.weekday, + startTime: classSchedule.startTime, + endTime: classSchedule.endTime, + course: classSchedule.course, + location: classSchedule.location, + }) + .from(classEnrollments) + .innerJoin(classes, eq(classes.id, classEnrollments.classId)) + .innerJoin(classSchedule, eq(classSchedule.classId, classes.id)) + .where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active"))) + .orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime)) + + return rows.map((r) => ({ + id: r.id, + classId: r.classId, + className: r.className, + weekday: r.weekday as StudentScheduleItem["weekday"], + startTime: r.startTime, + endTime: r.endTime, + course: r.course, + location: r.location, + })) +}) + +export const getClassStudents = cache( + async (params?: { classId?: string; q?: string; teacherId?: string }): Promise => { + const teacherId = params?.teacherId ?? (await getDefaultTeacherId()) + if (!teacherId) return [] + + const classId = params?.classId?.trim() + const q = params?.q?.trim().toLowerCase() + + const conditions: SQL[] = [eq(classes.teacherId, teacherId)] + + if (classId) { + conditions.push(eq(classes.id, classId)) + } + + if (q && q.length > 0) { + const needle = `%${q}%` + conditions.push( + sql`(LOWER(COALESCE(${users.name}, '')) LIKE ${needle} OR LOWER(${users.email}) LIKE ${needle})` + ) + } + + const rows = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + classId: classes.id, + className: classes.name, + status: classEnrollments.status, + }) + .from(classEnrollments) + .innerJoin(classes, eq(classes.id, classEnrollments.classId)) + .innerJoin(users, eq(users.id, classEnrollments.studentId)) + .where(and(...conditions)) + .orderBy(asc(users.name), asc(users.email)) + + return rows.map((r) => ({ + id: r.id, + name: r.name ?? "Unnamed", + email: r.email, + classId: r.classId, + className: r.className, + status: r.status, + })) + } +) + +export const getClassSchedule = cache( + async (params?: { classId?: string; teacherId?: string }): Promise => { + const teacherId = params?.teacherId ?? (await getDefaultTeacherId()) + if (!teacherId) return [] + + const classId = params?.classId?.trim() + + const conditions: SQL[] = [eq(classes.teacherId, teacherId)] + if (classId) conditions.push(eq(classSchedule.classId, classId)) + + const rows = await db + .select({ + id: classSchedule.id, + classId: classSchedule.classId, + weekday: classSchedule.weekday, + startTime: classSchedule.startTime, + endTime: classSchedule.endTime, + course: classSchedule.course, + location: classSchedule.location, + }) + .from(classSchedule) + .innerJoin(classes, eq(classes.id, classSchedule.classId)) + .where(and(...conditions)) + .orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime)) + + return rows.map((r) => ({ + id: r.id, + classId: r.classId, + weekday: r.weekday as ClassScheduleItem["weekday"], + startTime: r.startTime, + endTime: r.endTime, + course: r.course, + location: r.location, + })) + } +) + +const median = (sorted: number[]): number | null => { + if (sorted.length === 0) return null + const mid = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 1) return sorted[mid] ?? null + const a = sorted[mid - 1] + const b = sorted[mid] + if (typeof a !== "number" || typeof b !== "number") return null + return (a + b) / 2 +} + +const toScoreStats = (scores: number[]): ScoreStats => { + if (scores.length === 0) return { count: 0, avg: null, median: null, min: null, max: null } + const sorted = [...scores].sort((a, b) => a - b) + const sum = sorted.reduce((acc, v) => acc + v, 0) + return { + count: sorted.length, + avg: sum / sorted.length, + median: median(sorted), + min: sorted[0] ?? null, + max: sorted[sorted.length - 1] ?? null, + } +} + +export const getClassHomeworkInsights = cache( + async (params: { classId: string; teacherId?: string; limit?: number }): Promise => { + const teacherId = params.teacherId ?? (await getDefaultTeacherId()) + if (!teacherId) return null + + const classId = params.classId.trim() + if (!classId) return null + + const [classRow] = await db + .select({ + id: classes.id, + name: classes.name, + grade: classes.grade, + homeroom: classes.homeroom, + room: classes.room, + invitationCode: classes.invitationCode, + }) + .from(classes) + .where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!classRow) return null + + const enrollments = await db + .select({ + studentId: classEnrollments.studentId, + status: classEnrollments.status, + }) + .from(classEnrollments) + .innerJoin(classes, eq(classes.id, classEnrollments.classId)) + .where(and(eq(classes.teacherId, teacherId), eq(classEnrollments.classId, classId))) + + const activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId) + const inactiveStudentIds = enrollments.filter((e) => e.status !== "active").map((e) => e.studentId) + const studentIds = enrollments.map((e) => e.studentId) + + if (studentIds.length === 0) { + return { + class: { + id: classRow.id, + name: classRow.name, + grade: classRow.grade, + homeroom: classRow.homeroom, + room: classRow.room, + invitationCode: classRow.invitationCode ?? null, + }, + studentCounts: { total: 0, active: 0, inactive: 0 }, + assignments: [], + latest: null, + overallScores: { count: 0, avg: null, median: null, min: null, max: null }, + } + } + + const assignmentIdRows = await db + .selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId }) + .from(homeworkAssignmentTargets) + .where(inArray(homeworkAssignmentTargets.studentId, studentIds)) + + const assignmentIds = assignmentIdRows.map((r) => r.assignmentId) + if (assignmentIds.length === 0) { + return { + class: { + id: classRow.id, + name: classRow.name, + grade: classRow.grade, + homeroom: classRow.homeroom, + room: classRow.room, + invitationCode: classRow.invitationCode ?? null, + }, + studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length }, + assignments: [], + latest: null, + overallScores: { count: 0, avg: null, median: null, min: null, max: null }, + } + } + + const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50 + const assignments = await db.query.homeworkAssignments.findMany({ + where: and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)), + orderBy: [desc(homeworkAssignments.createdAt)], + limit, + }) + + const usedAssignmentIds = assignments.map((a) => a.id) + if (usedAssignmentIds.length === 0) { + return { + class: { + id: classRow.id, + name: classRow.name, + grade: classRow.grade, + homeroom: classRow.homeroom, + room: classRow.room, + invitationCode: classRow.invitationCode ?? null, + }, + studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length }, + assignments: [], + latest: null, + overallScores: { count: 0, avg: null, median: null, min: null, max: null }, + } + } + + const maxScoreRows = await db + .select({ + assignmentId: homeworkAssignmentQuestions.assignmentId, + maxScore: sql`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`, + }) + .from(homeworkAssignmentQuestions) + .where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds)) + .groupBy(homeworkAssignmentQuestions.assignmentId) + + const maxScoreByAssignmentId = new Map() + for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0)) + + const targetCountRows = await db + .select({ + assignmentId: homeworkAssignmentTargets.assignmentId, + targetCount: sql`COUNT(*)`, + }) + .from(homeworkAssignmentTargets) + .where( + and( + inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds), + inArray(homeworkAssignmentTargets.studentId, studentIds) + ) + ) + .groupBy(homeworkAssignmentTargets.assignmentId) + + const targetCountByAssignmentId = new Map() + for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0)) + + const submissions = await db.query.homeworkSubmissions.findMany({ + where: and( + inArray(homeworkSubmissions.assignmentId, usedAssignmentIds), + inArray(homeworkSubmissions.studentId, studentIds) + ), + orderBy: [desc(homeworkSubmissions.createdAt)], + }) + + const latestByKey = new Map() + for (const s of submissions) { + const key = `${s.assignmentId}:${s.studentId}` + if (!latestByKey.has(key)) latestByKey.set(key, s) + } + + const allScored: number[] = [] + const nowMs = Date.now() + + const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => { + const targetCount = targetCountByAssignmentId.get(a.id) ?? 0 + let submittedCount = 0 + let gradedCount = 0 + const scores: number[] = [] + const dueMs = a.dueAt ? a.dueAt.getTime() : null + + for (const studentId of studentIds) { + const s = latestByKey.get(`${a.id}:${studentId}`) + if (!s) continue + + const status = (s.status ?? "started") as string + if (status === "submitted" || status === "graded") submittedCount += 1 + if (status === "graded" || typeof s.score === "number") gradedCount += 1 + if (typeof s.score === "number") scores.push(s.score) + } + + allScored.push(...scores) + + return { + assignmentId: a.id, + title: a.title, + status: (a.status as string) ?? "draft", + createdAt: a.createdAt.toISOString(), + dueAt: a.dueAt ? a.dueAt.toISOString() : null, + isActive: dueMs === null || dueMs >= nowMs, + isOverdue: typeof dueMs === "number" && dueMs < nowMs, + maxScore: maxScoreByAssignmentId.get(a.id) ?? 0, + targetCount, + submittedCount, + gradedCount, + scoreStats: toScoreStats(scores), + } + }) + + const overallScores = toScoreStats(allScored) + const latest = stats[0] ?? null + + return { + class: { + id: classRow.id, + name: classRow.name, + grade: classRow.grade, + homeroom: classRow.homeroom, + room: classRow.room, + invitationCode: classRow.invitationCode ?? null, + }, + studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length }, + assignments: stats, + latest, + overallScores, + } + } +) + +const avg = (values: number[]): number | null => { + if (values.length === 0) return null + const sum = values.reduce((acc, v) => acc + v, 0) + return sum / values.length +} + +export const getGradeHomeworkInsights = cache( + async (params: { gradeId: string; limit?: number }): Promise => { + const gradeId = params.gradeId.trim() + if (!gradeId) return null + + const [gradeRow] = await db + .select({ + id: grades.id, + name: grades.name, + schoolId: schools.id, + schoolName: schools.name, + }) + .from(grades) + .innerJoin(schools, eq(schools.id, grades.schoolId)) + .where(eq(grades.id, gradeId)) + .limit(1) + + if (!gradeRow) return null + + const classRows = await db + .select({ + id: classes.id, + name: classes.name, + grade: classes.grade, + homeroom: classes.homeroom, + room: classes.room, + }) + .from(classes) + .where(eq(classes.gradeId, gradeId)) + .orderBy(asc(classes.name), asc(classes.homeroom), asc(classes.room)) + + const classIds = classRows.map((r) => r.id) + if (classIds.length === 0) { + return { + grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } }, + classCount: 0, + studentCounts: { total: 0, active: 0, inactive: 0 }, + assignments: [], + latest: null, + overallScores: { count: 0, avg: null, median: null, min: null, max: null }, + classes: [], + } + } + + const enrollmentRows = await db + .select({ + classId: classEnrollments.classId, + studentId: classEnrollments.studentId, + status: classEnrollments.status, + }) + .from(classEnrollments) + .where(inArray(classEnrollments.classId, classIds)) + + const studentActiveById = new Map() + const studentsByClassId = new Map; active: Set }>() + + for (const e of enrollmentRows) { + const prev = studentActiveById.get(e.studentId) ?? false + const next = prev || e.status === "active" + studentActiveById.set(e.studentId, next) + + const bucket = studentsByClassId.get(e.classId) ?? { all: new Set(), active: new Set() } + bucket.all.add(e.studentId) + if (e.status === "active") bucket.active.add(e.studentId) + studentsByClassId.set(e.classId, bucket) + } + + const studentIds = Array.from(studentActiveById.keys()) + const activeCount = Array.from(studentActiveById.values()).filter(Boolean).length + const inactiveCount = studentIds.length - activeCount + + if (studentIds.length === 0) { + const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => ({ + class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room }, + studentCounts: { total: 0, active: 0, inactive: 0 }, + latestAvg: null, + prevAvg: null, + deltaAvg: null, + overallScores: { count: 0, avg: null, median: null, min: null, max: null }, + })) + + return { + grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } }, + classCount: classRows.length, + studentCounts: { total: 0, active: 0, inactive: 0 }, + assignments: [], + latest: null, + overallScores: { count: 0, avg: null, median: null, min: null, max: null }, + classes: summaries, + } + } + + const assignmentIdRows = await db + .selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId }) + .from(homeworkAssignmentTargets) + .where(inArray(homeworkAssignmentTargets.studentId, studentIds)) + + const assignmentIds = assignmentIdRows.map((r) => r.assignmentId) + if (assignmentIds.length === 0) { + const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => { + const bucket = studentsByClassId.get(c.id) ?? { all: new Set(), active: new Set() } + return { + class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room }, + studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size }, + latestAvg: null, + prevAvg: null, + deltaAvg: null, + overallScores: { count: 0, avg: null, median: null, min: null, max: null }, + } + }) + + return { + grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } }, + classCount: classRows.length, + studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount }, + assignments: [], + latest: null, + overallScores: { count: 0, avg: null, median: null, min: null, max: null }, + classes: summaries, + } + } + + const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50 + const assignments = await db.query.homeworkAssignments.findMany({ + where: inArray(homeworkAssignments.id, assignmentIds), + orderBy: [desc(homeworkAssignments.createdAt)], + limit, + }) + + const usedAssignmentIds = assignments.map((a) => a.id) + if (usedAssignmentIds.length === 0) { + const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => { + const bucket = studentsByClassId.get(c.id) ?? { all: new Set(), active: new Set() } + return { + class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room }, + studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size }, + latestAvg: null, + prevAvg: null, + deltaAvg: null, + overallScores: { count: 0, avg: null, median: null, min: null, max: null }, + } + }) + + return { + grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } }, + classCount: classRows.length, + studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount }, + assignments: [], + latest: null, + overallScores: { count: 0, avg: null, median: null, min: null, max: null }, + classes: summaries, + } + } + + const maxScoreRows = await db + .select({ + assignmentId: homeworkAssignmentQuestions.assignmentId, + maxScore: sql`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`, + }) + .from(homeworkAssignmentQuestions) + .where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds)) + .groupBy(homeworkAssignmentQuestions.assignmentId) + + const maxScoreByAssignmentId = new Map() + for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0)) + + const targetCountRows = await db + .select({ + assignmentId: homeworkAssignmentTargets.assignmentId, + targetCount: sql`COUNT(*)`, + }) + .from(homeworkAssignmentTargets) + .where( + and( + inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds), + inArray(homeworkAssignmentTargets.studentId, studentIds) + ) + ) + .groupBy(homeworkAssignmentTargets.assignmentId) + + const targetCountByAssignmentId = new Map() + for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0)) + + const submissions = await db.query.homeworkSubmissions.findMany({ + where: and( + inArray(homeworkSubmissions.assignmentId, usedAssignmentIds), + inArray(homeworkSubmissions.studentId, studentIds) + ), + orderBy: [desc(homeworkSubmissions.createdAt)], + }) + + const latestByKey = new Map() + for (const s of submissions) { + const key = `${s.assignmentId}:${s.studentId}` + if (!latestByKey.has(key)) latestByKey.set(key, s) + } + + const allScored: number[] = [] + const nowMs = Date.now() + + const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => { + const targetCount = targetCountByAssignmentId.get(a.id) ?? 0 + let submittedCount = 0 + let gradedCount = 0 + const scores: number[] = [] + const dueMs = a.dueAt ? a.dueAt.getTime() : null + + for (const studentId of studentIds) { + const s = latestByKey.get(`${a.id}:${studentId}`) + if (!s) continue + + const status = (s.status ?? "started") as string + if (status === "submitted" || status === "graded") submittedCount += 1 + if (status === "graded" || typeof s.score === "number") gradedCount += 1 + if (typeof s.score === "number") scores.push(s.score) + } + + allScored.push(...scores) + + return { + assignmentId: a.id, + title: a.title, + status: (a.status as string) ?? "draft", + createdAt: a.createdAt.toISOString(), + dueAt: a.dueAt ? a.dueAt.toISOString() : null, + isActive: dueMs === null || dueMs >= nowMs, + isOverdue: typeof dueMs === "number" && dueMs < nowMs, + maxScore: maxScoreByAssignmentId.get(a.id) ?? 0, + targetCount, + submittedCount, + gradedCount, + scoreStats: toScoreStats(scores), + } + }) + + const overallScores = toScoreStats(allScored) + const latest = stats[0] ?? null + const latestAssignmentId = stats[0]?.assignmentId ?? null + const prevAssignmentId = stats[1]?.assignmentId ?? null + + const classSummaries: GradeHomeworkClassSummary[] = classRows.map((c) => { + const bucket = studentsByClassId.get(c.id) ?? { all: new Set(), active: new Set() } + const classStudentIds = Array.from(bucket.all) + + const latestScores: number[] = [] + const prevScores: number[] = [] + const overallClassScores: number[] = [] + + if (latestAssignmentId) { + for (const studentId of classStudentIds) { + const s = latestByKey.get(`${latestAssignmentId}:${studentId}`) + if (typeof s?.score === "number") latestScores.push(s.score) + } + } + + if (prevAssignmentId) { + for (const studentId of classStudentIds) { + const s = latestByKey.get(`${prevAssignmentId}:${studentId}`) + if (typeof s?.score === "number") prevScores.push(s.score) + } + } + + for (const assignmentId of usedAssignmentIds) { + for (const studentId of classStudentIds) { + const s = latestByKey.get(`${assignmentId}:${studentId}`) + if (typeof s?.score === "number") overallClassScores.push(s.score) + } + } + + const latestAvg = avg(latestScores) + const prevAvg = avg(prevScores) + + return { + class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room }, + studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size }, + latestAvg, + prevAvg, + deltaAvg: typeof latestAvg === "number" && typeof prevAvg === "number" ? latestAvg - prevAvg : null, + overallScores: toScoreStats(overallClassScores), + } + }) + + classSummaries.sort((a, b) => (b.latestAvg ?? -Infinity) - (a.latestAvg ?? -Infinity)) + + return { + grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } }, + classCount: classRows.length, + studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount }, + assignments: stats, + latest, + overallScores, + classes: classSummaries, + } + } +) + +export async function createTeacherClass(data: CreateTeacherClassInput): Promise { + const teacherId = await getTeacherIdForMutations() + const id = createId() + + const schoolName = data.schoolName?.trim() || null + const schoolId = data.schoolId?.trim() || null + const name = data.name.trim() + const grade = data.grade.trim() + const gradeId = data.gradeId?.trim() || null + const homeroom = data.homeroom?.trim() || null + const room = data.room?.trim() || null + + if (!name) throw new Error("Name is required") + if (!grade) throw new Error("Grade is required") + + for (let attempt = 0; attempt < 20; attempt += 1) { + const invitationCode = await generateUniqueInvitationCode() + try { + await db.transaction(async (tx) => { + await tx.insert(classes).values({ + id, + schoolName, + schoolId, + name, + grade, + gradeId, + homeroom, + room, + invitationCode, + teacherId, + }) + + await tx.insert(classSubjectTeachers).values( + DEFAULT_CLASS_SUBJECTS.map((subject) => ({ + classId: id, + subject, + teacherId: null, + })) + ) + }) + return id + } catch (err) { + if (isDuplicateInvitationCodeError(err)) continue + throw err + } + } + throw new Error("Failed to create class") + + return id +} + +export async function createAdminClass(data: CreateTeacherClassInput & { teacherId: string }): Promise { + const id = createId() + + const schoolName = data.schoolName?.trim() || null + const schoolId = data.schoolId?.trim() || null + const name = data.name.trim() + const grade = data.grade.trim() + const gradeId = data.gradeId?.trim() || null + const homeroom = data.homeroom?.trim() || null + const room = data.room?.trim() || null + const teacherId = data.teacherId.trim() + + if (!name) throw new Error("Name is required") + if (!grade) throw new Error("Grade is required") + if (!teacherId) throw new Error("Teacher is required") + + const [teacher] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.id, teacherId), eq(users.role, "teacher"))) + .limit(1) + if (!teacher) throw new Error("Teacher not found") + + for (let attempt = 0; attempt < 20; attempt += 1) { + const invitationCode = await generateUniqueInvitationCode() + try { + await db.transaction(async (tx) => { + await tx.insert(classes).values({ + id, + schoolName, + schoolId, + name, + grade, + gradeId, + homeroom, + room, + invitationCode, + teacherId, + }) + + await tx.insert(classSubjectTeachers).values( + DEFAULT_CLASS_SUBJECTS.map((subject) => ({ + classId: id, + subject, + teacherId: null, + })) + ) + }) + return id + } catch (err) { + if (isDuplicateInvitationCodeError(err)) continue + throw err + } + } + throw new Error("Failed to create class") + + return id +} + +export async function ensureClassInvitationCode(classId: string): Promise { + const teacherId = await getTeacherIdForMutations() + const id = classId.trim() + if (!id) throw new Error("Missing class id") + + const [owned] = await db + .select({ id: classes.id, invitationCode: classes.invitationCode }) + .from(classes) + .where(and(eq(classes.id, id), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!owned) throw new Error("Class not found") + + const existing = owned.invitationCode + if (typeof existing === "string" && /^\d{6}$/.test(existing)) return existing + + for (let attempt = 0; attempt < 40; attempt += 1) { + const code = await generateUniqueInvitationCode() + try { + await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id)) + return code + } catch (err) { + if (isDuplicateInvitationCodeError(err)) continue + throw err + } + } + + throw new Error("Failed to generate invitation code") +} + +export async function regenerateClassInvitationCode(classId: string): Promise { + const teacherId = await getTeacherIdForMutations() + const id = classId.trim() + if (!id) throw new Error("Missing class id") + + const [owned] = await db + .select({ id: classes.id }) + .from(classes) + .where(and(eq(classes.id, id), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!owned) throw new Error("Class not found") + + for (let attempt = 0; attempt < 40; attempt += 1) { + const code = await generateUniqueInvitationCode() + try { + await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id)) + return code + } catch (err) { + if (isDuplicateInvitationCodeError(err)) continue + throw err + } + } + + throw new Error("Failed to generate invitation code") +} + +export async function enrollStudentByInvitationCode(studentId: string, invitationCode: string): Promise { + const sid = studentId.trim() + const code = invitationCode.trim() + if (!sid) throw new Error("Missing student id") + if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code") + + const [cls] = await db + .select({ id: classes.id }) + .from(classes) + .where(eq(classes.invitationCode, code)) + .limit(1) + + if (!cls) throw new Error("Invalid invitation code") + + await db + .insert(classEnrollments) + .values({ classId: cls.id, studentId: sid, status: "active" }) + .onDuplicateKeyUpdate({ set: { status: "active" } }) + + return cls.id +} + +export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise { + const teacherId = await getTeacherIdForMutations() + + const [owned] = await db + .select({ id: classes.id }) + .from(classes) + .where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!owned) throw new Error("Class not found") + + const update: Partial = {} + if (data.schoolName !== undefined) update.schoolName = data.schoolName?.trim() || null + if (data.schoolId !== undefined) update.schoolId = data.schoolId?.trim() || null + if (typeof data.name === "string") update.name = data.name.trim() + if (typeof data.grade === "string") update.grade = data.grade.trim() + if (data.gradeId !== undefined) update.gradeId = data.gradeId?.trim() || null + if (data.homeroom !== undefined) update.homeroom = data.homeroom?.trim() || null + if (data.room !== undefined) update.room = data.room?.trim() || null + + if (Object.keys(update).length === 0) return + + await db + .update(classes) + .set(update) + .where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId))) +} + +export async function updateAdminClass( + classId: string, + data: UpdateTeacherClassInput & { teacherId?: string } +): Promise { + const id = classId.trim() + if (!id) throw new Error("Missing class id") + + const [existing] = await db + .select({ id: classes.id }) + .from(classes) + .where(eq(classes.id, id)) + .limit(1) + if (!existing) throw new Error("Class not found") + + const update: Partial = {} + if (data.schoolName !== undefined) update.schoolName = data.schoolName?.trim() || null + if (data.schoolId !== undefined) update.schoolId = data.schoolId?.trim() || null + if (typeof data.name === "string") update.name = data.name.trim() + if (typeof data.grade === "string") update.grade = data.grade.trim() + if (data.gradeId !== undefined) update.gradeId = data.gradeId?.trim() || null + if (data.homeroom !== undefined) update.homeroom = data.homeroom?.trim() || null + if (data.room !== undefined) update.room = data.room?.trim() || null + + if (typeof data.teacherId === "string") { + const nextTeacherId = data.teacherId.trim() + if (!nextTeacherId) throw new Error("Teacher is required") + + const [teacher] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.id, nextTeacherId), eq(users.role, "teacher"))) + .limit(1) + if (!teacher) throw new Error("Teacher not found") + + update.teacherId = nextTeacherId + } + + if (Object.keys(update).length === 0) return + + await db.update(classes).set(update).where(eq(classes.id, id)) +} + +export async function setClassSubjectTeachers(params: { + classId: string + assignments: Array<{ subject: ClassSubject; teacherId: string | null }> +}): Promise { + const classId = params.classId.trim() + if (!classId) throw new Error("Missing class id") + + const [existing] = await db.select({ id: classes.id }).from(classes).where(eq(classes.id, classId)).limit(1) + if (!existing) throw new Error("Class not found") + + const teacherIds = params.assignments + .map((a) => a.teacherId) + .filter((v): v is string => typeof v === "string" && v.trim().length > 0) + + if (teacherIds.length > 0) { + const rows = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.role, "teacher"), inArray(users.id, teacherIds))) + if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found") + } + + const teacherBySubject = new Map() + for (const a of params.assignments) { + if (!DEFAULT_CLASS_SUBJECTS.includes(a.subject)) continue + teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null) + } + + await db + .insert(classSubjectTeachers) + .values( + DEFAULT_CLASS_SUBJECTS.map((subject) => ({ + classId, + subject, + teacherId: teacherBySubject.get(subject) ?? null, + })) + ) + .onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } }) +} + +export async function deleteTeacherClass(classId: string): Promise { + const teacherId = await getTeacherIdForMutations() + + const [owned] = await db + .select({ id: classes.id }) + .from(classes) + .where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!owned) throw new Error("Class not found") + + await db + .delete(classes) + .where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId))) +} + +export async function deleteAdminClass(classId: string): Promise { + const id = classId.trim() + if (!id) throw new Error("Missing class id") + + const [existing] = await db + .select({ id: classes.id }) + .from(classes) + .where(eq(classes.id, id)) + .limit(1) + if (!existing) throw new Error("Class not found") + + await db.delete(classes).where(eq(classes.id, id)) +} + +export async function enrollStudentByEmail(classId: string, email: string): Promise { + const teacherId = await getTeacherIdForMutations() + const normalized = email.trim().toLowerCase() + if (!normalized) throw new Error("Student email is required") + + const [owned] = await db + .select({ id: classes.id }) + .from(classes) + .where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!owned) throw new Error("Class not found") + + const [student] = await db + .select({ id: users.id, role: users.role }) + .from(users) + .where(eq(users.email, normalized)) + .limit(1) + + if (!student) throw new Error("Student not found") + if (student.role !== "student") throw new Error("User is not a student") + + await db + .insert(classEnrollments) + .values({ classId, studentId: student.id, status: "active" }) + .onDuplicateKeyUpdate({ set: { status: "active" } }) +} + +export async function setStudentEnrollmentStatus(classId: string, studentId: string, status: "active" | "inactive"): Promise { + const teacherId = await getTeacherIdForMutations() + + const [owned] = await db + .select({ id: classes.id }) + .from(classes) + .where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!owned) throw new Error("Class not found") + + const [existing] = await db + .select({ classId: classEnrollments.classId }) + .from(classEnrollments) + .where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId))) + .limit(1) + + if (!existing) throw new Error("Enrollment not found") + + await db + .update(classEnrollments) + .set({ status }) + .where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId))) +} + +const isTimeHHMM = (v: string) => /^\d{2}:\d{2}$/.test(v) + +export async function createClassScheduleItem(data: CreateClassScheduleItemInput): Promise { + const teacherId = await getTeacherIdForMutations() + + const classId = data.classId.trim() + const course = data.course.trim() + const startTime = data.startTime.trim() + const endTime = data.endTime.trim() + const location = data.location?.trim() || null + const weekday = data.weekday + + if (!classId) throw new Error("Class is required") + if (!course) throw new Error("Course is required") + if (!isTimeHHMM(startTime) || !isTimeHHMM(endTime)) throw new Error("Invalid time format") + if (startTime >= endTime) throw new Error("Start time must be earlier than end time") + if (weekday < 1 || weekday > 7) throw new Error("Invalid weekday") + + const [owned] = await db + .select({ id: classes.id }) + .from(classes) + .where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!owned) throw new Error("Class not found") + + const id = createId() + await db.insert(classSchedule).values({ + id, + classId, + weekday, + startTime, + endTime, + course, + location, + }) + + return id +} + +export async function updateClassScheduleItem(scheduleId: string, data: UpdateClassScheduleItemInput): Promise { + const teacherId = await getTeacherIdForMutations() + const id = scheduleId.trim() + if (!id) throw new Error("Missing schedule id") + + const [existing] = await db + .select({ + id: classSchedule.id, + classId: classSchedule.classId, + startTime: classSchedule.startTime, + endTime: classSchedule.endTime, + }) + .from(classSchedule) + .innerJoin(classes, eq(classes.id, classSchedule.classId)) + .where(and(eq(classSchedule.id, id), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!existing) throw new Error("Schedule item not found") + + const update: Partial = {} + + if (typeof data.classId === "string") { + const nextClassId = data.classId.trim() + if (!nextClassId) throw new Error("Class is required") + + const [ownedNext] = await db + .select({ id: classes.id }) + .from(classes) + .where(and(eq(classes.id, nextClassId), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!ownedNext) throw new Error("Class not found") + update.classId = nextClassId + } + + if (typeof data.weekday === "number") { + if (data.weekday < 1 || data.weekday > 7) throw new Error("Invalid weekday") + update.weekday = data.weekday + } + + if (typeof data.course === "string") { + const course = data.course.trim() + if (!course) throw new Error("Course is required") + update.course = course + } + + const nextStart = typeof data.startTime === "string" ? data.startTime.trim() : undefined + const nextEnd = typeof data.endTime === "string" ? data.endTime.trim() : undefined + if (nextStart !== undefined) { + if (!isTimeHHMM(nextStart)) throw new Error("Invalid time format") + update.startTime = nextStart + } + if (nextEnd !== undefined) { + if (!isTimeHHMM(nextEnd)) throw new Error("Invalid time format") + update.endTime = nextEnd + } + + if (update.startTime !== undefined || update.endTime !== undefined) { + const mergedStart = update.startTime ?? existing.startTime + const mergedEnd = update.endTime ?? existing.endTime + if (typeof mergedStart === "string" && typeof mergedEnd === "string" && mergedStart >= mergedEnd) { + throw new Error("Start time must be earlier than end time") + } + } + + if (data.location !== undefined) { + update.location = data.location?.trim() || null + } + + if (Object.keys(update).length === 0) return + + await db + .update(classSchedule) + .set(update) + .where(eq(classSchedule.id, id)) +} + +export async function deleteClassScheduleItem(scheduleId: string): Promise { + const teacherId = await getTeacherIdForMutations() + const id = scheduleId.trim() + if (!id) throw new Error("Missing schedule id") + + const [owned] = await db + .select({ id: classSchedule.id }) + .from(classSchedule) + .innerJoin(classes, eq(classes.id, classSchedule.classId)) + .where(and(eq(classSchedule.id, id), eq(classes.teacherId, teacherId))) + .limit(1) + + if (!owned) throw new Error("Schedule item not found") + + await db.delete(classSchedule).where(eq(classSchedule.id, id)) +} diff --git a/src/modules/classes/types.ts b/src/modules/classes/types.ts new file mode 100644 index 0000000..d93062d --- /dev/null +++ b/src/modules/classes/types.ts @@ -0,0 +1,194 @@ +export type TeacherClass = { + id: string + schoolName?: string | null + name: string + grade: string + homeroom?: string | null + room?: string | null + invitationCode?: string | null + studentCount: number +} + +export type TeacherOption = { + id: string + name: string + email: string +} + +export const DEFAULT_CLASS_SUBJECTS = ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"] as const + +export type ClassSubject = (typeof DEFAULT_CLASS_SUBJECTS)[number] + +export type ClassSubjectTeacherAssignment = { + subject: ClassSubject + teacher: TeacherOption | null +} + +export type AdminClassListItem = { + id: string + schoolName?: string | null + schoolId?: string | null + name: string + grade: string + gradeId?: string | null + homeroom?: string | null + room?: string | null + invitationCode?: string | null + teacher: TeacherOption + subjectTeachers: ClassSubjectTeacherAssignment[] + studentCount: number + createdAt: string + updatedAt: string +} + +export type CreateTeacherClassInput = { + schoolName?: string | null + schoolId?: string | null + name: string + grade: string + gradeId?: string | null + homeroom?: string | null + room?: string | null +} + +export type UpdateTeacherClassInput = { + schoolName?: string | null + schoolId?: string | null + name?: string + grade?: string + gradeId?: string | null + homeroom?: string | null + room?: string | null +} + +export type ClassStudent = { + id: string + name: string + email: string + classId: string + className: string + status: "active" | "inactive" +} + +export type ClassScheduleItem = { + id: string + classId: string + weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7 + startTime: string + endTime: string + course: string + location?: string | null +} + +export type StudentEnrolledClass = { + id: string + schoolName?: string | null + name: string + grade: string + homeroom?: string | null + room?: string | null +} + +export type StudentScheduleItem = { + id: string + classId: string + className: string + weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7 + startTime: string + endTime: string + course: string + location?: string | null +} + +export type CreateClassScheduleItemInput = { + classId: string + weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7 + startTime: string + endTime: string + course: string + location?: string | null +} + +export type UpdateClassScheduleItemInput = { + classId?: string + weekday?: 1 | 2 | 3 | 4 | 5 | 6 | 7 + startTime?: string + endTime?: string + course?: string + location?: string | null +} + +export type ClassBasicInfo = { + id: string + name: string + grade: string + homeroom?: string | null + room?: string | null + invitationCode?: string | null +} + +export type ScoreStats = { + count: number + avg: number | null + median: number | null + min: number | null + max: number | null +} + +export type ClassHomeworkAssignmentStats = { + assignmentId: string + title: string + status: string + createdAt: string + dueAt: string | null + isActive: boolean + isOverdue: boolean + maxScore: number + targetCount: number + submittedCount: number + gradedCount: number + scoreStats: ScoreStats +} + +export type ClassHomeworkInsights = { + class: ClassBasicInfo + studentCounts: { + total: number + active: number + inactive: number + } + assignments: ClassHomeworkAssignmentStats[] + latest: ClassHomeworkAssignmentStats | null + overallScores: ScoreStats +} + +export type GradeHomeworkClassSummary = { + class: ClassBasicInfo + studentCounts: { + total: number + active: number + inactive: number + } + latestAvg: number | null + prevAvg: number | null + deltaAvg: number | null + overallScores: ScoreStats +} + +export type GradeHomeworkInsights = { + grade: { + id: string + name: string + school: { id: string; name: string } + } + classCount: number + studentCounts: { + total: number + active: number + inactive: number + } + assignments: ClassHomeworkAssignmentStats[] + latest: ClassHomeworkAssignmentStats | null + overallScores: ScoreStats + classes: GradeHomeworkClassSummary[] +} diff --git a/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx b/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx new file mode 100644 index 0000000..63e905c --- /dev/null +++ b/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx @@ -0,0 +1,158 @@ +import type { ReactNode } from "react" +import { Users, LayoutDashboard, BookOpen, FileText, ClipboardList, Library, Activity } from "lucide-react" + +import type { AdminDashboardData } from "@/modules/dashboard/types" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" + +export function AdminDashboardView({ data }: { data: AdminDashboardData }) { + return ( +
+
+
+

Dashboard

+
System overview across users, learning content, and activity.
+
+
+ + + {data.activeSessionsCount} active sessions + + + + {data.userCount} users + +
+
+ +
+ } /> + } /> + } /> + } /> +
+ +
+ + + User Roles + + + {data.userRoleCounts.length === 0 ? ( + + ) : ( + data.userRoleCounts.map((r) => ( +
+ {r.role} +
{r.count}
+
+ )) + )} +
+
+ + + + Content + + + } /> + } /> + } /> + } /> + + + + + + Homework Activity + + + } /> + } /> + } /> + + +
+ + + + Recent Users + + + {data.recentUsers.length === 0 ? ( + + ) : ( + + + + Name + Email + Role + Created + + + + {data.recentUsers.map((u) => ( + + {u.name || "-"} + {u.email} + + {u.role ?? "unknown"} + + {formatDate(u.createdAt)} + + ))} + +
+ )} +
+
+
+ ) +} + +function KpiCard({ + title, + value, + icon, +}: { + title: string + value: number + icon: ReactNode +}) { + return ( + + + {title} +
{icon}
+
+ +
{value}
+
+
+ ) +} + +function ContentRow({ + label, + value, + icon, +}: { + label: string + value: number + icon: ReactNode +}) { + return ( +
+
+ {icon} +
{label}
+
+
{value}
+
+ ) +} diff --git a/src/modules/dashboard/components/admin-view.tsx b/src/modules/dashboard/components/admin-view.tsx deleted file mode 100644 index e69e048..0000000 --- a/src/modules/dashboard/components/admin-view.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client" - -import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" - -export function AdminDashboard() { - return ( -
-

Admin Dashboard

-
- - System Status - Operational - - - Total Users - 2,450 - - - Active Sessions - 142 - -
-
- ) -} diff --git a/src/modules/dashboard/components/student-dashboard/student-dashboard-header.tsx b/src/modules/dashboard/components/student-dashboard/student-dashboard-header.tsx new file mode 100644 index 0000000..f70329e --- /dev/null +++ b/src/modules/dashboard/components/student-dashboard/student-dashboard-header.tsx @@ -0,0 +1,17 @@ +import Link from "next/link" + +import { Button } from "@/shared/components/ui/button" + +export function StudentDashboardHeader({ studentName }: { studentName: string }) { + return ( +
+
+

Dashboard

+
Welcome back, {studentName}.
+
+ +
+ ) +} diff --git a/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx b/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx new file mode 100644 index 0000000..40c13af --- /dev/null +++ b/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx @@ -0,0 +1,42 @@ +import type { StudentDashboardProps } from "@/modules/dashboard/types" + +import { StudentDashboardHeader } from "./student-dashboard-header" +import { StudentGradesCard } from "./student-grades-card" +import { StudentRankingCard } from "./student-ranking-card" +import { StudentStatsGrid } from "./student-stats-grid" +import { StudentTodayScheduleCard } from "./student-today-schedule-card" +import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card" + +export function StudentDashboard({ + studentName, + enrolledClassCount, + dueSoonCount, + overdueCount, + gradedCount, + todayScheduleItems, + upcomingAssignments, + grades, +}: StudentDashboardProps) { + return ( +
+ + + + +
+ + +
+ +
+ + +
+
+ ) +} diff --git a/src/modules/dashboard/components/student-dashboard/student-grades-card.tsx b/src/modules/dashboard/components/student-dashboard/student-grades-card.tsx new file mode 100644 index 0000000..2b49eb6 --- /dev/null +++ b/src/modules/dashboard/components/student-dashboard/student-grades-card.tsx @@ -0,0 +1,99 @@ +import Link from "next/link" +import { BarChart3 } from "lucide-react" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" +import type { StudentDashboardGradeProps } from "@/modules/homework/types" + +export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) { + const hasGradeTrend = grades.trend.length > 0 + const hasRecentGrades = grades.recent.length > 0 + + return ( + + + + + Recent Grades + + + + {!hasGradeTrend ? ( + + ) : ( +
+
+ + { + const t = grades.trend.length > 1 ? i / (grades.trend.length - 1) : 0 + const x = t * 100 + const v = Number.isFinite(p.percentage) ? Math.max(0, Math.min(100, p.percentage)) : 0 + const y = 40 - (v / 100) * 40 + return `${x},${y}` + }) + .join(" ")} + className="text-primary" + /> + +
+
+ Latest:{" "} + + {Math.round(grades.trend[grades.trend.length - 1]?.percentage ?? 0)}% + +
+
+ Points:{" "} + + {grades.trend[grades.trend.length - 1]?.score ?? 0}/{grades.trend[grades.trend.length - 1]?.maxScore ?? 0} + +
+
+
+ + {!hasRecentGrades ? null : ( +
+ + + + Assignment + Score + When + + + + {grades.recent.map((r) => ( + + + + {r.assignmentTitle} + + + + {r.score}/{r.maxScore} ({Math.round(r.percentage)}%) + + {formatDate(r.submittedAt)} + + ))} + +
+
+ )} +
+ )} +
+
+ ) +} diff --git a/src/modules/dashboard/components/student-dashboard/student-ranking-card.tsx b/src/modules/dashboard/components/student-dashboard/student-ranking-card.tsx new file mode 100644 index 0000000..3f0b780 --- /dev/null +++ b/src/modules/dashboard/components/student-dashboard/student-ranking-card.tsx @@ -0,0 +1,47 @@ +import { Trophy } from "lucide-react" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" +import type { StudentRanking } from "@/modules/homework/types" + +export function StudentRankingCard({ ranking }: { ranking: StudentRanking | null }) { + return ( + + + + + Ranking + + + + {!ranking ? ( + + ) : ( +
+
+
+
Class Rank
+
+ {ranking.rank}/{ranking.classSize} +
+
+
+
Overall
+
{Math.round(ranking.percentage)}%
+
+ {ranking.totalScore}/{ranking.totalMaxScore} pts +
+
+
+
Based on latest graded submissions per assignment for your class.
+
+ )} +
+
+ ) +} diff --git a/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx b/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx new file mode 100644 index 0000000..5c24ab9 --- /dev/null +++ b/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx @@ -0,0 +1,66 @@ +import { BookOpen, CheckCircle2, PenTool, TriangleAlert } from "lucide-react" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" + +type Stat = { + title: string + value: string + description: string + icon: typeof BookOpen +} + +export function StudentStatsGrid({ + enrolledClassCount, + dueSoonCount, + overdueCount, + gradedCount, +}: { + enrolledClassCount: number + dueSoonCount: number + overdueCount: number + gradedCount: number +}) { + const stats: readonly Stat[] = [ + { + title: "My Classes", + value: String(enrolledClassCount), + description: "Enrolled classes", + icon: BookOpen, + }, + { + title: "Due Soon", + value: String(dueSoonCount), + description: "Next 7 days", + icon: PenTool, + }, + { + title: "Overdue", + value: String(overdueCount), + description: "Needs attention", + icon: TriangleAlert, + }, + { + title: "Graded", + value: String(gradedCount), + description: "With score", + icon: CheckCircle2, + }, + ] + + return ( +
+ {stats.map((stat) => ( + + + {stat.title} + + + +
{stat.value}
+
{stat.description}
+
+
+ ))} +
+ ) +} diff --git a/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx b/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx new file mode 100644 index 0000000..4d84db6 --- /dev/null +++ b/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx @@ -0,0 +1,58 @@ +import { CalendarDays, CalendarX, Clock, MapPin } from "lucide-react" + +import { Badge } from "@/shared/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" +import type { StudentTodayScheduleItem } from "@/modules/dashboard/types" + +export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) { + const hasSchedule = items.length > 0 + + return ( + + + + + Today's Schedule + + + + {!hasSchedule ? ( + + ) : ( +
+ {items.map((item) => ( +
+
+
{item.course}
+
+
+ + + {item.startTime}–{item.endTime} + +
+ {item.location ? ( +
+ + {item.location} +
+ ) : null} +
+
+ + {item.className} + +
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx b/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx new file mode 100644 index 0000000..8d51bb2 --- /dev/null +++ b/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx @@ -0,0 +1,83 @@ +import Link from "next/link" +import { PenTool } from "lucide-react" + +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" +import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types" + +const getStatusVariant = (status: string): "default" | "secondary" | "outline" => { + if (status === "graded") return "default" + if (status === "submitted") return "secondary" + if (status === "in_progress") return "secondary" + return "outline" +} + +const getStatusLabel = (status: string) => { + if (status === "graded") return "Graded" + if (status === "submitted") return "Submitted" + if (status === "in_progress") return "In progress" + return "Not started" +} + +export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) { + const hasAssignments = upcomingAssignments.length > 0 + + return ( + + + + + Upcoming Assignments + + + + + {!hasAssignments ? ( + + ) : ( +
+ + + + Title + Status + Due + Score + + + + {upcomingAssignments.map((a) => ( + + + + {a.title} + + + + + {getStatusLabel(a.progressStatus)} + + + {a.dueAt ? formatDate(a.dueAt) : "-"} + {a.latestScore ?? "-"} + + ))} + +
+
+ )} +
+
+ ) +} diff --git a/src/modules/dashboard/components/student-view.tsx b/src/modules/dashboard/components/student-view.tsx deleted file mode 100644 index 1947bc3..0000000 --- a/src/modules/dashboard/components/student-view.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client" - -import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" - -export function StudentDashboard() { - return ( -
-

Student Dashboard

-
- - My Courses - Enrolled in 5 courses - - - Assignments - 2 due this week - -
-
- ) -} diff --git a/src/modules/dashboard/components/recent-submissions.tsx b/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx similarity index 55% rename from src/modules/dashboard/components/recent-submissions.tsx rename to src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx index c72fb00..874d780 100644 --- a/src/modules/dashboard/components/recent-submissions.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx @@ -1,62 +1,22 @@ +import Link from "next/link"; + import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"; import { EmptyState } from "@/shared/components/ui/empty-state"; import { Inbox } from "lucide-react"; +import { formatDate } from "@/shared/lib/utils"; +import type { HomeworkSubmissionListItem } from "@/modules/homework/types"; -interface SubmissionItem { - id: string; - studentName: string; - studentAvatar?: string; - assignment: string; - submittedAt: string; - status: "submitted" | "late"; -} - -const MOCK_SUBMISSIONS: SubmissionItem[] = [ - { - id: "1", - studentName: "Alice Johnson", - assignment: "React Component Composition", - submittedAt: "10 minutes ago", - status: "submitted", - }, - { - id: "2", - studentName: "Bob Smith", - assignment: "Design System Analysis", - submittedAt: "1 hour ago", - status: "submitted", - }, - { - id: "3", - studentName: "Charlie Brown", - assignment: "React Component Composition", - submittedAt: "2 hours ago", - status: "late", - }, - { - id: "4", - studentName: "Diana Prince", - assignment: "CSS Grid Layout", - submittedAt: "Yesterday", - status: "submitted", - }, - { - id: "5", - studentName: "Evan Wright", - assignment: "Design System Analysis", - submittedAt: "Yesterday", - status: "submitted", - }, -]; - -export function RecentSubmissions() { - const hasSubmissions = MOCK_SUBMISSIONS.length > 0; +export function RecentSubmissions({ submissions }: { submissions: HomeworkSubmissionListItem[] }) { + const hasSubmissions = submissions.length > 0; return ( - Recent Submissions + + + Recent Submissions + {!hasSubmissions ? ( @@ -64,15 +24,16 @@ export function RecentSubmissions() { icon={Inbox} title="No New Submissions" description="All caught up! There are no new submissions to review." + action={{ label: "View submissions", href: "/teacher/homework/submissions" }} className="border-none h-[300px]" /> ) : (
- {MOCK_SUBMISSIONS.map((item) => ( + {submissions.map((item) => (
- + {item.studentName.charAt(0)}
@@ -80,16 +41,20 @@ export function RecentSubmissions() { {item.studentName}

- Submitted {item.assignment} + + {item.assignmentTitle} +

- {/* Using static date for demo to prevent hydration mismatch */} - {item.submittedAt} + {item.submittedAt ? formatDate(item.submittedAt) : "-"}
- {item.status === "late" && ( + {item.isLate && ( Late diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx new file mode 100644 index 0000000..e8b9e35 --- /dev/null +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx @@ -0,0 +1,86 @@ +import Link from "next/link" +import { Users } from "lucide-react" + +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" +import type { TeacherClass } from "@/modules/classes/types" + +export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) { + const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0) + const topClassesByStudents = [...classes].sort((a, b) => (b.studentCount ?? 0) - (a.studentCount ?? 0)).slice(0, 8) + const maxStudentCount = Math.max(1, ...topClassesByStudents.map((c) => c.studentCount ?? 0)) + + return ( + + + + + My Classes + + + + + {classes.length === 0 ? ( + + ) : ( + <> + {topClassesByStudents.length > 0 ? ( +
+
+
Students by class
+
Total {totalStudents}
+
+
+ {topClassesByStudents.map((c) => { + const count = c.studentCount ?? 0 + const pct = Math.max(0, Math.min(100, (count / maxStudentCount) * 100)) + return ( +
+
{c.name}
+
+
+
+
{count}
+
+ ) + })} +
+
+ ) : null} + + {classes.slice(0, 6).map((c) => ( + +
+
{c.name}
+
+ {c.grade} + {c.homeroom ? ` · ${c.homeroom}` : ""} + {c.room ? ` · ${c.room}` : ""} +
+
+ + + {c.studentCount} students + + + ))} + + )} + + + ) +} diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx new file mode 100644 index 0000000..053a9e1 --- /dev/null +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx @@ -0,0 +1,13 @@ +import { TeacherQuickActions } from "./teacher-quick-actions" + +export function TeacherDashboardHeader() { + return ( +
+
+

Teacher

+

Overview of today's work and your classes.

+
+ +
+ ) +} diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx new file mode 100644 index 0000000..1bdad80 --- /dev/null +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx @@ -0,0 +1,59 @@ +import type { TeacherDashboardData, TeacherTodayScheduleItem } from "@/modules/dashboard/types" + +import { TeacherClassesCard } from "./teacher-classes-card" +import { TeacherDashboardHeader } from "./teacher-dashboard-header" +import { TeacherHomeworkCard } from "./teacher-homework-card" +import { RecentSubmissions } from "./recent-submissions" +import { TeacherSchedule } from "./teacher-schedule" +import { TeacherStats } from "./teacher-stats" + +const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => { + const day = d.getDay() + return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7 +} + +export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) { + const totalStudents = data.classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0) + const todayWeekday = toWeekday(new Date()) + + const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const)) + const todayScheduleItems: TeacherTodayScheduleItem[] = data.schedule + .filter((s) => s.weekday === todayWeekday) + .sort((a, b) => a.startTime.localeCompare(b.startTime)) + .map((s): TeacherTodayScheduleItem => ({ + id: s.id, + classId: s.classId, + className: classNameById.get(s.classId) ?? "Class", + course: s.course, + startTime: s.startTime, + endTime: s.endTime, + location: s.location ?? null, + })) + + const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt)) + const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length + const recentSubmissions = submittedSubmissions.slice(0, 6) + + return ( +
+ + + + +
+ + +
+ +
+ + +
+
+ ) +} diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-homework-card.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-homework-card.tsx new file mode 100644 index 0000000..ec627db --- /dev/null +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-homework-card.tsx @@ -0,0 +1,56 @@ +import Link from "next/link" +import { PenTool } from "lucide-react" + +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" +import type { HomeworkAssignmentListItem } from "@/modules/homework/types" + +export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) { + return ( + + + + + Homework + +
+ + +
+
+ + {assignments.length === 0 ? ( + + ) : ( + assignments.slice(0, 6).map((a) => ( + +
+
{a.title}
+
{a.sourceExamTitle}
+
+ + {a.status} + + + )) + )} +
+
+ ) +} diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-quick-actions.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-quick-actions.tsx new file mode 100644 index 0000000..1020f14 --- /dev/null +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-quick-actions.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; + +import { Button } from "@/shared/components/ui/button"; +import { PlusCircle, CheckSquare, Users } from "lucide-react"; + +export function TeacherQuickActions() { + return ( +
+ + + +
+ ); +} diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx new file mode 100644 index 0000000..f432e92 --- /dev/null +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx @@ -0,0 +1,73 @@ +import Link from "next/link"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; +import { Badge } from "@/shared/components/ui/badge"; +import { Clock, MapPin, CalendarDays, CalendarX } from "lucide-react"; +import { EmptyState } from "@/shared/components/ui/empty-state"; + +type TeacherTodayScheduleItem = { + id: string; + classId: string; + className: string; + course: string; + startTime: string; + endTime: string; + location: string | null; +}; + +export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) { + const hasSchedule = items.length > 0; + + return ( + + + + + Today's Schedule + + + + {!hasSchedule ? ( + + ) : ( +
+ {items.map((item) => ( +
+
+ + {item.course} + +
+ + {item.startTime}–{item.endTime} + {item.location ? ( + <> + + {item.location} + + ) : null} +
+
+ + {item.className} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/modules/dashboard/components/teacher-stats.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx similarity index 63% rename from src/modules/dashboard/components/teacher-stats.tsx rename to src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx index da81e6e..844d59d 100644 --- a/src/modules/dashboard/components/teacher-stats.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx @@ -2,45 +2,21 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui import { Users, BookOpen, FileCheck, Calendar } from "lucide-react"; import { Skeleton } from "@/shared/components/ui/skeleton"; -interface StatItem { - title: string; - value: string; - description: string; - icon: React.ElementType; -} - -const MOCK_STATS: StatItem[] = [ - { - title: "Total Students", - value: "1,248", - description: "+12% from last semester", - icon: Users, - }, - { - title: "Active Courses", - value: "4", - description: "2 lectures, 2 workshops", - icon: BookOpen, - }, - { - title: "To Grade", - value: "28", - description: "5 submissions pending review", - icon: FileCheck, - }, - { - title: "Upcoming Classes", - value: "3", - description: "Today's schedule", - icon: Calendar, - }, -]; - interface TeacherStatsProps { + totalStudents: number; + classCount: number; + toGradeCount: number; + todayScheduleCount: number; isLoading?: boolean; } -export function TeacherStats({ isLoading = false }: TeacherStatsProps) { +export function TeacherStats({ + totalStudents, + classCount, + toGradeCount, + todayScheduleCount, + isLoading = false, +}: TeacherStatsProps) { if (isLoading) { return (
@@ -60,9 +36,36 @@ export function TeacherStats({ isLoading = false }: TeacherStatsProps) { ); } + const stats = [ + { + title: "Total Students", + value: String(totalStudents), + description: "Across all your classes", + icon: Users, + }, + { + title: "My Classes", + value: String(classCount), + description: "Active classes you manage", + icon: BookOpen, + }, + { + title: "To Grade", + value: String(toGradeCount), + description: "Submitted homework waiting for grading", + icon: FileCheck, + }, + { + title: "Today", + value: String(todayScheduleCount), + description: "Scheduled items today", + icon: Calendar, + }, + ] as const; + return (
- {MOCK_STATS.map((stat, i) => ( + {stats.map((stat, i) => ( diff --git a/src/modules/dashboard/components/teacher-quick-actions.tsx b/src/modules/dashboard/components/teacher-quick-actions.tsx deleted file mode 100644 index c33df9f..0000000 --- a/src/modules/dashboard/components/teacher-quick-actions.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Button } from "@/shared/components/ui/button"; -import { PlusCircle, CheckSquare, MessageSquare } from "lucide-react"; - -export function TeacherQuickActions() { - return ( -
- - - -
- ); -} diff --git a/src/modules/dashboard/components/teacher-schedule.tsx b/src/modules/dashboard/components/teacher-schedule.tsx deleted file mode 100644 index dc03c6e..0000000 --- a/src/modules/dashboard/components/teacher-schedule.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; -import { Badge } from "@/shared/components/ui/badge"; -import { Clock, MapPin, CalendarX } from "lucide-react"; -import { EmptyState } from "@/shared/components/ui/empty-state"; - -interface ScheduleItem { - id: string; - course: string; - time: string; - location: string; - type: "Lecture" | "Workshop" | "Lab"; -} - -// MOCK_SCHEDULE can be empty to test empty state -const MOCK_SCHEDULE: ScheduleItem[] = [ - { - id: "1", - course: "Advanced Web Development", - time: "09:00 AM - 10:30 AM", - location: "Room 304", - type: "Lecture", - }, - { - id: "2", - course: "UI/UX Design Principles", - time: "11:00 AM - 12:30 PM", - location: "Design Studio A", - type: "Workshop", - }, - { - id: "3", - course: "Frontend Frameworks", - time: "02:00 PM - 03:30 PM", - location: "Online (Zoom)", - type: "Lecture", - }, -]; - -export function TeacherSchedule() { - const hasSchedule = MOCK_SCHEDULE.length > 0; - - return ( - - - Today's Schedule - - - {!hasSchedule ? ( - - ) : ( -
- {MOCK_SCHEDULE.map((item) => ( -
-
-

{item.course}

-
- - {item.time} - - {item.location} -
-
- - {item.type} - -
- ))} -
- )} -
-
- ); -} diff --git a/src/modules/dashboard/components/teacher-view.tsx b/src/modules/dashboard/components/teacher-view.tsx deleted file mode 100644 index 39cafd1..0000000 --- a/src/modules/dashboard/components/teacher-view.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client" - -import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions"; -import { TeacherStats } from "@/modules/dashboard/components/teacher-stats"; -import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule"; -import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions"; - -// This component is now exclusively for the Teacher Role View -export function TeacherDashboard() { - return ( -
-
-

Teacher Dashboard

- -
- - - -
- - -
-
- ) -} diff --git a/src/modules/dashboard/data-access.ts b/src/modules/dashboard/data-access.ts new file mode 100644 index 0000000..3ab2b43 --- /dev/null +++ b/src/modules/dashboard/data-access.ts @@ -0,0 +1,103 @@ +import "server-only" + +import { cache } from "react" +import { count, desc, eq, gt } from "drizzle-orm" + +import { db } from "@/shared/db" +import { + chapters, + classes, + exams, + homeworkAssignments, + homeworkSubmissions, + questions, + sessions, + textbooks, + users, +} from "@/shared/db/schema" +import type { AdminDashboardData } from "./types" + +export const getAdminDashboardData = cache(async (): Promise => { + const now = new Date() + + const [ + activeSessionsRow, + userCountRow, + userRoleRows, + classCountRow, + textbookCountRow, + chapterCountRow, + questionCountRow, + examCountRow, + homeworkAssignmentCountRow, + homeworkAssignmentPublishedCountRow, + homeworkSubmissionCountRow, + homeworkSubmissionToGradeCountRow, + recentUserRows, + ] = await Promise.all([ + db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)), + db.select({ value: count() }).from(users), + db.select({ role: users.role, value: count() }).from(users).groupBy(users.role), + db.select({ value: count() }).from(classes), + db.select({ value: count() }).from(textbooks), + db.select({ value: count() }).from(chapters), + db.select({ value: count() }).from(questions), + db.select({ value: count() }).from(exams), + db.select({ value: count() }).from(homeworkAssignments), + db.select({ value: count() }).from(homeworkAssignments).where(eq(homeworkAssignments.status, "published")), + db.select({ value: count() }).from(homeworkSubmissions), + db.select({ value: count() }).from(homeworkSubmissions).where(eq(homeworkSubmissions.status, "submitted")), + db + .select({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + createdAt: users.createdAt, + }) + .from(users) + .orderBy(desc(users.createdAt)) + .limit(8), + ]) + + const activeSessionsCount = Number(activeSessionsRow[0]?.value ?? 0) + const userCount = Number(userCountRow[0]?.value ?? 0) + const classCount = Number(classCountRow[0]?.value ?? 0) + const textbookCount = Number(textbookCountRow[0]?.value ?? 0) + const chapterCount = Number(chapterCountRow[0]?.value ?? 0) + const questionCount = Number(questionCountRow[0]?.value ?? 0) + const examCount = Number(examCountRow[0]?.value ?? 0) + const homeworkAssignmentCount = Number(homeworkAssignmentCountRow[0]?.value ?? 0) + const homeworkAssignmentPublishedCount = Number(homeworkAssignmentPublishedCountRow[0]?.value ?? 0) + const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0) + const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0) + + const userRoleCounts = userRoleRows + .map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) })) + .sort((a, b) => b.count - a.count) + + const recentUsers = recentUserRows.map((u) => ({ + id: u.id, + name: u.name, + email: u.email, + role: u.role, + createdAt: u.createdAt.toISOString(), + })) + + return { + activeSessionsCount, + userCount, + userRoleCounts, + classCount, + textbookCount, + chapterCount, + questionCount, + examCount, + homeworkAssignmentCount, + homeworkAssignmentPublishedCount, + homeworkSubmissionCount, + homeworkSubmissionToGradeCount, + recentUsers, + } +}) + diff --git a/src/modules/dashboard/types.ts b/src/modules/dashboard/types.ts new file mode 100644 index 0000000..d0538ed --- /dev/null +++ b/src/modules/dashboard/types.ts @@ -0,0 +1,70 @@ +import type { StudentDashboardGradeProps, StudentHomeworkAssignmentListItem } from "@/modules/homework/types" +import type { TeacherClass, ClassScheduleItem } from "@/modules/classes/types" +import type { HomeworkAssignmentListItem, HomeworkSubmissionListItem } from "@/modules/homework/types" + +export type AdminDashboardUserRoleCount = { + role: string + count: number +} + +export type AdminDashboardRecentUser = { + id: string + name: string | null + email: string + role: string | null + createdAt: string +} + +export type AdminDashboardData = { + activeSessionsCount: number + userCount: number + userRoleCounts: AdminDashboardUserRoleCount[] + classCount: number + textbookCount: number + chapterCount: number + questionCount: number + examCount: number + homeworkAssignmentCount: number + homeworkAssignmentPublishedCount: number + homeworkSubmissionCount: number + homeworkSubmissionToGradeCount: number + recentUsers: AdminDashboardRecentUser[] +} + +export type StudentTodayScheduleItem = { + id: string + classId: string + className: string + course: string + startTime: string + endTime: string + location: string | null +} + +export type StudentDashboardProps = { + studentName: string + enrolledClassCount: number + dueSoonCount: number + overdueCount: number + gradedCount: number + todayScheduleItems: StudentTodayScheduleItem[] + upcomingAssignments: StudentHomeworkAssignmentListItem[] + grades: StudentDashboardGradeProps +} + +export type TeacherTodayScheduleItem = { + id: string + classId: string + className: string + course: string + startTime: string + endTime: string + location: string | null +} + +export type TeacherDashboardData = { + classes: TeacherClass[] + schedule: ClassScheduleItem[] + assignments: HomeworkAssignmentListItem[] + submissions: HomeworkSubmissionListItem[] +} diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts index adcb29f..21bf4d4 100644 --- a/src/modules/exams/actions.ts +++ b/src/modules/exams/actions.ts @@ -75,8 +75,7 @@ export async function createExamAction( startTime: scheduled ? new Date(scheduled) : null, status: "draft", }) - } catch (error) { - console.error("Failed to create exam:", error) + } catch { return { success: false, message: "Database error: Failed to create exam", @@ -156,8 +155,7 @@ export async function updateExamAction( await db.update(exams).set(updateData).where(eq(exams.id, examId)) } - } catch (error) { - console.error("Failed to update exam:", error) + } catch { return { success: false, message: "Database error: Failed to update exam", @@ -197,8 +195,7 @@ export async function deleteExamAction( try { await db.delete(exams).where(eq(exams.id, examId)) - } catch (error) { - console.error("Failed to delete exam:", error) + } catch { return { success: false, message: "Database error: Failed to delete exam", @@ -292,8 +289,7 @@ export async function duplicateExamAction( ) } }) - } catch (error) { - console.error("Failed to duplicate exam:", error) + } catch { return { success: false, message: "Database error: Failed to duplicate exam", diff --git a/src/modules/exams/components/exam-viewer.tsx b/src/modules/exams/components/exam-viewer.tsx new file mode 100644 index 0000000..5f8d722 --- /dev/null +++ b/src/modules/exams/components/exam-viewer.tsx @@ -0,0 +1,184 @@ +"use client" + +import { useMemo, type ReactNode } from "react" +import { cn } from "@/shared/lib/utils" + +type ChoiceOption = { + id: string + text: string +} + +type QuestionLike = { + questionId: string + questionType: string + questionContent: unknown + maxScore: number +} + +type ExamViewerProps = { + structure: unknown + questions: QuestionLike[] + className?: string + selectedQuestionId?: string | null + onQuestionSelect?: (questionId: string) => void +} + +const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null + +const getQuestionText = (content: unknown): string => { + if (!isRecord(content)) return "" + return typeof content.text === "string" ? content.text : "" +} + +const getOptions = (content: unknown): ChoiceOption[] => { + if (!isRecord(content)) return [] + const raw = content.options + if (!Array.isArray(raw)) return [] + const out: ChoiceOption[] = [] + for (const item of raw) { + if (!isRecord(item)) continue + const id = typeof item.id === "string" ? item.id : "" + const text = typeof item.text === "string" ? item.text : "" + if (!id || !text) continue + out.push({ id, text }) + } + return out +} + +export function ExamViewer(props: ExamViewerProps) { + const { structure, questions, className } = props + const questionById = useMemo(() => new Map(questions.map((q) => [q.questionId, q] as const)), [questions]) + + const questionNumberById = useMemo(() => { + const ids: string[] = [] + + const visit = (nodes: unknown) => { + if (!Array.isArray(nodes)) return + for (const node of nodes) { + if (!isRecord(node)) continue + if (node.type === "question") { + const questionId = typeof node.questionId === "string" ? node.questionId : "" + if (questionId) ids.push(questionId) + continue + } + if (node.type === "group") { + visit(Array.isArray(node.children) ? node.children : []) + } + } + } + + if (Array.isArray(structure) && structure.length > 0) { + visit(structure) + } else { + for (const q of questions) ids.push(q.questionId) + } + + const out = new Map() + let n = 0 + for (const id of ids) { + if (out.has(id)) continue + n += 1 + out.set(id, n) + } + return out + }, [structure, questions]) + + const renderNodes = (rawNodes: unknown, depth: number): ReactNode => { + if (!Array.isArray(rawNodes)) return null + + return ( +
0 ? "space-y-4 pl-4 border-l" : "space-y-6"}> + {rawNodes.map((node, idx) => { + if (!isRecord(node)) return null + const type = node.type + + if (type === "group") { + const title = typeof node.title === "string" && node.title.trim().length > 0 ? node.title : "Section" + const children = Array.isArray(node.children) ? node.children : [] + return ( +
+
+ {title} +
+ {renderNodes(children, depth + 1)} +
+ ) + } + + if (type === "question") { + const questionId = typeof node.questionId === "string" ? node.questionId : "" + if (!questionId) return null + const questionNumber = questionNumberById.get(questionId) ?? 0 + const q = questionById.get(questionId) ?? null + const text = getQuestionText(q?.questionContent ?? null) + const options = getOptions(q?.questionContent ?? null) + const scoreFromStructure = typeof node.score === "number" ? node.score : null + const maxScore = scoreFromStructure ?? q?.maxScore ?? 0 + const isSelected = props.selectedQuestionId === questionId + const isClickable = typeof props.onQuestionSelect === "function" + + return ( +
props.onQuestionSelect?.(questionId) : undefined} + onKeyDown={ + isClickable + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + props.onQuestionSelect?.(questionId) + } + } + : undefined + } + > +
+
{questionNumber > 0 ? `${questionNumber}.` : "—"}
+
+
{text || "—"}
+
+ {q?.questionType ?? "unknown"} + + Score: {maxScore} +
+
+
+ + {(q?.questionType === "single_choice" || q?.questionType === "multiple_choice") && options.length > 0 ? ( +
+ {options.map((opt) => ( +
+ {opt.id}. + {opt.text} +
+ ))} +
+ ) : null} +
+ ) + } + + return null + })} +
+ ) + } + + if (Array.isArray(structure) && structure.length > 0) { + return
{renderNodes(structure, 0)}
+ } + + if (questions.length > 0) { + const flatNodes = questions.map((q) => ({ type: "question", questionId: q.questionId, score: q.maxScore })) + return
{renderNodes(flatNodes, 0)}
+ } + + return
No questions available.
+} diff --git a/src/modules/homework/actions.ts b/src/modules/homework/actions.ts index d7ebb4c..9f8eb5d 100644 --- a/src/modules/homework/actions.ts +++ b/src/modules/homework/actions.ts @@ -7,6 +7,8 @@ import { and, count, eq } from "drizzle-orm" import { db } from "@/shared/db" import { + classes, + classEnrollments, exams, homeworkAnswers, homeworkAssignmentQuestions, @@ -78,6 +80,7 @@ export async function createHomeworkAssignmentAction( const parsed = CreateHomeworkAssignmentSchema.safeParse({ sourceExamId: formData.get("sourceExamId"), + classId: formData.get("classId"), title: formData.get("title") || undefined, description: formData.get("description") || undefined, availableAt: formData.get("availableAt") || undefined, @@ -105,6 +108,13 @@ export async function createHomeworkAssignmentAction( const input = parsed.data const publish = input.publish ?? true + const [ownedClass] = await db + .select({ id: classes.id }) + .from(classes) + .where(user.role === "admin" ? eq(classes.id, input.classId) : and(eq(classes.id, input.classId), eq(classes.teacherId, user.id))) + .limit(1) + if (!ownedClass) return { success: false, message: "Class not found" } + const exam = await db.query.exams.findFirst({ where: eq(exams.id, input.sourceExamId), with: { @@ -122,15 +132,30 @@ export async function createHomeworkAssignmentAction( const dueAt = input.dueAt ? new Date(input.dueAt) : null const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null + const classStudentIds = ( + await db + .select({ studentId: classEnrollments.studentId }) + .from(classEnrollments) + .innerJoin(classes, eq(classes.id, classEnrollments.classId)) + .where( + and( + eq(classEnrollments.classId, input.classId), + eq(classEnrollments.status, "active"), + user.role === "admin" ? eq(classes.id, input.classId) : eq(classes.teacherId, user.id) + ) + ) + ).map((r) => r.studentId) + + const classStudentIdSet = new Set(classStudentIds) + const targetStudentIds = input.targetStudentIds && input.targetStudentIds.length > 0 - ? input.targetStudentIds - : ( - await db - .select({ id: users.id }) - .from(users) - .where(eq(users.role, "student")) - ).map((r) => r.id) + ? input.targetStudentIds.filter((id) => classStudentIdSet.has(id)) + : classStudentIds + + if (publish && targetStudentIds.length === 0) { + return { success: false, message: "No active students in this class" } + } await db.transaction(async (tx) => { await tx.insert(homeworkAssignments).values({ diff --git a/src/modules/homework/components/homework-assignment-exam-content-card.tsx b/src/modules/homework/components/homework-assignment-exam-content-card.tsx new file mode 100644 index 0000000..5842901 --- /dev/null +++ b/src/modules/homework/components/homework-assignment-exam-content-card.tsx @@ -0,0 +1,29 @@ +import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { HomeworkAssignmentExamErrorExplorerLazy } from "@/modules/homework/components/homework-assignment-exam-error-explorer-lazy" + +export function HomeworkAssignmentExamContentCard({ + structure, + questions, + gradedSampleCount, +}: { + structure: unknown + questions: HomeworkAssignmentQuestionAnalytics[] + gradedSampleCount: number +}) { + return ( + + + Exam Content + + + + + + ) +} diff --git a/src/modules/homework/components/homework-assignment-exam-error-explorer-lazy.tsx b/src/modules/homework/components/homework-assignment-exam-error-explorer-lazy.tsx new file mode 100644 index 0000000..c1984a2 --- /dev/null +++ b/src/modules/homework/components/homework-assignment-exam-error-explorer-lazy.tsx @@ -0,0 +1,79 @@ +"use client" + +import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types" +import dynamic from "next/dynamic" + +import { Skeleton } from "@/shared/components/ui/skeleton" + +function ExamErrorExplorerFallback() { + return ( +
+
+
题目
+
+ + + + + +
+
+ +
+
+
错题详情
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + + + +
+
+
+ ) +} + +const LazyHomeworkAssignmentExamErrorExplorer = dynamic( + () => + import("./homework-assignment-exam-error-explorer").then((m) => ({ + default: m.HomeworkAssignmentExamErrorExplorer, + })), + { ssr: false, loading: ExamErrorExplorerFallback } +) + +export function HomeworkAssignmentExamErrorExplorerLazy({ + structure, + questions, + gradedSampleCount, +}: { + structure: unknown + questions: HomeworkAssignmentQuestionAnalytics[] + gradedSampleCount: number +}) { + return ( + + ) +} + diff --git a/src/modules/homework/components/homework-assignment-exam-error-explorer.tsx b/src/modules/homework/components/homework-assignment-exam-error-explorer.tsx new file mode 100644 index 0000000..f124c9a --- /dev/null +++ b/src/modules/homework/components/homework-assignment-exam-error-explorer.tsx @@ -0,0 +1,44 @@ +"use client" + +import { useMemo, useState } from "react" + +import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types" +import { HomeworkAssignmentExamPreviewPane } from "@/modules/homework/components/homework-assignment-exam-preview-pane" +import { HomeworkAssignmentQuestionErrorDetailPanel } from "@/modules/homework/components/homework-assignment-question-error-detail-panel" + +export function HomeworkAssignmentExamErrorExplorer({ + structure, + questions, + gradedSampleCount, + heightClassName = "h-[560px]", +}: { + structure: unknown + questions: HomeworkAssignmentQuestionAnalytics[] + gradedSampleCount: number + heightClassName?: string +}) { + const firstQuestionId = questions[0]?.questionId ?? null + const [selectedQuestionId, setSelectedQuestionId] = useState(firstQuestionId) + + const selected = useMemo(() => { + if (!selectedQuestionId) return null + return questions.find((q) => q.questionId === selectedQuestionId) ?? null + }, [questions, selectedQuestionId]) + + return ( +
+ ({ + questionId: q.questionId, + questionType: q.questionType, + questionContent: q.questionContent, + maxScore: q.maxScore, + }))} + selectedQuestionId={selectedQuestionId} + onQuestionSelect={setSelectedQuestionId} + /> + +
+ ) +} diff --git a/src/modules/homework/components/homework-assignment-exam-preview-pane.tsx b/src/modules/homework/components/homework-assignment-exam-preview-pane.tsx new file mode 100644 index 0000000..4b87522 --- /dev/null +++ b/src/modules/homework/components/homework-assignment-exam-preview-pane.tsx @@ -0,0 +1,35 @@ +"use client" + +import { ExamViewer } from "@/modules/exams/components/exam-viewer" +import { ScrollArea } from "@/shared/components/ui/scroll-area" + +export function HomeworkAssignmentExamPreviewPane({ + structure, + questions, + selectedQuestionId, + onQuestionSelect, +}: { + structure: unknown + questions: Array<{ + questionId: string + questionType: string + questionContent: unknown + maxScore: number + }> + selectedQuestionId: string | null + onQuestionSelect: (questionId: string) => void +}) { + return ( +
+
题目
+ + + +
+ ) +} diff --git a/src/modules/homework/components/homework-assignment-form.tsx b/src/modules/homework/components/homework-assignment-form.tsx index 0ab859c..e7496b5 100644 --- a/src/modules/homework/components/homework-assignment-form.tsx +++ b/src/modules/homework/components/homework-assignment-form.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react" import { useFormStatus } from "react-dom" import { toast } from "sonner" import { useRouter } from "next/navigation" +import { useSearchParams } from "next/navigation" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" @@ -13,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Textarea } from "@/shared/components/ui/textarea" import { createHomeworkAssignmentAction } from "../actions" +import type { TeacherClass } from "@/modules/classes/types" type ExamOption = { id: string; title: string } @@ -25,11 +27,18 @@ function SubmitButton() { ) } -export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) { +export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]; classes: TeacherClass[] }) { const router = useRouter() + const searchParams = useSearchParams() const initialExamId = useMemo(() => exams[0]?.id ?? "", [exams]) const [examId, setExamId] = useState(initialExamId) + const initialClassId = useMemo(() => { + const fromQuery = searchParams.get("classId") || "" + if (fromQuery && classes.some((c) => c.id === fromQuery)) return fromQuery + return classes[0]?.id ?? "" + }, [classes, searchParams]) + const [classId, setClassId] = useState(initialClassId) const [allowLate, setAllowLate] = useState(false) const handleSubmit = async (formData: FormData) => { @@ -37,7 +46,12 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) { toast.error("Please select an exam") return } + if (!classId) { + toast.error("Please select a class") + return + } formData.set("sourceExamId", examId) + formData.set("classId", classId) formData.set("allowLate", allowLate ? "true" : "false") formData.set("publish", "true") @@ -58,6 +72,23 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
+
+ + + +
+