refactor: fix all P0/P1/P2 bugs and architecture issues

Bug fixes (from bugs/ directory):

- Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions

- Fix shared/lib <-> auth circular dependency via new session.ts module

- Fix divide-by-zero guard in grades data-access

- Fix audit export data truncation (paginated fetch for full datasets)

- Fix missing transactions in homework grading and elective lottery

- Fix missing revalidatePath in course-plans actions

- Fix frontend permission checks using requirePermission instead of requireAuth

- Fix dashboard role routing using session.user.roles

- Fix student auth pattern (migrate getDemoStudentUser to users module)

- Fix ActionState return type handling in components

Code quality fixes:

- Remove 60+ as type assertions (replace with type guards)

- Remove non-null assertions (use optional chaining or explicit checks)

- Convert dynamic imports to static imports (grades, diagnostic)

- Add React.cache() wrapping for read functions

- Parallelize independent queries with Promise.all

- Add explicit return types to 30+ arrow functions

- Replace any with unknown + type guards

- Fix import type for type-only imports

- Add Zod validation schemas for classes and diagnostic modules

- Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction)

- Add console.error to silent catch blocks

- Fix permission naming consistency (exam:proctor_read -> exam:proctor:read)

Architecture doc sync:

- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Update management-modules-audit.md for P0-7 cross-module fix

Moved deleted proctoring event route to deletes/ folder.
This commit is contained in:
SpecialX
2026-06-19 05:13:09 +08:00
parent 063baffe4c
commit 49291fcc31
114 changed files with 12548 additions and 3395 deletions

View File

