Files
NextEdu/src/shared/lib/auth-guard.ts
SpecialX 220061d62e refactor: P0-3/5/6 解耦修复 - 循环依赖/通知分发/课表写入口
P0-3: 修复 shared/lib <-> auth 循环依赖
- audit-logger.ts, change-logger.ts, auth-guard.ts, classes/data-access.ts
  改用动态 import("@/auth") 打破静态模块级循环依赖
- shared/lib 不再静态导入 @/auth

P0-5: messaging 改用 notifications dispatcher
- messaging/actions.ts 的 sendMessageAction 改用 sendNotification
  替代直接调用 createNotification
- 用户通知偏好(SMS/微信/邮件/站内)现在被正确尊重

P0-6: 统一 classSchedule 写入口到 scheduling/data-access
- 新增 insertClassScheduleItem/updateClassScheduleItemById/
  deleteClassScheduleItemById/replaceClassSchedule 统一写入函数
- classes/data-access.ts 的三个 schedule 写入函数委托给 scheduling
- scheduling/actions.ts 的 applyAutoScheduleAction 改用 replaceClassSchedule
- 移除 scheduling/actions.ts 中不再使用的 classSchedule/createId 导入

验证: tsc --noEmit 0 errors, npm run lint 0 errors
2026-06-17 23:44:02 +08:00

148 lines
4.4 KiB
TypeScript

import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import {
classes,
classSubjectTeachers,
grades,
parentStudentRelations,
} from "@/shared/db/schema"
import { eq, or } from "drizzle-orm"
export class PermissionDeniedError extends Error {
constructor(permission: string) {
super(`Permission denied: ${permission}`)
this.name = "PermissionDeniedError"
}
}
/**
* Get the current session without creating a static circular dependency
* on @/auth (which itself imports from @/shared/lib/*).
* Dynamic import breaks the module-level cycle.
*/
async function getCurrentSession() {
const { auth } = await import("@/auth")
return auth()
}
/**
* Get the full authentication context for the current user.
* Throws if not authenticated.
*/
export async function getAuthContext(): Promise<AuthContext> {
const session = await getCurrentSession()
const userId = session?.user?.id
if (!userId) throw new PermissionDeniedError("auth_required")
// Prefer session data (already resolved in JWT callback)
const roleNames = (session.user.roles ?? []) as string[]
const permissions = (session.user.permissions ?? []) as Permission[]
// Resolve data scope from DB (not cached in JWT since it can change)
const dataScope = await resolveDataScope(userId, roleNames)
return { userId, roles: roleNames, permissions, dataScope }
}
/**
* Assert the current user has the specified permission.
* Returns AuthContext on success, throws PermissionDeniedError on failure.
*/
export async function requirePermission(permission: Permission): Promise<AuthContext> {
const ctx = await getAuthContext()
if (!ctx.permissions.includes(permission)) {
throw new PermissionDeniedError(permission)
}
return ctx
}
/**
* Check permission without throwing. Useful for conditional logic.
*/
export async function checkPermission(
permission: Permission
): Promise<{ allowed: boolean; ctx: AuthContext }> {
const ctx = await getAuthContext()
return { allowed: ctx.permissions.includes(permission), ctx }
}
/**
* Resolve the data scope for a user based on their roles.
* Queries the DB for resource ownership information.
*/
async function resolveDataScope(userId: string, roleNames: string[]): Promise<DataScope> {
// Admin sees everything
if (roleNames.includes("admin")) {
return { type: "all" }
}
// Grade head / teaching head: can manage their grades
if (roleNames.includes("grade_head") || roleNames.includes("teaching_head")) {
const managedGrades = await db
.select({ id: grades.id })
.from(grades)
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
if (managedGrades.length > 0) {
return { type: "grade_managed", gradeIds: managedGrades.map((g) => g.id) }
}
}
// Teacher: can see their own classes
if (roleNames.includes("teacher")) {
// Classes where user is the homeroom teacher
const homeroomClasses = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, userId))
// Classes where user is a subject teacher
const subjectClasses = await db
.selectDistinct({ classId: classSubjectTeachers.classId, subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, userId))
const classIds = [
...new Set([
...homeroomClasses.map((c) => c.id),
...subjectClasses.map((c) => c.classId),
]),
]
const subjectIds = subjectClasses
.map((c) => c.subjectId)
.filter((s): s is string => s !== null)
return {
type: "class_taught",
classIds,
subjectIds: subjectIds.length > 0 ? subjectIds : undefined,
}
}
// Student: can see data from their enrolled classes
if (roleNames.includes("student")) {
return { type: "class_members" }
}
// Parent: can see their children's data
if (roleNames.includes("parent")) {
const children = await db
.select({ studentId: parentStudentRelations.studentId })
.from(parentStudentRelations)
.where(eq(parentStudentRelations.parentId, userId))
return { type: "children", childrenIds: children.map((c) => c.studentId) }
}
// Fallback: only own data
return { type: "owned", userId }
}
/**
* Convenience: assert the user is authenticated (has any role).
* Returns AuthContext on success.
*/
export async function requireAuth(): Promise<AuthContext> {
return getAuthContext()
}