refactor: P1-3/4/6 解耦修复 - 拆分 auth/users 文件 + notifications 反向依赖
This commit is contained in:
@@ -176,6 +176,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
──▶ import { ... } from "@/shared/lib/login-logger"
|
||||
──▶ import { ... } from "@/shared/lib/password-policy"
|
||||
──▶ import { ... } from "@/shared/lib/rate-limit"
|
||||
──▶ import { ... } from "@/shared/lib/role-utils" # P1-3 拆出
|
||||
──▶ import { ... } from "@/shared/lib/bcrypt-utils" # P1-3 拆出
|
||||
──▶ import { ... } from "@/shared/lib/http-utils" # P1-3 拆出
|
||||
──▶ import { ... } from "@/shared/lib/password-security-service" # P1-3 拆出
|
||||
──▶ import { db, schema } from "@/shared/db"
|
||||
|
||||
⟳ 循环:shared/lib/* → @/auth → shared/lib/*
|
||||
@@ -365,7 +369,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**已知问题**:
|
||||
- ❌ P0:`shared/lib/*` ↔ `@/auth` 循环依赖
|
||||
- ⚠️ P1:`schema.ts` 1111 行(54 张表混合,超 1000 硬上限)
|
||||
- ⚠️ P1:`auth.ts` 293 行混合 5 类职责
|
||||
- ✅ P1:~~`auth.ts` 293 行混合 5 类职责~~ 已拆分(4 个辅助函数组迁移至 `shared/lib/{role-utils,bcrypt-utils,http-utils,password-security-service}`,auth.ts 仅保留 NextAuth 配置)
|
||||
- ⚠️ P2:`ai.ts` 218 行混合 5 类职责
|
||||
- ⚠️ P2:`onboarding-gate.tsx` 业务逻辑泄漏到 shared
|
||||
|
||||
@@ -383,6 +387,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `lib/login-logger.ts` | - | 登录日志 |
|
||||
| `lib/password-policy.ts` | - | 密码策略纯函数 |
|
||||
| `lib/rate-limit.ts` | - | 内存滑动窗口限流 |
|
||||
| `lib/role-utils.ts` | 35 | 角色规范化纯函数(normalizeRole / resolvePrimaryRole) |
|
||||
| `lib/bcrypt-utils.ts` | 22 | bcrypt 哈希前缀规范化纯函数 |
|
||||
| `lib/http-utils.ts` | 30 | 请求头解析(resolveClientIp,server-only) |
|
||||
| `lib/password-security-service.ts` | 100 | 密码安全 DB 操作(账户锁定/失败登录追踪,server-only) |
|
||||
| `lib/excel.ts` | - | Excel 导入导出 |
|
||||
| `lib/file-storage.ts` | - | 文件存储抽象 |
|
||||
| `hooks/use-permission.ts` | - | 客户端权限 Hook |
|
||||
@@ -669,22 +677,26 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**导出函数**:
|
||||
- Actions:`getUserProfileAction` / `updateUserProfileAction` / `importUsersAction` / `exportUsersAction` / `downloadUserTemplateAction`
|
||||
- Data-access:`getUserProfile`
|
||||
- Import-export:`generateUserImportTemplate` / `parseUserImportData` / `batchImportUsers` / `exportUsersToExcel`
|
||||
- Import-export:`generateUserImportTemplate` / `parseUserImportData` / `exportUsersToExcel`(+ re-export `batchImportUsers` / `UserImportResult` 保持向后兼容)
|
||||
- User-service:`batchImportUsers`(用户创建 + 密码哈希 + 角色分配)
|
||||
- Class-registration:`registerStudentByInvitationCode`(委托 classes/data-access 完成班级注册)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`classes`(❌ `batchImportUsers` 直查 classes + 直写 classEnrollments)
|
||||
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-4 已修复:通过 `class-registration.ts` 调用 `classes/data-access.enrollStudentByInvitationCode`,不再直写 classEnrollments)
|
||||
- 被依赖:`dashboard`(通过 data-access,P0-4 已修复)、`grades`(❌ 直查)、`homework`(❌ 直查)
|
||||
|
||||
**已知问题**:
|
||||
- ❌ P1:`import-export.ts` 四重职责混合(导入解析 + 导出 + 用户创建 + 班级注册)
|
||||
- ❌ P1:`batchImportUsers` 跨模块写 `classEnrollments`(classes 模块的写操作)
|
||||
- ✅ P1 已解决:`import-export.ts` 四重职责已拆分为 `import-export.ts`(解析/生成)+ `user-service.ts`(用户创建)+ `class-registration.ts`(班级注册)
|
||||
- ✅ P1 已解决:`batchImportUsers` 不再跨模块直写 `classEnrollments`,改为调用 `classes/data-access.enrollStudentByInvitationCode`
|
||||
- ❌ P1:`updateUserProfile` 绕过 data-access 直接 DB 写
|
||||
- ⚠️ P2:`data-access.ts` 仅 71 行,写操作缺失
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `import-export.ts` | 291 | 导入解析 + 导出 + 用户创建(职责混合) |
|
||||
| `import-export.ts` | 177 | 文件解析/生成(模板生成 + 解析校验 + Excel 导出)+ re-export 向后兼容 |
|
||||
| `user-service.ts` | 96 | 用户创建(批量导入 + 密码哈希 + 角色分配) |
|
||||
| `class-registration.ts` | 30 | 班级注册(委托 classes/data-access) |
|
||||
| `actions.ts` | 151 | 5 个 Server Action |
|
||||
| `data-access.ts` | 71 | 仅 getUserProfile |
|
||||
|
||||
@@ -1196,24 +1208,28 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
||||
|
||||
**解耦建议**:actions 层仅做"权限校验 → 解析 → 调用 data-access → revalidatePath → 返回",所有 DB 操作下沉到 data-access。
|
||||
|
||||
### P1-3:auth.ts 混合 5 类职责
|
||||
### P1-3:auth.ts 混合 5 类职责 ✅ 已完成
|
||||
|
||||
`src/auth.ts` 293 行混合:NextAuth 配置 + 密码安全 DB 操作 + 角色规范化 + IP 解析 + 回调函数。
|
||||
`src/auth.ts` 原 293 行混合:NextAuth 配置 + 密码安全 DB 操作 + 角色规范化 + IP 解析 + 回调函数。
|
||||
|
||||
**解耦建议**:
|
||||
- 密码安全 DB 操作 → `shared/lib/password-security-service.ts`
|
||||
- 角色规范化 → `shared/lib/role-utils.ts`
|
||||
- IP 解析 → `shared/lib/http-utils.ts`(与三个 logger 共用)
|
||||
- `authorize` 回调拆分为 `checkRateLimit` / `checkAccountLockout` / `verifyPassword` / `loadUserRoles`
|
||||
**已完成拆分**(auth.ts 现 208 行,仅保留 NextAuth 配置):
|
||||
- ✅ 密码安全 DB 操作 → `shared/lib/password-security-service.ts`(getOrCreatePasswordSecurity / recordFailedLogin / resetFailedLogin,server-only)
|
||||
- ✅ 角色规范化 → `shared/lib/role-utils.ts`(normalizeRole / resolvePrimaryRole,纯函数)
|
||||
- ✅ bcrypt 哈希规范化 → `shared/lib/bcrypt-utils.ts`(normalizeBcryptHash,纯函数)
|
||||
- ✅ IP 解析 → `shared/lib/http-utils.ts`(resolveClientIp,server-only)
|
||||
|
||||
### P1-4:users/import-export.ts 四重职责
|
||||
**后续可选优化**(未执行,需保持 NextAuth 配置不变原则下评估):
|
||||
- `authorize` 回调可进一步拆分为 `checkRateLimit` / `checkAccountLockout` / `verifyPassword` / `loadUserRoles`,使 auth.ts 降至 ≤150 行
|
||||
|
||||
同时处理:导入解析 + 导出 + 用户创建(含密码哈希)+ 班级注册(跨模块写 classEnrollments)。
|
||||
### P1-4:users/import-export.ts 四重职责 ✅ 已完成
|
||||
|
||||
**解耦建议**:
|
||||
- 拆分为 `import.ts`(解析+校验)+ `export.ts`(模板生成+列表导出)
|
||||
- 用户创建逻辑迁移至 `data-access.ts` 的 `createUser`
|
||||
- classEnrollments 写入改为调用 `classes/data-access.enrollStudentByInvitationCode`
|
||||
`users/import-export.ts` 原 291 行混合:导入解析 + 导出 + 用户创建(含密码哈希)+ 班级注册(跨模块写 classEnrollments)。
|
||||
|
||||
**已完成拆分**(import-export.ts 现 177 行,仅保留文件解析/生成):
|
||||
- ✅ 用户创建(含密码哈希、角色分配)→ `user-service.ts`(`batchImportUsers`,96 行,server-only)
|
||||
- ✅ 班级注册 → `class-registration.ts`(`registerStudentByInvitationCode`,30 行,server-only)
|
||||
- ✅ `batchImportUsers` 不再直写 `classEnrollments`,改为调用 `classes/data-access.enrollStudentByInvitationCode`
|
||||
- ✅ `import-export.ts` 通过 re-export `batchImportUsers` / `UserImportResult` 保持向后兼容(`actions.ts` 和 `app/api/export/route.ts` 无需修改)
|
||||
|
||||
### P1-5:notifications 反向依赖 messaging
|
||||
|
||||
@@ -1269,8 +1285,8 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
||||
|
||||
### 短期执行(P1)
|
||||
8. actions 层移除直接 DB 操作(exams/homework/questions/announcements/users/scheduling)
|
||||
9. 拆分 `auth.ts`
|
||||
10. 拆分 `users/import-export.ts`
|
||||
9. ~~拆分 `auth.ts`~~ ✅ 已完成(4 个辅助函数组迁移至 shared/lib,auth.ts 保留 NextAuth 配置)
|
||||
10. ~~拆分 `users/import-export.ts`~~ ✅ 已完成(拆为 import-export.ts 177行 + user-service.ts 96行 + class-registration.ts 30行,班级注册改为调用 classes/data-access)
|
||||
11. 消除 notifications → messaging 反向依赖
|
||||
12. 提取 `shared/lib/http-utils.ts` 统一 IP 提取
|
||||
13. 各模块暴露跨模块查询接口(见 P1-1)
|
||||
@@ -1316,7 +1332,7 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
||||
| **school** | ✅ | ✅ | - | - | - | - | - | - | - | ⚠️可接受 | - | - | - | - |
|
||||
| **grades** | ✅ | ✅ | ✅外键 | ✅外键 | - | - | ❌直查 | ❌直查 | - | ❌直查 | - | - | - | - |
|
||||
| **dashboard** | ✅ | ✅ | ✅data-access | ✅data-access | ✅data-access | ✅data-access | ✅data-access | - | - | ✅data-access | - | - | - | - |
|
||||
| **users** | ✅ | ✅ | - | - | - | - | ❌写enrollments | - | - | - | - | - | - | - |
|
||||
| **users** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | - | - | - | - | - |
|
||||
| **messaging** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | - | ❌绕过 | - |
|
||||
| **notifications** | ✅ | ✅ | - | - | - | - | ❌直查 | - | - | - | - | ⟳反向依赖 | - | - |
|
||||
| **attendance** | ✅ | ✅ | - | - | - | - | ❌直查 | - | - | - | - | - | - | - |
|
||||
|
||||
@@ -527,6 +527,97 @@
|
||||
"auth.ts (events.signIn, events.signOut)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "normalizeRole",
|
||||
"file": "lib/role-utils.ts",
|
||||
"signature": "normalizeRole(value: unknown): NormalizedRole",
|
||||
"purpose": "将角色值规范化为 admin/teacher/student/parent 之一(纯函数,legacy 别名 grade_head/teaching_head→teacher)",
|
||||
"deps": [],
|
||||
"usedBy": [
|
||||
"auth.ts (jwt/session callbacks)",
|
||||
"lib/role-utils.resolvePrimaryRole"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resolvePrimaryRole",
|
||||
"file": "lib/role-utils.ts",
|
||||
"signature": "resolvePrimaryRole(roleNames: string[]): NormalizedRole",
|
||||
"purpose": "从多角色列表解析主角色(优先级 admin>teacher>parent>student,纯函数)",
|
||||
"deps": [
|
||||
"lib/role-utils.normalizeRole"
|
||||
],
|
||||
"usedBy": [
|
||||
"auth.ts (authorize, jwt callback)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "normalizeBcryptHash",
|
||||
"file": "lib/bcrypt-utils.ts",
|
||||
"signature": "normalizeBcryptHash(value: string): string",
|
||||
"purpose": "将存储的 bcrypt 哈希规范化为 $2b$ 前缀形式(纯函数,兼容 legacy 无前缀存储)",
|
||||
"deps": [],
|
||||
"usedBy": [
|
||||
"auth.ts (authorize)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resolveClientIp",
|
||||
"file": "lib/http-utils.ts",
|
||||
"signature": "resolveClientIp(): Promise<string>",
|
||||
"purpose": "从请求头解析客户端 IP(x-forwarded-for/x-real-ip,best-effort,失败返回 unknown)",
|
||||
"deps": [
|
||||
"next/headers"
|
||||
],
|
||||
"usedBy": [
|
||||
"auth.ts (authorize 速率限制键)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "getOrCreatePasswordSecurity",
|
||||
"file": "lib/password-security-service.ts",
|
||||
"signature": "getOrCreatePasswordSecurity(db, passwordSecurity, userId: string): Promise<PasswordSecurityRow>",
|
||||
"purpose": "获取或创建用户的 password_security 行(server-only)",
|
||||
"deps": [
|
||||
"drizzle-orm.eq",
|
||||
"@paralleldrive/cuid2",
|
||||
"shared.db",
|
||||
"shared.db.schema.passwordSecurity"
|
||||
],
|
||||
"usedBy": [
|
||||
"auth.ts (authorize)",
|
||||
"lib/password-security-service.recordFailedLogin",
|
||||
"lib/password-security-service.resetFailedLogin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "recordFailedLogin",
|
||||
"file": "lib/password-security-service.ts",
|
||||
"signature": "recordFailedLogin(db, passwordSecurity, userId: string): Promise<{ locked: boolean; lockedUntil: Date | null }>",
|
||||
"purpose": "递增失败登录计数,达到阈值则锁定账户(server-only)",
|
||||
"deps": [
|
||||
"lib/password-security-service.getOrCreatePasswordSecurity",
|
||||
"lib/password-policy.PASSWORD_RULES",
|
||||
"shared.db",
|
||||
"shared.db.schema.passwordSecurity"
|
||||
],
|
||||
"usedBy": [
|
||||
"auth.ts (authorize 密码校验失败分支)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resetFailedLogin",
|
||||
"file": "lib/password-security-service.ts",
|
||||
"signature": "resetFailedLogin(db, passwordSecurity, userId: string): Promise<void>",
|
||||
"purpose": "登录成功后重置失败计数与锁定状态(server-only)",
|
||||
"deps": [
|
||||
"lib/password-security-service.getOrCreatePasswordSecurity",
|
||||
"shared.db",
|
||||
"shared.db.schema.passwordSecurity"
|
||||
],
|
||||
"usedBy": [
|
||||
"auth.ts (authorize 登录成功分支)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "isAllowedMimeType",
|
||||
"file": "lib/file-storage.ts",
|
||||
@@ -1801,7 +1892,19 @@
|
||||
},
|
||||
"auth": {
|
||||
"path": "src/auth.ts",
|
||||
"description": "用户认证:NextAuth配置、JWT/Session callbacks、events回调(登录日志)、middleware。集成密码安全策略(账户锁定、失败登录追踪)和登录速率限制",
|
||||
"description": "用户认证:NextAuth配置(handlers/auth/signIn/signOut)、JWT/Session callbacks、events回调(登录日志)。集成密码安全策略(账户锁定、失败登录追踪)和登录速率限制。P1-3 拆分后,辅助函数已迁移至 shared/lib/{role-utils,bcrypt-utils,http-utils,password-security-service}",
|
||||
"imports": [
|
||||
"shared/lib/permissions",
|
||||
"shared/lib/login-logger",
|
||||
"shared/lib/password-policy",
|
||||
"shared/lib/rate-limit",
|
||||
"shared/lib/role-utils",
|
||||
"shared/lib/bcrypt-utils",
|
||||
"shared/lib/http-utils",
|
||||
"shared/lib/password-security-service",
|
||||
"shared/db",
|
||||
"shared/db/schema"
|
||||
],
|
||||
"exports": {
|
||||
"functions": [
|
||||
{
|
||||
@@ -4850,25 +4953,6 @@
|
||||
"actions.importUsersAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "batchImportUsers",
|
||||
"signature": "(records: UserImportRecord[]) => Promise<UserImportResult>",
|
||||
"file": "import-export.ts",
|
||||
"purpose": "批量创建用户(默认密码 123456 bcrypt 哈希,自动创建 usersToRoles,student 通过邀请码自动加入班级)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.users",
|
||||
"shared.db.schema.roles",
|
||||
"shared.db.schema.usersToRoles",
|
||||
"shared.db.schema.classes",
|
||||
"shared.db.schema.classEnrollments",
|
||||
"bcryptjs",
|
||||
"@paralleldrive/cuid2"
|
||||
],
|
||||
"usedBy": [
|
||||
"actions.importUsersAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exportUsersToExcel",
|
||||
"signature": "(params: { scope: DataScope; role?: string }) => Promise<Buffer>",
|
||||
@@ -4887,6 +4971,47 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"userService": [
|
||||
{
|
||||
"name": "batchImportUsers",
|
||||
"signature": "(records: UserImportRecord[]) => Promise<UserImportResult>",
|
||||
"file": "user-service.ts",
|
||||
"purpose": "批量创建用户(默认密码 123456 bcrypt 哈希,自动创建 usersToRoles,student 通过邀请码自动加入班级——委托 class-registration)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.users",
|
||||
"shared.db.schema.roles",
|
||||
"shared.db.schema.usersToRoles",
|
||||
"bcryptjs",
|
||||
"@paralleldrive/cuid2",
|
||||
"class-registration.registerStudentByInvitationCode"
|
||||
],
|
||||
"usedBy": [
|
||||
"actions.importUsersAction",
|
||||
"import-export.ts (re-export 向后兼容)"
|
||||
]
|
||||
}
|
||||
],
|
||||
"classRegistration": [
|
||||
{
|
||||
"name": "registerStudentByInvitationCode",
|
||||
"signature": "(studentId: string, invitationCode: string) => Promise<ClassRegistrationResult>",
|
||||
"file": "class-registration.ts",
|
||||
"purpose": "通过邀请码将学生注册到班级,委托 classes/data-access.enrollStudentByInvitationCode,返回结构化结果(不抛异常)",
|
||||
"deps": [
|
||||
"classes/data-access.enrollStudentByInvitationCode"
|
||||
],
|
||||
"usedBy": [
|
||||
"user-service.batchImportUsers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ClassRegistrationResult",
|
||||
"type": "type",
|
||||
"file": "class-registration.ts",
|
||||
"definition": "{ success: boolean; error?: string }"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
{
|
||||
"name": "UserImportRecord",
|
||||
@@ -4910,11 +5035,12 @@
|
||||
{
|
||||
"name": "UserImportResult",
|
||||
"type": "type",
|
||||
"file": "import-export.ts",
|
||||
"file": "user-service.ts",
|
||||
"definition": "{ successCount, failedCount, errors: Array<{ row, email, error }> }",
|
||||
"usedBy": [
|
||||
"batchImportUsers",
|
||||
"importUsersAction"
|
||||
"importUsersAction",
|
||||
"import-export.ts (re-export 向后兼容)"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -10255,7 +10381,8 @@
|
||||
"users": {
|
||||
"dependsOn": [
|
||||
"shared",
|
||||
"auth"
|
||||
"auth",
|
||||
"classes"
|
||||
],
|
||||
"uses": {
|
||||
"shared": [
|
||||
@@ -10265,14 +10392,15 @@
|
||||
"db.schema.users",
|
||||
"db.schema.roles",
|
||||
"db.schema.usersToRoles",
|
||||
"db.schema.classes",
|
||||
"db.schema.classEnrollments",
|
||||
"types.permissions",
|
||||
"types.action-state",
|
||||
"lib.excel"
|
||||
],
|
||||
"auth": [
|
||||
"auth"
|
||||
],
|
||||
"classes": [
|
||||
"data-access.enrollStudentByInvitationCode"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -10952,7 +11080,9 @@
|
||||
"title": "auth.ts 混合 5 类职责",
|
||||
"file": "src/auth.ts",
|
||||
"problem": "NextAuth 配置 + 密码安全 DB 操作 + 角色规范化 + IP 解析 + 回调函数混合",
|
||||
"suggestion": "拆分为 auth-config/password-security/role-normalizer/ip-utils 等多文件"
|
||||
"suggestion": "拆分为 auth-config/password-security/role-normalizer/ip-utils 等多文件",
|
||||
"status": "resolved",
|
||||
"resolution": "已拆分:密码安全DB操作→shared/lib/password-security-service.ts,角色规范化→shared/lib/role-utils.ts,bcrypt哈希规范化→shared/lib/bcrypt-utils.ts,IP解析→shared/lib/http-utils.ts。auth.ts 现 208 行仅保留 NextAuth 配置"
|
||||
},
|
||||
{
|
||||
"id": "P1-4",
|
||||
|
||||
127
src/auth.ts
127
src/auth.ts
@@ -9,125 +9,14 @@ import {
|
||||
isAccountLocked,
|
||||
} from "@/shared/lib/password-policy"
|
||||
import { RATE_LIMIT_RULES, rateLimit, rateLimitKey, resetRateLimit } from "@/shared/lib/rate-limit"
|
||||
|
||||
const normalizeRole = (value: unknown) => {
|
||||
const role = String(value ?? "").trim().toLowerCase()
|
||||
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||
return "student"
|
||||
}
|
||||
|
||||
const resolvePrimaryRole = (roleNames: string[]) => {
|
||||
const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean)
|
||||
if (mapped.includes("admin")) return "admin"
|
||||
if (mapped.includes("teacher")) return "teacher"
|
||||
if (mapped.includes("parent")) return "parent"
|
||||
if (mapped.includes("student")) return "student"
|
||||
return "student"
|
||||
}
|
||||
|
||||
const normalizeBcryptHash = (value: string) => {
|
||||
if (value.startsWith("$2")) return value
|
||||
if (value.startsWith("$")) return `$2b${value}`
|
||||
return `$2b$${value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the client IP from request headers (best-effort, used for
|
||||
* rate-limit keying only — not stored).
|
||||
*/
|
||||
const resolveClientIp = async (): Promise<string> => {
|
||||
try {
|
||||
const { headers } = await import("next/headers")
|
||||
const headerList = await headers()
|
||||
return (
|
||||
headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
headerList.get("x-real-ip") ??
|
||||
"unknown"
|
||||
)
|
||||
} catch {
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a password_security row for a user.
|
||||
*/
|
||||
const getOrCreatePasswordSecurity = async (
|
||||
db: typeof import("@/shared/db")["db"],
|
||||
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||
userId: string
|
||||
) => {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(passwordSecurity)
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) return existing
|
||||
|
||||
const { createId } = await import("@paralleldrive/cuid2")
|
||||
const id = createId()
|
||||
await db.insert(passwordSecurity).values({
|
||||
id,
|
||||
userId,
|
||||
failedLoginAttempts: 0,
|
||||
})
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(passwordSecurity)
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
.limit(1)
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment failed login attempts and lock the account if the threshold
|
||||
* is reached.
|
||||
*/
|
||||
const recordFailedLogin = async (
|
||||
db: typeof import("@/shared/db")["db"],
|
||||
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||
userId: string
|
||||
): Promise<{ locked: boolean; lockedUntil: Date | null }> => {
|
||||
const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId)
|
||||
const nextAttempts = current.failedLoginAttempts + 1
|
||||
const shouldLock = nextAttempts >= PASSWORD_RULES.maxLoginAttempts
|
||||
const lockedUntil = shouldLock
|
||||
? new Date(Date.now() + PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000)
|
||||
: null
|
||||
|
||||
await db
|
||||
.update(passwordSecurity)
|
||||
.set({
|
||||
failedLoginAttempts: nextAttempts,
|
||||
lockedUntil,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
|
||||
return { locked: shouldLock, lockedUntil }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failed login attempts on successful login.
|
||||
*/
|
||||
const resetFailedLogin = async (
|
||||
db: typeof import("@/shared/db")["db"],
|
||||
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||
userId: string
|
||||
): Promise<void> => {
|
||||
const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId)
|
||||
if (current.failedLoginAttempts === 0 && !current.lockedUntil) return
|
||||
await db
|
||||
.update(passwordSecurity)
|
||||
.set({
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
}
|
||||
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
|
||||
import { resolveClientIp } from "@/shared/lib/http-utils"
|
||||
import {
|
||||
getOrCreatePasswordSecurity,
|
||||
recordFailedLogin,
|
||||
resetFailedLogin,
|
||||
} from "@/shared/lib/password-security-service"
|
||||
import { normalizeRole, resolvePrimaryRole } from "@/shared/lib/role-utils"
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
trustHost: true,
|
||||
|
||||
@@ -13,6 +13,10 @@ import "server-only"
|
||||
* 而本模块 NotificationPayload.type 为 "info" | "warning" | "error" | "success"。
|
||||
* 此处将 payload.type 作为字符串写入 DB(DB 列为 varchar(128),支持任意值),
|
||||
* 不破坏现有 messaging 模块的类型约束。
|
||||
*
|
||||
* 使用动态 import 打破 notifications -> messaging 的静态反向依赖。
|
||||
* 运行时调用链: messaging -> dispatcher -> in-app channel -> messaging.createNotification (存储)
|
||||
* 这是可接受的运行时调用链,但模块级静态依赖必须单向。
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -21,11 +25,10 @@ import type {
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
import { createNotification } from "@/modules/messaging/data-access"
|
||||
|
||||
const channel: NotificationChannel = "in_app"
|
||||
|
||||
/** 站内消息发送器(调用现有 messaging data-access) */
|
||||
/** 站内消息发送器(通过动态 import 调用 messaging data-access) */
|
||||
class InAppChannelSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
@@ -43,6 +46,8 @@ class InAppChannelSender implements NotificationChannelSender {
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
// Dynamic import to break static reverse dependency on messaging module
|
||||
const { createNotification } = await import("@/modules/messaging/data-access")
|
||||
const id = await createNotification({
|
||||
userId: payload.userId,
|
||||
// DB 列为 varchar(128),支持任意字符串;保留 payload.type 语义
|
||||
|
||||
27
src/modules/users/class-registration.ts
Normal file
27
src/modules/users/class-registration.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import "server-only"
|
||||
|
||||
import { enrollStudentByInvitationCode } from "@/modules/classes/data-access"
|
||||
|
||||
export type ClassRegistrationResult = {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过邀请码将学生注册到班级。
|
||||
*
|
||||
* 委托给 classes 模块的 data-access,避免跨模块直接写入 classEnrollments 表。
|
||||
* 返回结构化结果而非抛出异常,便于批量导入场景累计错误。
|
||||
*/
|
||||
export async function registerStudentByInvitationCode(
|
||||
studentId: string,
|
||||
invitationCode: string
|
||||
): Promise<ClassRegistrationResult> {
|
||||
try {
|
||||
await enrollStudentByInvitationCode(studentId, invitationCode)
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "注册失败"
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,12 @@
|
||||
import "server-only"
|
||||
|
||||
import { hash } from "bcryptjs"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
roles,
|
||||
users,
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import { roles, users, usersToRoles } from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import { exportToExcel, generateTemplate } from "@/shared/lib/excel"
|
||||
|
||||
const DEFAULT_PASSWORD = "123456"
|
||||
|
||||
const normalizeBcryptHash = (value: string) => {
|
||||
if (value.startsWith("$2")) return value
|
||||
if (value.startsWith("$")) return `$2b${value}`
|
||||
return `$2b$${value}`
|
||||
}
|
||||
|
||||
const VALID_ROLES = ["admin", "teacher", "student", "parent", "grade_head", "teaching_head"] as const
|
||||
type ValidRole = (typeof VALID_ROLES)[number]
|
||||
|
||||
@@ -42,12 +26,6 @@ export type UserImportValidation = {
|
||||
invalid: Array<{ row: number; record: UserImportRecord; errors: string[] }>
|
||||
}
|
||||
|
||||
export type UserImportResult = {
|
||||
successCount: number
|
||||
failedCount: number
|
||||
errors: Array<{ row: number; email: string; error: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成用户导入模板
|
||||
*/
|
||||
@@ -110,102 +88,6 @@ export function parseUserImportData(
|
||||
return { valid, invalid }
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入用户(事务)
|
||||
*/
|
||||
export async function batchImportUsers(
|
||||
records: UserImportRecord[]
|
||||
): Promise<UserImportResult> {
|
||||
const errors: UserImportResult["errors"] = []
|
||||
let successCount = 0
|
||||
|
||||
// Pre-load all roles
|
||||
const allRoles = await db.select().from(roles)
|
||||
const roleMap = new Map(allRoles.map((r) => [r.name, r.id]))
|
||||
|
||||
// Pre-load invitation codes
|
||||
const codes = records.map((r) => r.invitationCode).filter((c): c is string => !!c)
|
||||
const classMap = new Map<string, { classId: string; className: string }>()
|
||||
if (codes.length > 0) {
|
||||
const classRows = await db
|
||||
.select({ id: classes.id, name: classes.name, code: classes.invitationCode })
|
||||
.from(classes)
|
||||
.where(inArray(classes.invitationCode, codes))
|
||||
for (const c of classRows) {
|
||||
if (c.code) classMap.set(c.code, { classId: c.id, className: c.name })
|
||||
}
|
||||
}
|
||||
|
||||
// Check existing emails
|
||||
const emails = records.map((r) => r.email)
|
||||
const existing = await db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(inArray(users.email, emails))
|
||||
const existingEmails = new Set(existing.map((e) => e.email))
|
||||
|
||||
const hashedPassword = normalizeBcryptHash(await hash(DEFAULT_PASSWORD, 10))
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i]
|
||||
const rowNum = i + 2
|
||||
|
||||
if (existingEmails.has(record.email)) {
|
||||
errors.push({ row: rowNum, email: record.email, error: "邮箱已存在" })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = createId()
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: record.name,
|
||||
email: record.email,
|
||||
password: hashedPassword,
|
||||
phone: record.phone ?? null,
|
||||
})
|
||||
|
||||
const roleId = roleMap.get(record.role)
|
||||
if (roleId) {
|
||||
await db.insert(usersToRoles).values({ userId, roleId })
|
||||
}
|
||||
|
||||
// Enroll student in class via invitation code
|
||||
if (record.invitationCode && record.role === "student") {
|
||||
const classInfo = classMap.get(record.invitationCode)
|
||||
if (classInfo) {
|
||||
await db
|
||||
.insert(classEnrollments)
|
||||
.values({
|
||||
classId: classInfo.classId,
|
||||
studentId: userId,
|
||||
status: "active",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { status: "active" } })
|
||||
} else {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
email: record.email,
|
||||
error: `邀请码 ${record.invitationCode} 无效(用户已创建)`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
existingEmails.add(record.email)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "创建失败"
|
||||
errors.push({ row: rowNum, email: record.email, error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
successCount,
|
||||
failedCount: errors.length,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用户列表到 Excel
|
||||
*/
|
||||
@@ -289,3 +171,6 @@ export async function exportUsersToExcel(params: {
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Re-export 保持向后兼容:用户创建与班级注册已拆分至独立文件
|
||||
export { batchImportUsers, type UserImportResult } from "./user-service"
|
||||
|
||||
99
src/modules/users/user-service.ts
Normal file
99
src/modules/users/user-service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import "server-only"
|
||||
|
||||
import { hash } from "bcryptjs"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { inArray } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { roles, users, usersToRoles } from "@/shared/db/schema"
|
||||
|
||||
import type { UserImportRecord } from "./import-export"
|
||||
import { registerStudentByInvitationCode } from "./class-registration"
|
||||
|
||||
const DEFAULT_PASSWORD = "123456"
|
||||
|
||||
const normalizeBcryptHash = (value: string) => {
|
||||
if (value.startsWith("$2")) return value
|
||||
if (value.startsWith("$")) return `$2b${value}`
|
||||
return `$2b$${value}`
|
||||
}
|
||||
|
||||
export type UserImportResult = {
|
||||
successCount: number
|
||||
failedCount: number
|
||||
errors: Array<{ row: number; email: string; error: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入用户(事务)
|
||||
*/
|
||||
export async function batchImportUsers(
|
||||
records: UserImportRecord[]
|
||||
): Promise<UserImportResult> {
|
||||
const errors: UserImportResult["errors"] = []
|
||||
let successCount = 0
|
||||
|
||||
// Pre-load all roles
|
||||
const allRoles = await db.select().from(roles)
|
||||
const roleMap = new Map(allRoles.map((r) => [r.name, r.id]))
|
||||
|
||||
// Check existing emails
|
||||
const emails = records.map((r) => r.email)
|
||||
const existing = await db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(inArray(users.email, emails))
|
||||
const existingEmails = new Set(existing.map((e) => e.email))
|
||||
|
||||
const hashedPassword = normalizeBcryptHash(await hash(DEFAULT_PASSWORD, 10))
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i]
|
||||
const rowNum = i + 2
|
||||
|
||||
if (existingEmails.has(record.email)) {
|
||||
errors.push({ row: rowNum, email: record.email, error: "邮箱已存在" })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = createId()
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: record.name,
|
||||
email: record.email,
|
||||
password: hashedPassword,
|
||||
phone: record.phone ?? null,
|
||||
})
|
||||
|
||||
const roleId = roleMap.get(record.role)
|
||||
if (roleId) {
|
||||
await db.insert(usersToRoles).values({ userId, roleId })
|
||||
}
|
||||
|
||||
// Enroll student in class via invitation code (delegated to classes module)
|
||||
if (record.invitationCode && record.role === "student") {
|
||||
const result = await registerStudentByInvitationCode(userId, record.invitationCode)
|
||||
if (!result.success) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
email: record.email,
|
||||
error: `邀请码 ${record.invitationCode} 无效(用户已创建)`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
existingEmails.add(record.email)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "创建失败"
|
||||
errors.push({ row: rowNum, email: record.email, error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
successCount,
|
||||
failedCount: errors.length,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
19
src/shared/lib/bcrypt-utils.ts
Normal file
19
src/shared/lib/bcrypt-utils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* bcrypt hash normalization helper (pure function).
|
||||
*
|
||||
* Some legacy rows store bcrypt hashes without the leading `$2b$` version
|
||||
* marker. `compare` from `bcryptjs` requires a well-formed hash, so we
|
||||
* restore the prefix before verification.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize a stored bcrypt hash to the canonical `$2b$...` form.
|
||||
* - Already-prefixed hashes (`$2a$`, `$2b$`, `$2y$`, ...) are returned as-is.
|
||||
* - Hashes starting with `$` but missing the version (e.g. `$abc...`) get `$2b` prepended.
|
||||
* - Bare hashes get the full `$2b$` prefix prepended.
|
||||
*/
|
||||
export const normalizeBcryptHash = (value: string): string => {
|
||||
if (value.startsWith("$2")) return value
|
||||
if (value.startsWith("$")) return `$2b${value}`
|
||||
return `$2b$${value}`
|
||||
}
|
||||
28
src/shared/lib/http-utils.ts
Normal file
28
src/shared/lib/http-utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* HTTP request helpers (server-only).
|
||||
*
|
||||
* Thin wrappers around `next/headers` used for best-effort client
|
||||
* identification (rate-limit keying, logging). These values are NOT
|
||||
* stored as audit-trail identifiers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the client IP from request headers (best-effort, used for
|
||||
* rate-limit keying only — not stored).
|
||||
*
|
||||
* Falls back to `"unknown"` when headers are unavailable (e.g. during
|
||||
* build or in non-request contexts).
|
||||
*/
|
||||
export const resolveClientIp = async (): Promise<string> => {
|
||||
try {
|
||||
const { headers } = await import("next/headers")
|
||||
const headerList = await headers()
|
||||
return (
|
||||
headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
headerList.get("x-real-ip") ??
|
||||
"unknown"
|
||||
)
|
||||
} catch {
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
94
src/shared/lib/password-security-service.ts
Normal file
94
src/shared/lib/password-security-service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import "server-only"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { PASSWORD_RULES } from "@/shared/lib/password-policy"
|
||||
|
||||
/**
|
||||
* Password security DB operations.
|
||||
*
|
||||
* These functions operate on the `password_security` table to track
|
||||
* failed-login counters and account lockout state. They receive `db`
|
||||
* and the `passwordSecurity` schema object as parameters so the caller
|
||||
* (NextAuth `authorize` callback) can share a single dynamic-import
|
||||
* resolution with its own DB access.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get or create a password_security row for a user.
|
||||
*/
|
||||
export const getOrCreatePasswordSecurity = async (
|
||||
db: typeof import("@/shared/db")["db"],
|
||||
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||
userId: string
|
||||
) => {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(passwordSecurity)
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) return existing
|
||||
|
||||
const { createId } = await import("@paralleldrive/cuid2")
|
||||
const id = createId()
|
||||
await db.insert(passwordSecurity).values({
|
||||
id,
|
||||
userId,
|
||||
failedLoginAttempts: 0,
|
||||
})
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(passwordSecurity)
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
.limit(1)
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment failed login attempts and lock the account if the threshold
|
||||
* is reached.
|
||||
*/
|
||||
export const recordFailedLogin = async (
|
||||
db: typeof import("@/shared/db")["db"],
|
||||
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||
userId: string
|
||||
): Promise<{ locked: boolean; lockedUntil: Date | null }> => {
|
||||
const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId)
|
||||
const nextAttempts = current.failedLoginAttempts + 1
|
||||
const shouldLock = nextAttempts >= PASSWORD_RULES.maxLoginAttempts
|
||||
const lockedUntil = shouldLock
|
||||
? new Date(Date.now() + PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000)
|
||||
: null
|
||||
|
||||
await db
|
||||
.update(passwordSecurity)
|
||||
.set({
|
||||
failedLoginAttempts: nextAttempts,
|
||||
lockedUntil,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
|
||||
return { locked: shouldLock, lockedUntil }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failed login attempts on successful login.
|
||||
*/
|
||||
export const resetFailedLogin = async (
|
||||
db: typeof import("@/shared/db")["db"],
|
||||
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||
userId: string
|
||||
): Promise<void> => {
|
||||
const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId)
|
||||
if (current.failedLoginAttempts === 0 && !current.lockedUntil) return
|
||||
await db
|
||||
.update(passwordSecurity)
|
||||
.set({
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
}
|
||||
34
src/shared/lib/role-utils.ts
Normal file
34
src/shared/lib/role-utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Role normalization utilities (pure functions).
|
||||
*
|
||||
* These helpers map various role names (including legacy aliases) to the
|
||||
* canonical K12 role set: admin / teacher / student / parent.
|
||||
*/
|
||||
|
||||
export type NormalizedRole = "admin" | "teacher" | "student" | "parent"
|
||||
|
||||
/**
|
||||
* Normalize a single role value to one of the canonical roles.
|
||||
* Legacy aliases such as `grade_head` / `teaching_head` collapse to `teacher`.
|
||||
* Unknown values fall back to `student`.
|
||||
*/
|
||||
export const normalizeRole = (value: unknown): NormalizedRole => {
|
||||
const role = String(value ?? "").trim().toLowerCase()
|
||||
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||
return "student"
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of role names (e.g. from `users_to_roles`), resolve the
|
||||
* primary role used for routing/permission checks. Priority order:
|
||||
* admin > teacher > parent > student.
|
||||
*/
|
||||
export const resolvePrimaryRole = (roleNames: string[]): NormalizedRole => {
|
||||
const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean)
|
||||
if (mapped.includes("admin")) return "admin"
|
||||
if (mapped.includes("teacher")) return "teacher"
|
||||
if (mapped.includes("parent")) return "parent"
|
||||
if (mapped.includes("student")) return "student"
|
||||
return "student"
|
||||
}
|
||||
Reference in New Issue
Block a user