@@ -1,9 +1,10 @@
"use server"
import { createId } from "@paralleldrive/cuid2"
import { headers } from "next/headers"
import { db } from "@/shared/db"
import { auditLogs } from "@/shared/db/schema"
import { getSession } from "@/shared/lib/session"
import { resolveClientIp, getUserAgent } from "@/shared/lib/http-utils"
export type AuditLogStatus = "success" | "failure"
@@ -16,29 +17,15 @@ export interface LogAuditParams {
status?: AuditLogStatus
}
/**
* 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()
}
/**
* Record an audit log entry for the current authenticated user.
* Silently fails on error so it never breaks the main operation.
*/
export async function logAudit(params: LogAuditParams): Promise<void> {
try {
const session = await getCurrentSession()
const headerList = await headers()
const ipAddress =
headerList.get("x-forwarded-for") ??
headerList.get("x-real-ip") ??
"unknown"
const userAgent = headerList.get("user-agent") ?? "unknown"
const session = await getSession()
const ipAddress = await resolveClientIp()
const userAgent = await getUserAgent()
await db.insert(auditLogs).values({
id: createId(),

View File

@@ -7,6 +7,7 @@ import {
parentStudentRelations,
} from "@/shared/db/schema"
import { eq, or } from "drizzle-orm"
import { getSession } from "@/shared/lib/session"
export class PermissionDeniedError extends Error {
constructor(permission: string) {
@@ -15,22 +16,12 @@ export class PermissionDeniedError extends Error {
}
}
/**
* 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 session = await getSession()
const userId = session?.user?.id
if (!userId) throw new PermissionDeniedError("auth_required")

View File

@@ -1,10 +1,11 @@
"use server"
import { createId } from "@paralleldrive/cuid2"
import { headers } from "next/headers"
import { db } from "@/shared/db"
import { dataChangeLogs } from "@/shared/db/schema"
import { getSession } from "@/shared/lib/session"
import { resolveClientIp } from "@/shared/lib/http-utils"
export type DataChangeAction = "create" | "update" | "delete"
@@ -16,28 +17,14 @@ export interface LogDataChangeParams {
newValue?: Record<string, unknown>
}
/**
* 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()
}
/**
* Record a data change log entry for the current authenticated user.
* Silently fails on error so it never blocks the main operation.
*/
export async function logDataChange(params: LogDataChangeParams): Promise<void> {
try {
const session = await getCurrentSession()
const headerList = await headers()
const ipAddress =
headerList.get("x-forwarded-for") ??
headerList.get("x-real-ip") ??
"unknown"
const session = await getSession()
const ipAddress = await resolveClientIp()
await db.insert(dataChangeLogs).values({
id: createId(),

View File

@@ -26,3 +26,19 @@ export const resolveClientIp = async (): Promise<string> => {
return "unknown"
}
}
/**
* Resolve the User-Agent string from request headers (best-effort).
*
* Falls back to `"unknown"` when headers are unavailable (e.g. during
* build or in non-request contexts).
*/
export const getUserAgent = async (): Promise<string> => {
try {
const { headers } = await import("next/headers")
const headerList = await headers()
return headerList.get("user-agent") ?? "unknown"
} catch {
return "unknown"
}
}

View File

@@ -1,9 +1,9 @@
"use server"
import { createId } from "@paralleldrive/cuid2"
import { headers } from "next/headers"
import { db } from "@/shared/db"
import { loginLogs } from "@/shared/db/schema"
import { resolveClientIp, getUserAgent } from "@/shared/lib/http-utils"
export type LoginLogAction = "signin" | "signout" | "signup"
export type LoginLogStatus = "success" | "failure"
@@ -23,12 +23,8 @@ export interface LogLoginEventParams {
*/
export async function logLoginEvent(params: LogLoginEventParams): Promise<void> {
try {
const headerList = await headers()
const ipAddress =
headerList.get("x-forwarded-for") ??
headerList.get("x-real-ip") ??
"unknown"
const userAgent = headerList.get("user-agent") ?? "unknown"
const ipAddress = await resolveClientIp()
const userAgent = await getUserAgent()
await db.insert(loginLogs).values({
id: createId(),

View File

@@ -30,6 +30,7 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.SCHOOL_MANAGE,
Permissions.GRADE_MANAGE,
Permissions.USER_MANAGE,
Permissions.USER_PROFILE_UPDATE,
Permissions.AI_CHAT,
Permissions.AI_CONFIGURE,
Permissions.SETTINGS_ADMIN,
@@ -53,6 +54,11 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
Permissions.LESSON_PLAN_CREATE,
Permissions.LESSON_PLAN_READ,
Permissions.LESSON_PLAN_UPDATE,
Permissions.LESSON_PLAN_DELETE,
Permissions.LESSON_PLAN_PUBLISH,
],
teacher: [
Permissions.EXAM_CREATE,
@@ -74,6 +80,7 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.CLASS_READ,
Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
Permissions.USER_PROFILE_UPDATE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_MANAGE,
@@ -90,13 +97,20 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
Permissions.LESSON_PLAN_CREATE,
Permissions.LESSON_PLAN_READ,
Permissions.LESSON_PLAN_UPDATE,
Permissions.LESSON_PLAN_DELETE,
Permissions.LESSON_PLAN_PUBLISH,
],
student: [
Permissions.EXAM_READ,
Permissions.EXAM_SUBMIT,
Permissions.HOMEWORK_SUBMIT,
Permissions.QUESTION_READ,
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
Permissions.USER_PROFILE_UPDATE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
@@ -112,6 +126,7 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.EXAM_READ,
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
Permissions.USER_PROFILE_UPDATE,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.ATTENDANCE_READ,
@@ -142,6 +157,7 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
Permissions.GRADE_MANAGE,
Permissions.USER_PROFILE_UPDATE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
@@ -174,6 +190,7 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.TEXTBOOK_UPDATE,
Permissions.CLASS_READ,
Permissions.GRADE_MANAGE,
Permissions.USER_PROFILE_UPDATE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,

35
src/shared/lib/session.ts Normal file
View File

@@ -0,0 +1,35 @@
import "server-only"
/**
* Session access single entry point (server-only).
*
* Shared/lib modules that need the current session MUST go through this
* module instead of importing `@/auth` directly. `@/auth` itself imports
* from `@/shared/lib/*`, so a static `import { auth } from "@/auth"` in
* shared/lib would create a module-level cycle. The dynamic import below
* keeps the module-loading graph acyclic while centralising session
* retrieval so callers depend on `@/shared/lib/session`, not `@/auth`.
*/
export type AppSession = {
user: {
id: string
name?: string | null
email?: string | null
role?: string
roles?: string[]
permissions?: string[]
}
} | null
/**
* Get the current NextAuth session.
*
* Uses a dynamic import to avoid a static circular dependency on
* `@/auth` (which itself imports from `@/shared/lib/*`). The runtime
* call chain is unchanged — only the module-loading graph is acyclic.
*/
export async function getSession(): Promise<AppSession> {
const { auth } = await import("@/auth")
return auth()
}

View File

@@ -1,6 +1,14 @@
/**
* Standard return type for Server Actions.
*
* - `success` indicates whether the action completed without errors.
* - `message` is an optional human-readable summary (success or failure).
* - `errors` holds field-level validation errors keyed by field name.
* - `data` carries the action's payload on success.
*/
export type ActionState<T = void> = {
success: boolean;
message?: string;
errors?: Record<string, string[]>;
data?: T;
};
success: boolean
message?: string
errors?: Record<string, string[]>
data?: T
}

View File

@@ -10,6 +10,7 @@ export const Permissions = {
EXAM_DUPLICATE: "exam:duplicate",
EXAM_PUBLISH: "exam:publish",
EXAM_AI_GENERATE: "exam:ai_generate",
EXAM_SUBMIT: "exam:submit",
// Homework
HOMEWORK_CREATE: "homework:create",
@@ -40,6 +41,8 @@ export const Permissions = {
SCHOOL_MANAGE: "school:manage",
GRADE_MANAGE: "grade:manage",
USER_MANAGE: "user:manage",
/** Self-service profile update (all authenticated roles) */
USER_PROFILE_UPDATE: "user:profile_update",
// AI
AI_CHAT: "ai:chat",
@@ -88,16 +91,33 @@ export const Permissions = {
// P2: Exam Proctoring (考试监考)
EXAM_PROCTOR: "exam:proctor",
EXAM_PROCTOR_READ: "exam:proctor_read",
EXAM_PROCTOR_READ: "exam:proctor:read",
// P2: Learning Diagnostic (学情诊断)
DIAGNOSTIC_MANAGE: "diagnostic:manage",
DIAGNOSTIC_READ: "diagnostic:read",
// Lesson Plan (备课)
LESSON_PLAN_CREATE: "lesson_plan:create",
LESSON_PLAN_READ: "lesson_plan:read",
LESSON_PLAN_UPDATE: "lesson_plan:update",
LESSON_PLAN_DELETE: "lesson_plan:delete",
LESSON_PLAN_PUBLISH: "lesson_plan:publish",
} as const
export type Permission = (typeof Permissions)[keyof typeof Permissions]
// Data scope for row-level security
/**
* Data scope for row-level security.
*
* Determines which rows a user is allowed to see based on their role:
* - `all`: no filtering (admin).
* - `owned`: only rows created by the user.
* - `class_members`: rows visible to members of the user's enrolled classes (student).
* - `grade_managed`: rows within grades the user manages (grade_head / teaching_head).
* - `class_taught`: rows within classes the user teaches (teacher).
* - `children`: rows belonging to the user's children (parent).
*/
export type DataScope =
| { type: "all" }
| { type: "owned"; userId: string }
@@ -106,6 +126,14 @@ export type DataScope =
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] }
| { type: "children"; childrenIds: string[] }
/**
* Authentication context for the current request.
*
* - `userId`: the authenticated user's id.
* - `roles`: all role names assigned to the user.
* - `permissions`: the merged set of permission strings granted to the user.
* - `dataScope`: the row-level security scope used to filter queries.
*/
export interface AuthContext {
userId: string
roles: string[]