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()
}