fix: patch P0 security vulnerabilities and critical UX issues across 6 modules

Security: Add admin/layout.tsx auth guard; Add requirePermission() to 12 admin pages

Dashboard: Fix StudentStatsGrid rendering; Fix teacher greeting; Add loading/error boundaries; Fix col-span; Add metadata

Announcements: Fix audience filtering; Add user detail page; Trigger notifications on publish; Pass classes data; Add loading.tsx

Messages: Implement soft delete; Add unread badge with polling; Add notification dropdown polling; Add keyword search; Add quiet hours DND

Management: Add loading/error for 9 admin routes; Fix admin-classes-view to use Select for school/grade

Profile/Settings: Add loading/error; Fix parent role routing; Create ParentSettingsView; Integrate AiProviderSettingsCard; Add Tab URL persistence; Add logout confirm; Add avatar; Fix Progress arbitrary class

Schema: Add senderDeletedAt/receiverDeletedAt to messages; Add quietHours to notificationPreferences; Add uniqueIndex import

Docs: Update architecture docs 004/005
This commit is contained in:
SpecialX
2026-06-22 13:57:31 +08:00
parent 5ff7ab9e72
commit a4d096a6fc
81 changed files with 2145 additions and 124 deletions

View File

@@ -6,6 +6,13 @@ 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 { sendBatchNotifications } from "@/modules/notifications"
import type { NotificationPayload } from "@/modules/notifications"
import { getAllUserIds, getUserIdsByGradeId } from "@/modules/users/data-access"
import {
getStudentIdsByClassId,
getTeacherIdsByClassIds,
} from "@/modules/classes/data-access"
import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema"
import {
@@ -27,6 +34,60 @@ function handleActionError(e: unknown): ActionState<never> {
return { success: false, message: "Unexpected error" }
}
/**
* 根据公告类型解析目标用户 ID 列表。
* - school: 全校所有用户
* - grade: 该年级下所有用户
* - class: 该班级学生 + 任课教师 + 班主任
*/
async function resolveTargetUserIds(announcement: Announcement): Promise<string[]> {
if (announcement.type === "school") {
return getAllUserIds()
}
if (announcement.type === "grade" && announcement.targetGradeId) {
return getUserIdsByGradeId(announcement.targetGradeId)
}
if (announcement.type === "class" && announcement.targetClassId) {
const [studentIds, teacherIds] = await Promise.all([
getStudentIdsByClassId(announcement.targetClassId),
getTeacherIdsByClassIds([announcement.targetClassId]),
])
return Array.from(new Set([...studentIds, ...teacherIds]))
}
return []
}
/**
* 发布公告后向目标用户批量发送通知。
* 通知发送失败不影响公告发布本身,仅记录日志。
*/
async function notifyAnnouncementPublished(announcement: Announcement): Promise<void> {
try {
const targetUserIds = await resolveTargetUserIds(announcement)
if (targetUserIds.length === 0) return
const payloads: NotificationPayload[] = targetUserIds.map((userId) => ({
userId,
title: `新公告:${announcement.title}`,
content: announcement.content.slice(0, 200),
type: "info",
actionUrl: `/announcements/${announcement.id}`,
metadata: {
announcementId: announcement.id,
announcementType: announcement.type,
},
}))
await sendBatchNotifications(payloads)
} catch (error) {
// 通知发送失败不阻塞公告发布流程,仅记录错误
console.error("Failed to send announcement notifications:", error)
}
}
export async function createAnnouncementAction(
prevState: ActionState<string> | null,
formData: FormData
@@ -74,6 +135,14 @@ export async function createAnnouncementAction(
publishedAt,
})
// 如果创建时直接发布,触发通知(失败不阻塞)
if (isPublished) {
const created = await getAnnouncementById(id)
if (created) {
await notifyAnnouncementPublished(created)
}
}
revalidatePath("/admin/announcements")
revalidatePath("/announcements")
@@ -114,6 +183,7 @@ export async function updateAnnouncementAction(
const input = parsed.data
const isPublished = input.status === "published"
const wasPublished = existing.status === "published"
const publishedAt = isPublished
? existing.publishedAt
? new Date(existing.publishedAt)
@@ -133,6 +203,14 @@ export async function updateAnnouncementAction(
updatedAt: new Date(),
})
// 当公告从非发布状态变为发布状态时,触发通知(失败不阻塞)
if (isPublished && !wasPublished) {
const updated = await getAnnouncementById(id)
if (updated) {
await notifyAnnouncementPublished(updated)
}
}
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")
@@ -173,6 +251,9 @@ export async function publishAnnouncementAction(id: string): Promise<ActionState
: new Date()
await publishAnnouncementById(id, publishedAt)
// 发布成功后触发通知(失败不阻塞)
await notifyAnnouncementPublished(existing)
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")

View File

@@ -1,7 +1,7 @@
import "server-only"
import { cache } from "react"
import { and, desc, eq } from "drizzle-orm"
import { and, desc, eq, or } from "drizzle-orm"
import { db } from "@/shared/db"
import { announcements, users } from "@/shared/db/schema"
@@ -61,6 +61,25 @@ export const getAnnouncements = cache(
conditions.push(eq(announcements.type, params.type))
}
// 受众过滤:当提供 audience 时,仅返回对该受众可见的公告
// (type = 'school') OR (type = 'grade' AND target_grade_id = audience.gradeId)
// OR (type = 'class' AND target_class_id = audience.classId)
if (params?.audience) {
const { gradeId, classId } = params.audience
const gradeClause = gradeId
? and(eq(announcements.type, "grade"), eq(announcements.targetGradeId, gradeId))
: undefined
const classClause = classId
? and(eq(announcements.type, "class"), eq(announcements.targetClassId, classId))
: undefined
const orClauses = [
eq(announcements.type, "school"),
gradeClause,
classClause,
].filter((c): c is NonNullable<typeof c> => c !== undefined)
conditions.push(or(...orClauses))
}
const rows = await db
.select({
id: announcements.id,

View File

@@ -24,6 +24,17 @@ export type GetAnnouncementsParams = {
type?: AnnouncementType
page?: number
pageSize?: number
/**
* 受众过滤(用户端使用):当提供时,仅返回对该受众可见的公告。
* - school 类型公告:对所有受众可见
* - grade 类型公告:仅当 targetGradeId 与 audience.gradeId 匹配时可见
* - class 类型公告:仅当 targetClassId 与 audience.classId 匹配时可见
* 未提供时(管理端)返回所有公告。
*/
audience?: {
gradeId?: string
classId?: string
}
}
export interface AnnouncementInsertData {