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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user