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")