diff --git a/src/modules/classes/data-access.ts b/src/modules/classes/data-access.ts index 9f96e38..c03c401 100644 --- a/src/modules/classes/data-access.ts +++ b/src/modules/classes/data-access.ts @@ -5,7 +5,6 @@ import { cache } from "react" import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm" import { createId } from "@paralleldrive/cuid2" -import { auth } from "@/auth" import { db } from "@/shared/db" import { classes, @@ -24,6 +23,11 @@ import { users, usersToRoles, } from "@/shared/db/schema" +import { + insertClassScheduleItem, + updateClassScheduleItemById, + deleteClassScheduleItemById, +} from "@/modules/scheduling/data-access" import { DEFAULT_CLASS_SUBJECTS } from "./types" import type { AdminClassListItem, @@ -47,6 +51,7 @@ import type { } from "./types" const getSessionTeacherId = async (): Promise => { + const { auth } = await import("@/auth") const session = await auth() const userId = String(session?.user?.id ?? "").trim() if (!userId) return null @@ -1859,9 +1864,8 @@ export async function createClassScheduleItem(data: CreateClassScheduleItemInput if (!owned) throw new Error("Class not found") - const id = createId() - await db.insert(classSchedule).values({ - id, + // Delegate DB write to scheduling module (unified write entry point) + return insertClassScheduleItem({ classId, weekday, startTime, @@ -1869,8 +1873,6 @@ export async function createClassScheduleItem(data: CreateClassScheduleItemInput course, location, }) - - return id } export async function updateClassScheduleItem(scheduleId: string, data: UpdateClassScheduleItemInput): Promise { @@ -1944,10 +1946,8 @@ export async function updateClassScheduleItem(scheduleId: string, data: UpdateCl if (Object.keys(update).length === 0) return - await db - .update(classSchedule) - .set(update) - .where(eq(classSchedule.id, id)) + // Delegate DB write to scheduling module (unified write entry point) + await updateClassScheduleItemById(id, update) } export async function deleteClassScheduleItem(scheduleId: string): Promise { @@ -1964,7 +1964,8 @@ export async function deleteClassScheduleItem(scheduleId: string): Promise if (!owned) throw new Error("Schedule item not found") - await db.delete(classSchedule).where(eq(classSchedule.id, id)) + // Delegate DB write to scheduling module (unified write entry point) + await deleteClassScheduleItemById(id) } export const getStudentsSubjectScores = cache( diff --git a/src/modules/messaging/actions.ts b/src/modules/messaging/actions.ts index 11acb43..8514736 100644 --- a/src/modules/messaging/actions.ts +++ b/src/modules/messaging/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache" import { PermissionDeniedError, requireAuth, requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" +import { sendNotification } from "@/modules/notifications/dispatcher" import { SendMessageSchema } from "./schema" import { @@ -13,7 +14,6 @@ import { markMessageAsRead, deleteMessage, getNotifications, - createNotification, markNotificationAsRead, markAllNotificationsAsRead, getRecipients, @@ -62,13 +62,15 @@ export async function sendMessageAction( parentMessageId: input.parentMessageId, }) - // Notify the receiver about the new message - await createNotification({ + // Notify the receiver about the new message via the notifications dispatcher. + // This respects user notification preferences (SMS/WeChat/Email/In-App). + await sendNotification({ userId: input.receiverId, - type: "message", + type: "info", title: input.subject ? `New message: ${input.subject}` : "New message", content: input.content.slice(0, 200), - link: `/messages/${id}`, + actionUrl: `/messages/${id}`, + metadata: { messageType: "message", messageId: id }, }) revalidatePath("/messages") diff --git a/src/modules/scheduling/actions.ts b/src/modules/scheduling/actions.ts index c4462bb..e5c80db 100644 --- a/src/modules/scheduling/actions.ts +++ b/src/modules/scheduling/actions.ts @@ -2,14 +2,13 @@ import { revalidatePath } from "next/cache" import { eq, or } from "drizzle-orm" -import { createId } from "@paralleldrive/cuid2" 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 { classSchedule, users } from "@/shared/db/schema" +import { users } from "@/shared/db/schema" import { getSchedulingRules, @@ -22,6 +21,7 @@ import { getTeachersForScheduling, getClassroomsForScheduling, getClassSubjectsForScheduling, + replaceClassSchedule, } from "./data-access" import { autoSchedule, buildDefaultTimeSlots } from "./auto-scheduler" import { @@ -164,11 +164,10 @@ export async function applyAutoScheduleAction( return { success: false, message: "No schedules to apply" } } - // Replace existing schedule for the class - await db.transaction(async (tx) => { - await tx.delete(classSchedule).where(eq(classSchedule.classId, classId)) - const rows = schedules.map((s) => ({ - id: createId(), + // Replace existing schedule for the class via unified write entry point + await replaceClassSchedule( + classId, + schedules.map((s) => ({ classId, weekday: s.weekday, startTime: s.startTime, @@ -176,8 +175,7 @@ export async function applyAutoScheduleAction( course: s.course, location: s.location ?? null, })) - await tx.insert(classSchedule).values(rows) - }) + ) revalidatePath("/admin/scheduling/auto") revalidatePath("/teacher/classes/schedule") diff --git a/src/modules/scheduling/data-access.ts b/src/modules/scheduling/data-access.ts index e032571..7aab541 100644 --- a/src/modules/scheduling/data-access.ts +++ b/src/modules/scheduling/data-access.ts @@ -270,3 +270,99 @@ export async function getClassSubjectsForScheduling(classId: string) { .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) + }) +} diff --git a/src/shared/lib/audit-logger.ts b/src/shared/lib/audit-logger.ts index 0b0bd0f..b44e515 100644 --- a/src/shared/lib/audit-logger.ts +++ b/src/shared/lib/audit-logger.ts @@ -4,7 +4,6 @@ import { createId } from "@paralleldrive/cuid2" import { headers } from "next/headers" import { db } from "@/shared/db" import { auditLogs } from "@/shared/db/schema" -import { auth } from "@/auth" export type AuditLogStatus = "success" | "failure" @@ -17,13 +16,23 @@ 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 { try { - const session = await auth() + const session = await getCurrentSession() const headerList = await headers() const ipAddress = headerList.get("x-forwarded-for") ?? diff --git a/src/shared/lib/auth-guard.ts b/src/shared/lib/auth-guard.ts index a8de787..c50862c 100644 --- a/src/shared/lib/auth-guard.ts +++ b/src/shared/lib/auth-guard.ts @@ -1,4 +1,3 @@ -import { auth } from "@/auth" import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions" import { db } from "@/shared/db" import { @@ -16,12 +15,22 @@ 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 { - const session = await auth() + const session = await getCurrentSession() const userId = session?.user?.id if (!userId) throw new PermissionDeniedError("auth_required") diff --git a/src/shared/lib/change-logger.ts b/src/shared/lib/change-logger.ts index 5d65524..3928cdd 100644 --- a/src/shared/lib/change-logger.ts +++ b/src/shared/lib/change-logger.ts @@ -3,7 +3,6 @@ import { createId } from "@paralleldrive/cuid2" import { headers } from "next/headers" -import { auth } from "@/auth" import { db } from "@/shared/db" import { dataChangeLogs } from "@/shared/db/schema" @@ -17,13 +16,23 @@ export interface LogDataChangeParams { newValue?: Record } +/** + * 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 { try { - const session = await auth() + const session = await getCurrentSession() const headerList = await headers() const ipAddress = headerList.get("x-forwarded-for") ??