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.
226 lines
7.7 KiB
TypeScript
226 lines
7.7 KiB
TypeScript
import "server-only"
|
|
|
|
import { cache } from "react"
|
|
import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm"
|
|
|
|
import { db } from "@/shared/db"
|
|
import {
|
|
courseSelections,
|
|
electiveCourses,
|
|
} from "@/shared/db/schema"
|
|
|
|
import { getStudentActiveGradeId } from "@/modules/classes/data-access"
|
|
import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access"
|
|
import { getUserNamesByIds } from "@/modules/users/data-access"
|
|
|
|
import type {
|
|
CourseSelectionWithDetails,
|
|
ElectiveCourseWithDetails,
|
|
} from "./types"
|
|
|
|
type CourseCoreRow = typeof electiveCourses.$inferSelect
|
|
|
|
type SelectionCoreRow = {
|
|
id: string
|
|
courseId: string
|
|
studentId: string
|
|
status: (typeof courseSelections.status.enumValues)[number]
|
|
priority: number | null
|
|
selectedAt: Date
|
|
enrolledAt: Date | null
|
|
droppedAt: Date | null
|
|
lotteryRank: number | null
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
courseName: string | null
|
|
courseCapacity: number | null
|
|
courseEnrolledCount: number | null
|
|
courseStatus: (typeof electiveCourses.status.enumValues)[number] | null
|
|
}
|
|
|
|
const toIso = (d: Date | null | undefined): string | null =>
|
|
d ? d.toISOString() : null
|
|
|
|
const toIsoRequired = (d: Date): string => d.toISOString()
|
|
|
|
const mapCourseRow = (
|
|
r: CourseCoreRow,
|
|
teacherNames: Map<string, string | null>,
|
|
subjectNames: Map<string, string>,
|
|
gradeNames: Map<string, string>
|
|
): ElectiveCourseWithDetails => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
subjectId: r.subjectId,
|
|
teacherId: r.teacherId,
|
|
gradeId: r.gradeId,
|
|
description: r.description,
|
|
capacity: r.capacity,
|
|
enrolledCount: r.enrolledCount,
|
|
classroom: r.classroom,
|
|
schedule: r.schedule,
|
|
startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null,
|
|
endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null,
|
|
selectionStartAt: toIso(r.selectionStartAt),
|
|
selectionEndAt: toIso(r.selectionEndAt),
|
|
status: r.status,
|
|
selectionMode: r.selectionMode,
|
|
credit: String(r.credit),
|
|
createdAt: toIsoRequired(r.createdAt),
|
|
updatedAt: toIsoRequired(r.updatedAt),
|
|
teacherName: r.teacherId ? (teacherNames.get(r.teacherId) ?? null) : null,
|
|
subjectName: r.subjectId ? (subjectNames.get(r.subjectId) ?? null) : null,
|
|
gradeName: r.gradeId ? (gradeNames.get(r.gradeId) ?? null) : null,
|
|
})
|
|
|
|
const mapSelectionRow = (
|
|
r: SelectionCoreRow,
|
|
studentNames: Map<string, string | null>
|
|
): CourseSelectionWithDetails => ({
|
|
id: r.id,
|
|
courseId: r.courseId,
|
|
studentId: r.studentId,
|
|
status: r.status,
|
|
priority: r.priority,
|
|
selectedAt: toIsoRequired(r.selectedAt),
|
|
enrolledAt: toIso(r.enrolledAt),
|
|
droppedAt: toIso(r.droppedAt),
|
|
lotteryRank: r.lotteryRank,
|
|
createdAt: toIsoRequired(r.createdAt),
|
|
updatedAt: toIsoRequired(r.updatedAt),
|
|
courseName: r.courseName,
|
|
studentName: r.studentId ? (studentNames.get(r.studentId) ?? null) : null,
|
|
courseCapacity: r.courseCapacity,
|
|
courseEnrolledCount: r.courseEnrolledCount,
|
|
courseStatus: r.courseStatus,
|
|
})
|
|
|
|
const buildCourseCoreSelect = () =>
|
|
db
|
|
.select({
|
|
id: electiveCourses.id,
|
|
name: electiveCourses.name,
|
|
subjectId: electiveCourses.subjectId,
|
|
teacherId: electiveCourses.teacherId,
|
|
gradeId: electiveCourses.gradeId,
|
|
description: electiveCourses.description,
|
|
capacity: electiveCourses.capacity,
|
|
enrolledCount: electiveCourses.enrolledCount,
|
|
classroom: electiveCourses.classroom,
|
|
schedule: electiveCourses.schedule,
|
|
startDate: electiveCourses.startDate,
|
|
endDate: electiveCourses.endDate,
|
|
selectionStartAt: electiveCourses.selectionStartAt,
|
|
selectionEndAt: electiveCourses.selectionEndAt,
|
|
status: electiveCourses.status,
|
|
selectionMode: electiveCourses.selectionMode,
|
|
credit: electiveCourses.credit,
|
|
createdAt: electiveCourses.createdAt,
|
|
updatedAt: electiveCourses.updatedAt,
|
|
})
|
|
.from(electiveCourses)
|
|
|
|
const buildSelectionCoreSelect = () =>
|
|
db
|
|
.select({
|
|
id: courseSelections.id,
|
|
courseId: courseSelections.courseId,
|
|
studentId: courseSelections.studentId,
|
|
status: courseSelections.status,
|
|
priority: courseSelections.priority,
|
|
selectedAt: courseSelections.selectedAt,
|
|
enrolledAt: courseSelections.enrolledAt,
|
|
droppedAt: courseSelections.droppedAt,
|
|
lotteryRank: courseSelections.lotteryRank,
|
|
createdAt: courseSelections.createdAt,
|
|
updatedAt: courseSelections.updatedAt,
|
|
courseName: electiveCourses.name,
|
|
courseCapacity: electiveCourses.capacity,
|
|
courseEnrolledCount: electiveCourses.enrolledCount,
|
|
courseStatus: electiveCourses.status,
|
|
})
|
|
.from(courseSelections)
|
|
.leftJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))
|
|
|
|
const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<{
|
|
teacherNames: Map<string, string | null>
|
|
subjectNames: Map<string, string>
|
|
gradeNames: Map<string, string>
|
|
}> => {
|
|
const teacherIds = Array.from(new Set(rows.map((r) => r.teacherId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
|
const [userMap, subjects, grades] = await Promise.all([
|
|
getUserNamesByIds(teacherIds),
|
|
getSubjectOptions(),
|
|
getGradeOptions(),
|
|
])
|
|
|
|
const teacherNames = new Map<string, string | null>()
|
|
for (const [id, user] of userMap.entries()) {
|
|
teacherNames.set(id, user.name)
|
|
}
|
|
const subjectNames = new Map<string, string>()
|
|
for (const s of subjects) subjectNames.set(s.id, s.name)
|
|
const gradeNames = new Map<string, string>()
|
|
for (const g of grades) gradeNames.set(g.id, g.name)
|
|
|
|
return { teacherNames, subjectNames, gradeNames }
|
|
}
|
|
|
|
const resolveStudentDisplayNames = async (rows: SelectionCoreRow[]): Promise<Map<string, string | null>> => {
|
|
const studentIds = Array.from(new Set(rows.map((r) => r.studentId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
|
const userMap = await getUserNamesByIds(studentIds)
|
|
const studentNames = new Map<string, string | null>()
|
|
for (const [id, user] of userMap.entries()) {
|
|
studentNames.set(id, user.name)
|
|
}
|
|
return studentNames
|
|
}
|
|
|
|
export const getCourseSelections = cache(
|
|
async (
|
|
courseId: string
|
|
): Promise<CourseSelectionWithDetails[]> => {
|
|
const rows = await buildSelectionCoreSelect()
|
|
.where(eq(courseSelections.courseId, courseId))
|
|
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
|
|
const studentNames = await resolveStudentDisplayNames(rows)
|
|
return rows.map((r) => mapSelectionRow(r, studentNames))
|
|
}
|
|
)
|
|
|
|
export const getStudentSelections = cache(
|
|
async (
|
|
studentId: string
|
|
): Promise<CourseSelectionWithDetails[]> => {
|
|
const rows = await buildSelectionCoreSelect()
|
|
.where(eq(courseSelections.studentId, studentId))
|
|
.orderBy(desc(courseSelections.selectedAt))
|
|
const studentNames = await resolveStudentDisplayNames(rows)
|
|
return rows.map((r) => mapSelectionRow(r, studentNames))
|
|
}
|
|
)
|
|
|
|
export const getStudentGradeId = cache(async (studentId: string): Promise<string | null> => {
|
|
return getStudentActiveGradeId(studentId)
|
|
})
|
|
|
|
export const getAvailableCoursesForStudent = cache(
|
|
async (
|
|
studentId: string,
|
|
gradeId?: string | null
|
|
): Promise<ElectiveCourseWithDetails[]> => {
|
|
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
|
|
const conditions: SQL[] = [eq(electiveCourses.status, "open")]
|
|
if (resolvedGradeId) {
|
|
conditions.push(
|
|
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
|
|
)
|
|
}
|
|
const rows = await buildCourseCoreSelect()
|
|
.where(and(...conditions))
|
|
.orderBy(desc(electiveCourses.createdAt))
|
|
const displayMaps = await resolveCourseDisplayNames(rows)
|
|
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
|
|
}
|
|
)
|