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:
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
35
src/shared/lib/session.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user