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