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,7 +1,7 @@
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, asc, eq, inArray } from "drizzle-orm"
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import {
@@ -11,27 +11,36 @@ import {
import type { CourseSelectionStatus } from "./types"
function buildLotteryRankCase(ids: string[], startRank: number): SQL {
const branches = ids.map(
(id, idx) => sql`WHEN ${id} THEN ${startRank + idx}`
)
return sql`CASE ${courseSelections.id} ${sql.join(branches, sql` `)} END`
}
export async function runLottery(courseId: string): Promise<{
enrolled: number
waitlist: number
}> {
const [course] = await db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1)
if (!course) throw new Error("Course not found")
const selections = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.status, "selected")
const [courseRows, selections] = await Promise.all([
db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1),
db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.status, "selected")
)
)
)
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt)),
])
const course = courseRows[0]
if (!course) throw new Error("Course not found")
if (selections.length === 0) {
return { enrolled: 0, waitlist: 0 }
@@ -41,39 +50,46 @@ export async function runLottery(courseId: string): Promise<{
const capacity = course.capacity
const now = new Date()
let enrolledCount = 0
let waitlistCount = 0
const enrolledIds: string[] = []
const waitlistIds: string[] = []
for (let i = 0; i < shuffled.length; i++) {
const sel = shuffled[i]
const rank = i + 1
if (i < capacity) {
await db
.update(courseSelections)
.set({
status: "enrolled",
lotteryRank: rank,
enrolledAt: now,
updatedAt: now,
})
.where(eq(courseSelections.id, sel.id))
enrolledCount++
enrolledIds.push(shuffled[i].id)
} else {
await db
.update(courseSelections)
.set({
status: "waitlist",
lotteryRank: rank,
updatedAt: now,
})
.where(eq(courseSelections.id, sel.id))
waitlistCount++
waitlistIds.push(shuffled[i].id)
}
}
await db
.update(electiveCourses)
.set({ enrolledCount, status: "closed", updatedAt: now })
.where(eq(electiveCourses.id, courseId))
const enrolledCount = enrolledIds.length
const waitlistCount = waitlistIds.length
await db.transaction(async (tx) => {
if (enrolledIds.length > 0) {
await tx
.update(courseSelections)
.set({
status: "enrolled",
lotteryRank: buildLotteryRankCase(enrolledIds, 1),
enrolledAt: now,
updatedAt: now,
})
.where(inArray(courseSelections.id, enrolledIds))
}
if (waitlistIds.length > 0) {
await tx
.update(courseSelections)
.set({
status: "waitlist",
lotteryRank: buildLotteryRankCase(waitlistIds, capacity + 1),
updatedAt: now,
})
.where(inArray(courseSelections.id, waitlistIds))
}
await tx
.update(electiveCourses)
.set({ enrolledCount, status: "closed", updatedAt: now })
.where(eq(electiveCourses.id, courseId))
})
return { enrolled: enrolledCount, waitlist: waitlistCount }
}
@@ -83,11 +99,25 @@ export async function selectCourse(
studentId: string,
priority?: number
): Promise<{ status: CourseSelectionStatus; message: string }> {
const [course] = await db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1)
const [courseRows, existingRows] = await Promise.all([
db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1),
db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
)
)
.limit(1),
])
const course = courseRows[0]
if (!course) throw new Error("Course not found")
if (course.status !== "open") throw new Error("Course selection is not open")
@@ -99,17 +129,7 @@ export async function selectCourse(
throw new Error("Selection has ended")
}
const [existing] = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
)
)
.limit(1)
const existing = existingRows[0]
if (existing) throw new Error("Already selected this course")
const id = createId()
@@ -155,19 +175,28 @@ export async function dropCourse(
courseId: string,
studentId: string
): Promise<void> {
const [existing] = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
const [existingRows, courseRows] = await Promise.all([
db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
)
)
)
.limit(1)
.limit(1),
db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1),
])
const existing = existingRows[0]
if (!existing) throw new Error("No active selection found")
const course = courseRows[0]
const now = new Date()
await db
.update(courseSelections)
@@ -175,11 +204,6 @@ export async function dropCourse(
.where(eq(courseSelections.id, existing.id))
if (existing.status === "enrolled") {
const [course] = await db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1)
if (course && course.selectionMode === "fcfs") {
const newEnrolledCount = Math.max(0, course.enrolledCount - 1)
await db