feat(shared,tests): add error boundaries, lib utils, i18n messages, and integration tests
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled

shared:

- Add class-filter, error-state, route-error, section-error-boundary, widget-boundary components

- Add ui/alert component

- Add constants directory

- Add breached-password, export-utils, permission-bitmap, rate-limit, resolve-action-error, route-permissions, route-resolver, type-guards lib

- Add i18n messages (en, zh-CN) for invitation-codes, parent, questions, rbac

tests:

- Add integration tests for elective

- Add tests/setup/empty-stub

scripts:

- Add update-md.cjs, tmp_append_en.ps1, tmp_merge_en.ps1 utilities
This commit is contained in:
SpecialX
2026-07-03 10:26:38 +08:00
parent 21142f9b99
commit 0e63c24ed9
97 changed files with 9753 additions and 819 deletions

View File

@@ -1,19 +1,30 @@
import type { Permission, DataScope, AuthContext, Role } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
classSubjectTeachers,
grades,
parentStudentRelations,
} from "@/shared/db/schema"
import { eq, inArray, or } from "drizzle-orm"
import { isPermission } from "@/shared/lib/type-guards"
import { getSession } from "@/shared/lib/session"
import { PermissionDeniedError } from "@/shared/lib/errors"
// Re-export for backward compatibility (other modules still import from here)
export { PermissionDeniedError } from "@/shared/lib/errors"
/**
* Resolve the data scope for a user based on their roles.
*
* Delegates to the RBAC module's configuration-driven resolver via dynamic
* import, so the shared layer never statically depends on modules/*.
* This is the same pattern used by shared/lib/session.ts to break the
* shared ↔ auth circular dependency.
*
* P1-5/P1-6 audit fix: removed direct DB queries against classes,
* classEnrollments, classSubjectTeachers, grades, parentStudentRelations
* tables. The resolver now calls module data-access functions and is
* driven by a configuration array (DATA_SCOPE_RULES) instead of hardcoded
* role-name checks.
*/
async function resolveDataScope(userId: string, roleNames: Role[]): Promise<DataScope> {
const { resolveDataScopeFromConfig } = await import("@/modules/rbac/lib/data-scope-resolver")
return resolveDataScopeFromConfig(userId, roleNames)
}
/**
* Get the full authentication context for the current user.
* Throws if not authenticated.
@@ -24,8 +35,11 @@ export async function getAuthContext(): Promise<AuthContext> {
if (!userId) throw new PermissionDeniedError("auth_required")
// Prefer session data (already resolved in JWT callback)
const roleNames = (session.user.roles ?? []) as Role[]
const permissions = (session.user.permissions ?? []) as Permission[]
// Use type guards to safely coerce session values
const roleNames = (session.user.roles ?? []).filter(
(r): r is Role => typeof r === "string"
)
const permissions = (session.user.permissions ?? []).filter(isPermission)
// Resolve data scope from DB (not cached in JWT since it can change)
const dataScope = await resolveDataScope(userId, roleNames)
@@ -55,118 +69,6 @@ export async function checkPermission(
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: Role[]): 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
// Pre-resolve classIds and gradeIds here to avoid N+1 queries in data-access layer
if (roleNames.includes("student")) {
const enrolledClasses = await db
.select({ classId: classEnrollments.classId, gradeId: classes.gradeId })
.from(classEnrollments)
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
.where(eq(classEnrollments.studentId, userId))
const gradeIds = [
...new Set(
enrolledClasses
.map((c) => c.gradeId)
.filter((g): g is string => g !== null),
),
]
return {
type: "class_members",
classIds: enrolledClasses.map((c) => c.classId),
gradeIds: gradeIds.length > 0 ? gradeIds : undefined,
}
}
// 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))
const childrenIds = children.map((c) => c.studentId)
// Pre-resolve gradeIds from children's enrolled classes
let gradeIds: string[] | undefined
if (childrenIds.length > 0) {
const childrenClasses = await db
.select({ gradeId: classes.gradeId })
.from(classEnrollments)
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
.where(inArray(classEnrollments.studentId, childrenIds))
const uniqueGradeIds = [
...new Set(
childrenClasses
.map((c) => c.gradeId)
.filter((g): g is string => g !== null),
),
]
gradeIds = uniqueGradeIds.length > 0 ? uniqueGradeIds : undefined
}
return { type: "children", childrenIds, gradeIds }
}
// Fallback: only own data
return { type: "owned", userId }
}
/**
* Convenience: assert the user is authenticated (has any role).
* Returns AuthContext on success.