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:
SpecialX
2026-06-19 05:13:09 +08:00
parent 063baffe4c
commit 49291fcc31
114 changed files with 12548 additions and 3395 deletions

View File

@@ -1,5 +1,20 @@
import "server-only"
/**
* 私信数据访问层
*
* 职责:
* - getMessages / getMessageById / getMessageThread: 私信查询
* - createMessage / markMessageAsRead / deleteMessage: 私信 CRUD
* - getUnreadMessageCount: 未读私信计数
* - getRecipients: 获取收件人列表(按 DataScope 过滤)
*
* 注意: 通知相关函数createNotification / getNotifications /
* markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount
* 已迁移到 notifications/data-access.tsP0-4 / P1-5 修复)。
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
*/
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, or } from "drizzle-orm"
@@ -7,7 +22,6 @@ import { and, count, desc, eq, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db"
import {
messages,
messageNotifications,
users,
classEnrollments,
classes,
@@ -15,18 +29,16 @@ import {
import type { DataScope } from "@/shared/types/permissions"
import type {
Message,
Notification,
NotificationType,
GetMessagesParams,
GetNotificationsParams,
CreateMessageInput,
CreateNotificationInput,
PaginatedResult,
RecipientOption,
} from "./types"
const toIso = (d: Date | null | undefined): string | null => (d ? d.toISOString() : null)
const toIsoRequired = (d: Date): string => d.toISOString()
interface MessageRow {
id: string
senderId: string
@@ -39,17 +51,6 @@ interface MessageRow {
createdAt: Date
}
interface NotificationRow {
id: string
userId: string
type: string
title: string
content: string | null
link: string | null
isRead: boolean
createdAt: Date
}
async function resolveUserNames(userIds: string[]): Promise<Map<string, string>> {
const uniqueIds = [...new Set(userIds)].filter(Boolean)
if (uniqueIds.length === 0) return new Map()
@@ -71,18 +72,7 @@ const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
isRead: r.isRead,
readAt: toIso(r.readAt),
parentMessageId: r.parentMessageId,
createdAt: toIso(r.createdAt) as string,
})
const mapNotification = (r: NotificationRow): Notification => ({
id: r.id,
userId: r.userId,
type: r.type as NotificationType,
title: r.title,
content: r.content,
link: r.link,
isRead: r.isRead,
createdAt: toIso(r.createdAt) as string,
createdAt: toIsoRequired(r.createdAt),
})
export const getMessages = cache(
@@ -94,7 +84,10 @@ export const getMessages = cache(
const conds = []
if (params.type === "inbox") conds.push(eq(messages.receiverId, params.userId))
else if (params.type === "sent") conds.push(eq(messages.senderId, params.userId))
else conds.push(or(eq(messages.receiverId, params.userId), eq(messages.senderId, params.userId))!)
else {
const cond = or(eq(messages.receiverId, params.userId), eq(messages.senderId, params.userId))
if (cond) conds.push(cond)
}
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
@@ -114,7 +107,7 @@ export const getMessageById = cache(
const [row] = await db
.select()
.from(messages)
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))!))
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))))
.limit(1)
if (!row) return null
const nameMap = await resolveUserNames([row.senderId, row.receiverId])
@@ -160,7 +153,7 @@ export async function markMessageAsRead(id: string, userId: string): Promise<voi
export async function deleteMessage(id: string, userId: string): Promise<void> {
await db
.delete(messages)
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))!))
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))))
}
export const getUnreadMessageCount = cache(async (userId: string): Promise<number> => {
@@ -171,59 +164,6 @@ export const getUnreadMessageCount = cache(async (userId: string): Promise<numbe
return Number(row?.value ?? 0)
})
export const getNotifications = cache(
async (userId: string, params?: GetNotificationsParams): Promise<PaginatedResult<Notification>> => {
const page = Math.max(1, params?.page ?? 1)
const pageSize = Math.max(1, params?.pageSize ?? 20)
const offset = (page - 1) * pageSize
const conds = [eq(messageNotifications.userId, userId)]
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
db.select().from(messageNotifications).where(where).orderBy(desc(messageNotifications.createdAt)).limit(pageSize).offset(offset),
db.select({ value: count() }).from(messageNotifications).where(where),
])
const total = Number(totalRow?.value ?? 0)
return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
}
)
export async function createNotification(data: CreateNotificationInput): Promise<string> {
const id = createId()
await db.insert(messageNotifications).values({
id,
userId: data.userId,
type: data.type,
title: data.title,
content: data.content ?? null,
link: data.link ?? null,
})
return id
}
export async function markNotificationAsRead(id: string, userId: string): Promise<void> {
await db
.update(messageNotifications)
.set({ isRead: true })
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
}
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
await db
.update(messageNotifications)
.set({ isRead: true })
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
}
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => {
const [row] = await db
.select({ value: count() })
.from(messageNotifications)
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
return Number(row?.value ?? 0)
})
export const getRecipients = cache(
async (userId: string, scope: DataScope): Promise<RecipientOption[]> => {
if (scope.type === "all") {
@@ -250,3 +190,15 @@ export const getRecipients = cache(
return []
}
)
// ---------------------------------------------------------------------------
// 向后兼容 re-export通知 CRUD 已迁移到 notifications/data-access.ts
// ---------------------------------------------------------------------------
export {
createNotification,
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
getUnreadNotificationCount,
} from "@/modules/notifications/data-access"