import "server-only" import { and, asc, desc, eq, inArray, isNull, or, type SQL } from "drizzle-orm" import { createId } from "@paralleldrive/cuid2" import { db } from "@/shared/db" import { classes, classSchedule, classSubjectTeachers, classrooms, scheduleChanges, schedulingRules, subjects, users, } from "@/shared/db/schema" import type { ScheduleChangeListItem, ScheduleChangeQueryParams, ScheduleConflict, SchedulingRule, } from "./types" import type { SchedulingRuleInput, ScheduleChangeInput } from "./schema" const serializeDate = (d: Date | string | null): string | null => d ? new Date(d).toISOString().slice(0, 10) : null const mapRule = (r: typeof schedulingRules.$inferSelect): SchedulingRule => ({ id: r.id, classId: r.classId ?? null, maxDailyHours: r.maxDailyHours ?? 8, maxContinuousHours: r.maxContinuousHours ?? 2, lunchBreakStart: r.lunchBreakStart ?? "12:00", lunchBreakEnd: r.lunchBreakEnd ?? "13:00", morningStart: r.morningStart ?? "08:00", afternoonEnd: r.afternoonEnd ?? "17:00", avoidBackToBack: r.avoidBackToBack ?? false, balancedSubjects: r.balancedSubjects ?? true, createdAt: r.createdAt.toISOString(), updatedAt: r.updatedAt.toISOString(), }) export async function getSchedulingRules(classId?: string): Promise { const conditions: SQL[] = [] if (classId) { const cond = or(eq(schedulingRules.classId, classId), isNull(schedulingRules.classId)) if (cond) conditions.push(cond) } const rows = await db .select() .from(schedulingRules) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(schedulingRules.classId), desc(schedulingRules.updatedAt)) return rows.map(mapRule) } export async function upsertSchedulingRules(data: SchedulingRuleInput): Promise { const [existing] = await db .select() .from(schedulingRules) .where(eq(schedulingRules.classId, data.classId)) .limit(1) if (existing) { await db .update(schedulingRules) .set({ maxDailyHours: data.maxDailyHours ?? 8, maxContinuousHours: data.maxContinuousHours ?? 2, lunchBreakStart: data.lunchBreakStart ?? "12:00", lunchBreakEnd: data.lunchBreakEnd ?? "13:00", morningStart: data.morningStart ?? "08:00", afternoonEnd: data.afternoonEnd ?? "17:00", avoidBackToBack: data.avoidBackToBack ?? false, balancedSubjects: data.balancedSubjects ?? true, updatedAt: new Date(), }) .where(eq(schedulingRules.id, existing.id)) return existing.id } const id = createId() await db.insert(schedulingRules).values({ id, classId: data.classId, maxDailyHours: data.maxDailyHours ?? 8, maxContinuousHours: data.maxContinuousHours ?? 2, lunchBreakStart: data.lunchBreakStart ?? "12:00", lunchBreakEnd: data.lunchBreakEnd ?? "13:00", morningStart: data.morningStart ?? "08:00", afternoonEnd: data.afternoonEnd ?? "17:00", avoidBackToBack: data.avoidBackToBack ?? false, balancedSubjects: data.balancedSubjects ?? true, }) return id } export async function getScheduleChanges( params: ScheduleChangeQueryParams ): Promise { const conditions: SQL[] = [] if (params.classId) conditions.push(eq(scheduleChanges.classId, params.classId)) if (params.status) conditions.push(eq(scheduleChanges.status, params.status)) if (params.requesterId) conditions.push(eq(scheduleChanges.requestedBy, params.requesterId)) const rows = await db .select({ change: scheduleChanges, className: classes.name, originalTeacherName: users.name, }) .from(scheduleChanges) .innerJoin(classes, eq(classes.id, scheduleChanges.classId)) .leftJoin(users, eq(users.id, scheduleChanges.originalTeacherId)) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(scheduleChanges.createdAt)) // Resolve substitute teacher & approver names separately to avoid join ambiguity const userIds = Array.from( new Set( rows.flatMap((r) => [ r.change.substituteTeacherId, r.change.approvedBy, r.change.requestedBy, ].filter((v): v is string => typeof v === "string" && v.length > 0)) ) ) const userMap = new Map() if (userIds.length > 0) { const userRows = await db .select({ id: users.id, name: users.name }) .from(users) .where(inArray(users.id, userIds)) for (const u of userRows) userMap.set(u.id, u.name ?? "Unknown") } return rows.map((r) => ({ id: r.change.id, originalScheduleId: r.change.originalScheduleId ?? null, classId: r.change.classId, originalTeacherId: r.change.originalTeacherId ?? null, substituteTeacherId: r.change.substituteTeacherId ?? null, originalDate: serializeDate(r.change.originalDate), newDate: serializeDate(r.change.newDate), newStartTime: r.change.newStartTime ?? null, newEndTime: r.change.newEndTime ?? null, reason: r.change.reason ?? null, status: r.change.status, requestedBy: r.change.requestedBy, approvedBy: r.change.approvedBy ?? null, createdAt: r.change.createdAt.toISOString(), updatedAt: r.change.updatedAt.toISOString(), className: r.className, originalTeacherName: r.originalTeacherName ?? null, substituteTeacherName: r.change.substituteTeacherId ? userMap.get(r.change.substituteTeacherId) ?? null : null, requesterName: userMap.get(r.change.requestedBy) ?? "Unknown", approverName: r.change.approvedBy ? userMap.get(r.change.approvedBy) ?? null : null, })) } export async function createScheduleChange( data: ScheduleChangeInput, requestedBy: string ): Promise { const id = createId() await db.insert(scheduleChanges).values({ id, originalScheduleId: data.originalScheduleId ?? null, classId: data.classId, originalTeacherId: data.originalTeacherId ?? null, substituteTeacherId: data.substituteTeacherId ?? null, originalDate: data.originalDate ? new Date(data.originalDate) : null, newDate: data.newDate ? new Date(data.newDate) : null, newStartTime: data.newStartTime ?? null, newEndTime: data.newEndTime ?? null, reason: data.reason, status: "pending", requestedBy, }) return id } export async function updateScheduleChangeStatus( id: string, status: "approved" | "rejected" | "completed", approverId: string ): Promise { await db .update(scheduleChanges) .set({ status, approvedBy: approverId, updatedAt: new Date() }) .where(eq(scheduleChanges.id, id)) } export async function getClassConflicts(classId: string): Promise { const rows = await db .select({ id: classSchedule.id, weekday: classSchedule.weekday, startTime: classSchedule.startTime, endTime: classSchedule.endTime, course: classSchedule.course, }) .from(classSchedule) .where(eq(classSchedule.classId, classId)) .orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime)) 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] 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) { conflicts.push({ type: "class_overlap", description: `Time overlap on day ${a.weekday}: "${a.course}" (${a.startTime}-${a.endTime}) vs "${b.course}" (${b.startTime}-${b.endTime})`, scheduleIds: [a.id, b.id], }) } } } return conflicts } // --- Helpers for scheduling pages --- /** 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 { return await db .select({ id: classes.id, name: classes.name, grade: classes.grade }) .from(classes) .orderBy(classes.grade, classes.name) } export async function getTeachersForScheduling(): Promise { return await db .select({ id: users.id, name: users.name, email: users.email }) .from(users) .innerJoin(classSubjectTeachers, eq(classSubjectTeachers.teacherId, users.id)) .groupBy(users.id, users.name, users.email) .orderBy(users.name) } export async function getClassroomsForScheduling(): Promise { return await db .select({ id: classrooms.id, name: classrooms.name, building: classrooms.building }) .from(classrooms) .orderBy(classrooms.name) } export async function getClassSubjectsForScheduling( classId: string ): Promise { return await db .select({ subjectId: subjects.id, subjectName: subjects.name, teacherId: classSubjectTeachers.teacherId, }) .from(classSubjectTeachers) .innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId)) .where(eq(classSubjectTeachers.classId, classId)) } // --------------------------------------------------------------------------- // Unified classSchedule write entry points // --------------------------------------------------------------------------- // All classSchedule writes MUST go through these functions to ensure // consistent conflict detection and data integrity. // See: docs/architecture/audit/01_decoupling_roadmap.md P0-6 // --------------------------------------------------------------------------- /** Input for a single schedule item insert */ export interface ScheduleItemInput { classId: string weekday: number startTime: string endTime: string course: string location?: string | null } /** * Insert a single classSchedule row. * Returns the generated id. */ export async function insertClassScheduleItem( item: ScheduleItemInput ): Promise { const id = createId() await db.insert(classSchedule).values({ id, classId: item.classId, weekday: item.weekday, startTime: item.startTime, endTime: item.endTime, course: item.course, location: item.location ?? null, }) return id } /** * Update a classSchedule row by id. * Only the provided fields are updated. */ export async function updateClassScheduleItemById( scheduleId: string, data: Partial> & { classId?: string } ): Promise { const update: Partial = {} if (data.classId !== undefined) update.classId = data.classId if (data.weekday !== undefined) update.weekday = data.weekday if (data.startTime !== undefined) update.startTime = data.startTime if (data.endTime !== undefined) update.endTime = data.endTime if (data.course !== undefined) update.course = data.course if (data.location !== undefined) update.location = data.location ?? null if (Object.keys(update).length === 0) return await db .update(classSchedule) .set(update) .where(eq(classSchedule.id, scheduleId)) } /** * Delete a classSchedule row by id. */ export async function deleteClassScheduleItemById(scheduleId: string): Promise { await db.delete(classSchedule).where(eq(classSchedule.id, scheduleId)) } /** * Replace all schedule items for a class in a single transaction. * Deletes existing items then inserts the new ones atomically. * * This is the single entry point for batch schedule replacement * (used by auto-scheduling and admin bulk operations). */ export async function replaceClassSchedule( classId: string, items: ScheduleItemInput[] ): Promise { await db.transaction(async (tx) => { await tx.delete(classSchedule).where(eq(classSchedule.classId, classId)) if (items.length === 0) return const rows = items.map((s) => ({ id: createId(), classId, weekday: s.weekday, startTime: s.startTime, endTime: s.endTime, course: s.course, location: s.location ?? null, })) await tx.insert(classSchedule).values(rows) }) } // --------------------------------------------------------------------------- // Schedule grid view entries for admin scheduling pages // --------------------------------------------------------------------------- /** Lightweight schedule entry for the admin schedule grid view */ export type ScheduleEntry = { id: string dayOfWeek: number period: number subject: string teacherName: string className: string room: string | null } /** * Get schedule entries for the admin schedule grid view. * Returns a flattened list of schedule items keyed by day/period. * * Note: simplified implementation returns an empty array; a real * implementation should join classSchedule with classes/users to * populate teacherName/className/subject/room. */ export async function getScheduleEntriesForAdmin(): Promise { return [] }