refactor: P0-3/5/6 解耦修复 - 循环依赖/通知分发/课表写入口
P0-3: 修复 shared/lib <-> auth 循环依赖
- audit-logger.ts, change-logger.ts, auth-guard.ts, classes/data-access.ts
改用动态 import("@/auth") 打破静态模块级循环依赖
- shared/lib 不再静态导入 @/auth
P0-5: messaging 改用 notifications dispatcher
- messaging/actions.ts 的 sendMessageAction 改用 sendNotification
替代直接调用 createNotification
- 用户通知偏好(SMS/微信/邮件/站内)现在被正确尊重
P0-6: 统一 classSchedule 写入口到 scheduling/data-access
- 新增 insertClassScheduleItem/updateClassScheduleItemById/
deleteClassScheduleItemById/replaceClassSchedule 统一写入函数
- classes/data-access.ts 的三个 schedule 写入函数委托给 scheduling
- scheduling/actions.ts 的 applyAutoScheduleAction 改用 replaceClassSchedule
- 移除 scheduling/actions.ts 中不再使用的 classSchedule/createId 导入
验证: tsc --noEmit 0 errors, npm run lint 0 errors
This commit is contained in:
@@ -5,7 +5,6 @@ import { cache } from "react"
|
|||||||
import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
import { auth } from "@/auth"
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import {
|
import {
|
||||||
classes,
|
classes,
|
||||||
@@ -24,6 +23,11 @@ import {
|
|||||||
users,
|
users,
|
||||||
usersToRoles,
|
usersToRoles,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
|
import {
|
||||||
|
insertClassScheduleItem,
|
||||||
|
updateClassScheduleItemById,
|
||||||
|
deleteClassScheduleItemById,
|
||||||
|
} from "@/modules/scheduling/data-access"
|
||||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||||
import type {
|
import type {
|
||||||
AdminClassListItem,
|
AdminClassListItem,
|
||||||
@@ -47,6 +51,7 @@ import type {
|
|||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
const getSessionTeacherId = async (): Promise<string | null> => {
|
const getSessionTeacherId = async (): Promise<string | null> => {
|
||||||
|
const { auth } = await import("@/auth")
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
const userId = String(session?.user?.id ?? "").trim()
|
const userId = String(session?.user?.id ?? "").trim()
|
||||||
if (!userId) return null
|
if (!userId) return null
|
||||||
@@ -1859,9 +1864,8 @@ export async function createClassScheduleItem(data: CreateClassScheduleItemInput
|
|||||||
|
|
||||||
if (!owned) throw new Error("Class not found")
|
if (!owned) throw new Error("Class not found")
|
||||||
|
|
||||||
const id = createId()
|
// Delegate DB write to scheduling module (unified write entry point)
|
||||||
await db.insert(classSchedule).values({
|
return insertClassScheduleItem({
|
||||||
id,
|
|
||||||
classId,
|
classId,
|
||||||
weekday,
|
weekday,
|
||||||
startTime,
|
startTime,
|
||||||
@@ -1869,8 +1873,6 @@ export async function createClassScheduleItem(data: CreateClassScheduleItemInput
|
|||||||
course,
|
course,
|
||||||
location,
|
location,
|
||||||
})
|
})
|
||||||
|
|
||||||
return id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateClassScheduleItem(scheduleId: string, data: UpdateClassScheduleItemInput): Promise<void> {
|
export async function updateClassScheduleItem(scheduleId: string, data: UpdateClassScheduleItemInput): Promise<void> {
|
||||||
@@ -1944,10 +1946,8 @@ export async function updateClassScheduleItem(scheduleId: string, data: UpdateCl
|
|||||||
|
|
||||||
if (Object.keys(update).length === 0) return
|
if (Object.keys(update).length === 0) return
|
||||||
|
|
||||||
await db
|
// Delegate DB write to scheduling module (unified write entry point)
|
||||||
.update(classSchedule)
|
await updateClassScheduleItemById(id, update)
|
||||||
.set(update)
|
|
||||||
.where(eq(classSchedule.id, id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteClassScheduleItem(scheduleId: string): Promise<void> {
|
export async function deleteClassScheduleItem(scheduleId: string): Promise<void> {
|
||||||
@@ -1964,7 +1964,8 @@ export async function deleteClassScheduleItem(scheduleId: string): Promise<void>
|
|||||||
|
|
||||||
if (!owned) throw new Error("Schedule item not found")
|
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(
|
export const getStudentsSubjectScores = cache(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"
|
|||||||
import { PermissionDeniedError, requireAuth, requirePermission } from "@/shared/lib/auth-guard"
|
import { PermissionDeniedError, requireAuth, requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { sendNotification } from "@/modules/notifications/dispatcher"
|
||||||
|
|
||||||
import { SendMessageSchema } from "./schema"
|
import { SendMessageSchema } from "./schema"
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
markMessageAsRead,
|
markMessageAsRead,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
getNotifications,
|
getNotifications,
|
||||||
createNotification,
|
|
||||||
markNotificationAsRead,
|
markNotificationAsRead,
|
||||||
markAllNotificationsAsRead,
|
markAllNotificationsAsRead,
|
||||||
getRecipients,
|
getRecipients,
|
||||||
@@ -62,13 +62,15 @@ export async function sendMessageAction(
|
|||||||
parentMessageId: input.parentMessageId,
|
parentMessageId: input.parentMessageId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify the receiver about the new message
|
// Notify the receiver about the new message via the notifications dispatcher.
|
||||||
await createNotification({
|
// This respects user notification preferences (SMS/WeChat/Email/In-App).
|
||||||
|
await sendNotification({
|
||||||
userId: input.receiverId,
|
userId: input.receiverId,
|
||||||
type: "message",
|
type: "info",
|
||||||
title: input.subject ? `New message: ${input.subject}` : "New message",
|
title: input.subject ? `New message: ${input.subject}` : "New message",
|
||||||
content: input.content.slice(0, 200),
|
content: input.content.slice(0, 200),
|
||||||
link: `/messages/${id}`,
|
actionUrl: `/messages/${id}`,
|
||||||
|
metadata: { messageType: "message", messageId: id },
|
||||||
})
|
})
|
||||||
|
|
||||||
revalidatePath("/messages")
|
revalidatePath("/messages")
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { eq, or } from "drizzle-orm"
|
import { eq, or } from "drizzle-orm"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
|
||||||
|
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { classSchedule, users } from "@/shared/db/schema"
|
import { users } from "@/shared/db/schema"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSchedulingRules,
|
getSchedulingRules,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
getTeachersForScheduling,
|
getTeachersForScheduling,
|
||||||
getClassroomsForScheduling,
|
getClassroomsForScheduling,
|
||||||
getClassSubjectsForScheduling,
|
getClassSubjectsForScheduling,
|
||||||
|
replaceClassSchedule,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import { autoSchedule, buildDefaultTimeSlots } from "./auto-scheduler"
|
import { autoSchedule, buildDefaultTimeSlots } from "./auto-scheduler"
|
||||||
import {
|
import {
|
||||||
@@ -164,11 +164,10 @@ export async function applyAutoScheduleAction(
|
|||||||
return { success: false, message: "No schedules to apply" }
|
return { success: false, message: "No schedules to apply" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace existing schedule for the class
|
// Replace existing schedule for the class via unified write entry point
|
||||||
await db.transaction(async (tx) => {
|
await replaceClassSchedule(
|
||||||
await tx.delete(classSchedule).where(eq(classSchedule.classId, classId))
|
classId,
|
||||||
const rows = schedules.map((s) => ({
|
schedules.map((s) => ({
|
||||||
id: createId(),
|
|
||||||
classId,
|
classId,
|
||||||
weekday: s.weekday,
|
weekday: s.weekday,
|
||||||
startTime: s.startTime,
|
startTime: s.startTime,
|
||||||
@@ -176,8 +175,7 @@ export async function applyAutoScheduleAction(
|
|||||||
course: s.course,
|
course: s.course,
|
||||||
location: s.location ?? null,
|
location: s.location ?? null,
|
||||||
}))
|
}))
|
||||||
await tx.insert(classSchedule).values(rows)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
revalidatePath("/admin/scheduling/auto")
|
revalidatePath("/admin/scheduling/auto")
|
||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
|
|||||||
@@ -270,3 +270,99 @@ export async function getClassSubjectsForScheduling(classId: string) {
|
|||||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||||
.where(eq(classSubjectTeachers.classId, classId))
|
.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<string> {
|
||||||
|
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<Omit<ScheduleItemInput, "classId">> & { classId?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
const update: Partial<typeof classSchedule.$inferSelect> = {}
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createId } from "@paralleldrive/cuid2"
|
|||||||
import { headers } from "next/headers"
|
import { headers } from "next/headers"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { auditLogs } from "@/shared/db/schema"
|
import { auditLogs } from "@/shared/db/schema"
|
||||||
import { auth } from "@/auth"
|
|
||||||
|
|
||||||
export type AuditLogStatus = "success" | "failure"
|
export type AuditLogStatus = "success" | "failure"
|
||||||
|
|
||||||
@@ -17,13 +16,23 @@ export interface LogAuditParams {
|
|||||||
status?: AuditLogStatus
|
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.
|
* Record an audit log entry for the current authenticated user.
|
||||||
* Silently fails on error so it never breaks the main operation.
|
* Silently fails on error so it never breaks the main operation.
|
||||||
*/
|
*/
|
||||||
export async function logAudit(params: LogAuditParams): Promise<void> {
|
export async function logAudit(params: LogAuditParams): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await getCurrentSession()
|
||||||
const headerList = await headers()
|
const headerList = await headers()
|
||||||
const ipAddress =
|
const ipAddress =
|
||||||
headerList.get("x-forwarded-for") ??
|
headerList.get("x-forwarded-for") ??
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { auth } from "@/auth"
|
|
||||||
import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
|
import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import {
|
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.
|
* Get the full authentication context for the current user.
|
||||||
* Throws if not authenticated.
|
* Throws if not authenticated.
|
||||||
*/
|
*/
|
||||||
export async function getAuthContext(): Promise<AuthContext> {
|
export async function getAuthContext(): Promise<AuthContext> {
|
||||||
const session = await auth()
|
const session = await getCurrentSession()
|
||||||
const userId = session?.user?.id
|
const userId = session?.user?.id
|
||||||
if (!userId) throw new PermissionDeniedError("auth_required")
|
if (!userId) throw new PermissionDeniedError("auth_required")
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { headers } from "next/headers"
|
import { headers } from "next/headers"
|
||||||
|
|
||||||
import { auth } from "@/auth"
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { dataChangeLogs } from "@/shared/db/schema"
|
import { dataChangeLogs } from "@/shared/db/schema"
|
||||||
|
|
||||||
@@ -17,13 +16,23 @@ export interface LogDataChangeParams {
|
|||||||
newValue?: Record<string, unknown>
|
newValue?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Record a data change log entry for the current authenticated user.
|
||||||
* Silently fails on error so it never blocks the main operation.
|
* Silently fails on error so it never blocks the main operation.
|
||||||
*/
|
*/
|
||||||
export async function logDataChange(params: LogDataChangeParams): Promise<void> {
|
export async function logDataChange(params: LogDataChangeParams): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await getCurrentSession()
|
||||||
const headerList = await headers()
|
const headerList = await headers()
|
||||||
const ipAddress =
|
const ipAddress =
|
||||||
headerList.get("x-forwarded-for") ??
|
headerList.get("x-forwarded-for") ??
|
||||||
|
|||||||
Reference in New Issue
Block a user