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,14 +1,12 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { eq, or } from "drizzle-orm"
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { users } from "@/shared/db/schema"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import {
|
||||
getSchedulingRules,
|
||||
@@ -107,14 +105,8 @@ export async function autoScheduleAction(
|
||||
const teacherIds = Array.from(
|
||||
new Set(subjectRows.map((r) => r.teacherId).filter((v): v is string => v !== null))
|
||||
)
|
||||
const teacherRows =
|
||||
teacherIds.length > 0
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(teacherIds.length === 1 ? eq(users.id, teacherIds[0]!) : or(...teacherIds.map((id) => eq(users.id, id))))
|
||||
: []
|
||||
const teachersInput = teacherRows.map((t) => ({ id: t.id, name: t.name ?? "Unknown" }))
|
||||
const teacherMap = teacherIds.length > 0 ? await getUserNamesByIds(teacherIds) : new Map()
|
||||
const teachersInput = teacherIds.map((id) => ({ id, name: teacherMap.get(id)?.name ?? "Unknown" }))
|
||||
|
||||
// Load classrooms
|
||||
const classroomRows = await getClassroomsForScheduling()
|
||||
|
||||
@@ -141,8 +141,9 @@ export function validateSchedule(
|
||||
// Pairwise overlap check (class/teacher/classroom)
|
||||
for (let i = 0; i < schedule.length; i += 1) {
|
||||
for (let j = i + 1; j < schedule.length; j += 1) {
|
||||
const a = schedule[i]!
|
||||
const b = schedule[j]!
|
||||
const a = schedule[i]
|
||||
const b = schedule[j]
|
||||
if (!a || !b) continue
|
||||
if (!isOverlap(a, b)) continue
|
||||
|
||||
if (a.teacherId && a.teacherId === b.teacherId) {
|
||||
|
||||
159
src/modules/scheduling/data-access-class-schedule.ts
Normal file
159
src/modules/scheduling/data-access-class-schedule.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import "server-only"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { classSchedule } from "@/shared/db/schema"
|
||||
import {
|
||||
getTeacherIdForMutations,
|
||||
verifyTeacherOwnsClass,
|
||||
} from "@/modules/classes/data-access"
|
||||
import {
|
||||
insertClassScheduleItem,
|
||||
updateClassScheduleItemById,
|
||||
deleteClassScheduleItemById,
|
||||
} from "./data-access"
|
||||
import type {
|
||||
CreateClassScheduleItemInput,
|
||||
UpdateClassScheduleItemInput,
|
||||
} from "./types"
|
||||
|
||||
const isTimeHHMM = (v: string): boolean => /^\d{2}:\d{2}$/.test(v)
|
||||
|
||||
/**
|
||||
* Create a single classSchedule item.
|
||||
* Ownership: the caller (teacher) must own the target class.
|
||||
* DB write is delegated to the unified scheduling write entry point.
|
||||
*/
|
||||
export async function createClassScheduleItem(
|
||||
data: CreateClassScheduleItemInput,
|
||||
): Promise<string> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
const classId = data.classId.trim()
|
||||
const course = data.course.trim()
|
||||
const startTime = data.startTime.trim()
|
||||
const endTime = data.endTime.trim()
|
||||
const location = data.location?.trim() || null
|
||||
const weekday = data.weekday
|
||||
|
||||
if (!classId) throw new Error("Class is required")
|
||||
if (!course) throw new Error("Course is required")
|
||||
if (!isTimeHHMM(startTime) || !isTimeHHMM(endTime)) throw new Error("Invalid time format")
|
||||
if (startTime >= endTime) throw new Error("Start time must be earlier than end time")
|
||||
if (weekday < 1 || weekday > 7) throw new Error("Invalid weekday")
|
||||
|
||||
const owned = await verifyTeacherOwnsClass(classId, teacherId)
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
return insertClassScheduleItem({
|
||||
classId,
|
||||
weekday,
|
||||
startTime,
|
||||
endTime,
|
||||
course,
|
||||
location,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a classSchedule item by id.
|
||||
* Ownership: the teacher must own the class associated with the schedule item
|
||||
* (and the target class when classId is being changed).
|
||||
*/
|
||||
export async function updateClassScheduleItem(
|
||||
scheduleId: string,
|
||||
data: UpdateClassScheduleItemInput,
|
||||
): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = scheduleId.trim()
|
||||
if (!id) throw new Error("Missing schedule id")
|
||||
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: classSchedule.id,
|
||||
classId: classSchedule.classId,
|
||||
startTime: classSchedule.startTime,
|
||||
endTime: classSchedule.endTime,
|
||||
})
|
||||
.from(classSchedule)
|
||||
.where(eq(classSchedule.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Schedule item not found")
|
||||
|
||||
const ownedExisting = await verifyTeacherOwnsClass(existing.classId, teacherId)
|
||||
if (!ownedExisting) throw new Error("Schedule item not found")
|
||||
|
||||
const update: Partial<typeof classSchedule.$inferSelect> = {}
|
||||
|
||||
if (typeof data.classId === "string") {
|
||||
const nextClassId = data.classId.trim()
|
||||
if (!nextClassId) throw new Error("Class is required")
|
||||
|
||||
const ownedNext = await verifyTeacherOwnsClass(nextClassId, teacherId)
|
||||
if (!ownedNext) throw new Error("Class not found")
|
||||
update.classId = nextClassId
|
||||
}
|
||||
|
||||
if (typeof data.weekday === "number") {
|
||||
if (data.weekday < 1 || data.weekday > 7) throw new Error("Invalid weekday")
|
||||
update.weekday = data.weekday
|
||||
}
|
||||
|
||||
if (typeof data.course === "string") {
|
||||
const course = data.course.trim()
|
||||
if (!course) throw new Error("Course is required")
|
||||
update.course = course
|
||||
}
|
||||
|
||||
const nextStart = typeof data.startTime === "string" ? data.startTime.trim() : undefined
|
||||
const nextEnd = typeof data.endTime === "string" ? data.endTime.trim() : undefined
|
||||
if (nextStart !== undefined) {
|
||||
if (!isTimeHHMM(nextStart)) throw new Error("Invalid time format")
|
||||
update.startTime = nextStart
|
||||
}
|
||||
if (nextEnd !== undefined) {
|
||||
if (!isTimeHHMM(nextEnd)) throw new Error("Invalid time format")
|
||||
update.endTime = nextEnd
|
||||
}
|
||||
|
||||
if (update.startTime !== undefined || update.endTime !== undefined) {
|
||||
const mergedStart = update.startTime ?? existing.startTime
|
||||
const mergedEnd = update.endTime ?? existing.endTime
|
||||
if (typeof mergedStart === "string" && typeof mergedEnd === "string" && mergedStart >= mergedEnd) {
|
||||
throw new Error("Start time must be earlier than end time")
|
||||
}
|
||||
}
|
||||
|
||||
if (data.location !== undefined) {
|
||||
update.location = data.location?.trim() || null
|
||||
}
|
||||
|
||||
if (Object.keys(update).length === 0) return
|
||||
|
||||
await updateClassScheduleItemById(id, update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a classSchedule item by id.
|
||||
* Ownership: the teacher must own the class associated with the schedule item.
|
||||
*/
|
||||
export async function deleteClassScheduleItem(scheduleId: string): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = scheduleId.trim()
|
||||
if (!id) throw new Error("Missing schedule id")
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: classSchedule.id, classId: classSchedule.classId })
|
||||
.from(classSchedule)
|
||||
.where(eq(classSchedule.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Schedule item not found")
|
||||
|
||||
const owned = await verifyTeacherOwnsClass(existing.classId, teacherId)
|
||||
if (!owned) throw new Error("Schedule item not found")
|
||||
|
||||
await deleteClassScheduleItemById(id)
|
||||
}
|
||||
@@ -132,12 +132,13 @@ export async function getScheduleChanges(
|
||||
|
||||
const userMap = new Map<string, string>()
|
||||
if (userIds.length > 0) {
|
||||
const firstId = userIds[0]
|
||||
const userRows = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(
|
||||
userIds.length === 1
|
||||
? eq(users.id, userIds[0]!)
|
||||
userIds.length === 1 && firstId
|
||||
? eq(users.id, firstId)
|
||||
: or(...userIds.map((id) => eq(users.id, id)))
|
||||
)
|
||||
for (const u of userRows) userMap.set(u.id, u.name ?? "Unknown")
|
||||
@@ -218,8 +219,9 @@ export async function getClassConflicts(classId: string): Promise<ScheduleConfli
|
||||
const conflicts: ScheduleConflict[] = []
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
for (let j = i + 1; j < rows.length; j += 1) {
|
||||
const a = rows[i]!
|
||||
const b = rows[j]!
|
||||
const a = rows[i]
|
||||
const b = rows[j]
|
||||
if (!a || !b) continue
|
||||
if (a.weekday !== b.weekday) continue
|
||||
// Time overlap: a.start < b.end && b.start < a.end
|
||||
if (a.startTime < b.endTime && b.startTime < a.endTime) {
|
||||
@@ -236,14 +238,42 @@ export async function getClassConflicts(classId: string): Promise<ScheduleConfli
|
||||
|
||||
// --- Helpers for scheduling pages ---
|
||||
|
||||
export async function getAdminClassesForScheduling() {
|
||||
/** Lightweight class info for scheduling selectors */
|
||||
export type SchedulingClassOption = {
|
||||
id: string
|
||||
name: string
|
||||
grade: string
|
||||
}
|
||||
|
||||
/** Lightweight teacher info for scheduling selectors */
|
||||
export type SchedulingTeacherOption = {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
}
|
||||
|
||||
/** Lightweight classroom info for scheduling selectors */
|
||||
export type SchedulingClassroomOption = {
|
||||
id: string
|
||||
name: string
|
||||
building: string | null
|
||||
}
|
||||
|
||||
/** Class subject with assigned teacher for scheduling */
|
||||
export type SchedulingClassSubject = {
|
||||
subjectId: string
|
||||
subjectName: string
|
||||
teacherId: string | null
|
||||
}
|
||||
|
||||
export async function getAdminClassesForScheduling(): Promise<SchedulingClassOption[]> {
|
||||
return await db
|
||||
.select({ id: classes.id, name: classes.name, grade: classes.grade })
|
||||
.from(classes)
|
||||
.orderBy(classes.grade, classes.name)
|
||||
}
|
||||
|
||||
export async function getTeachersForScheduling() {
|
||||
export async function getTeachersForScheduling(): Promise<SchedulingTeacherOption[]> {
|
||||
return await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(users)
|
||||
@@ -252,14 +282,16 @@ export async function getTeachersForScheduling() {
|
||||
.orderBy(users.name)
|
||||
}
|
||||
|
||||
export async function getClassroomsForScheduling() {
|
||||
export async function getClassroomsForScheduling(): Promise<SchedulingClassroomOption[]> {
|
||||
return await db
|
||||
.select({ id: classrooms.id, name: classrooms.name, building: classrooms.building })
|
||||
.from(classrooms)
|
||||
.orderBy(classrooms.name)
|
||||
}
|
||||
|
||||
export async function getClassSubjectsForScheduling(classId: string) {
|
||||
export async function getClassSubjectsForScheduling(
|
||||
classId: string
|
||||
): Promise<SchedulingClassSubject[]> {
|
||||
return await db
|
||||
.select({
|
||||
subjectId: subjects.id,
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
export type ScheduleChangeStatus = "pending" | "approved" | "rejected" | "completed"
|
||||
|
||||
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
|
||||
export type CreateClassScheduleItemInput = {
|
||||
classId: string
|
||||
weekday: Weekday
|
||||
startTime: string
|
||||
endTime: string
|
||||
course: string
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export type UpdateClassScheduleItemInput = {
|
||||
classId?: string
|
||||
weekday?: Weekday
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
course?: string
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export interface SchedulingRule {
|
||||
id: string
|
||||
classId: string | null
|
||||
|
||||
Reference in New Issue
Block a user