Files
NextEdu/src/modules/notifications/actions.ts
SpecialX 1fe30984b6 refactor(announcements,messaging,notifications): V1+V2 审计重构 — i18n 命名空间独立 + 通知标题 i18n 化 + 服务端过滤 + 编排下沉 + 表单错误展示 + 架构图同步
V1 改进(已完成):
- P0-4/P1-4/P1-5: 通知组件和 CRUD Action 从 messaging 迁移至 notifications 模块
- P1-5: 新增 getMessagesPageData / getAdminAnnouncementsPageData 编排函数
- P1-6: announcements schema 添加 superRefine 条件校验
- P1-7: 新增 useMessageSearch hook(防抖 + 请求竞态取消)+ 客户端分页 UI
- P1-9: deleteMessage 事务化
- P2-11: 全模块 trackEvent 埋点
- 全模块 i18n 接入 + Error Boundary + a11y 改进

V2 改进(本次完成):
- V2-P0-1: 通知 i18n 命名空间独立(notifications.json),useTranslations 从 "messages" 切换到 "notifications"
- V2-P0-2: 公告/消息通知标题 i18n 化,Server Action 中使用 getTranslations 生成通知标题
- V2-P1-1: AnnouncementList 纯服务端过滤,移除客户端 useState/useMemo
- V2-P1-2: MessageList 客户端过滤仅在初始数据时执行,搜索结果由服务端按 tab 过滤
- V2-P1-3: 消息详情页编排下沉,新增 getMessageDetailPageData 编排函数
- V2-P1-4: 表单服务端校验错误展示(fieldErrors + aria-invalid)
- V2-P2-1: 轮询间隔常量化(POLL_INTERVAL_MS)
- V2-P2-2: 架构图同步(004 + 005)
2026-06-22 18:43:12 +08:00

268 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use server"
/**
* 通知 Server Actions
*
* - sendNotificationAction: 发送通知给指定用户(需要 MESSAGE_SEND 权限)
* - sendClassNotificationAction: 发送班级通知(教师权限,按班级查询学生后批量发送)
* - getNotificationsAction / markNotificationAsReadAction / markAllNotificationsAsReadAction / getUnreadNotificationCountAction: 站内通知 CRUD
*
* 权限说明:
* 项目无独立 NOTIFICATION_SEND 权限点,复用 MESSAGE_SEND教师/管理员/年级主任均拥有)。
* 班级通知按教师所教班级过滤,确保教师只能给自己班级发通知。
* 站内通知读取/标记已读复用 MESSAGE_READ 权限(任何能读消息的用户都能查看通知)。
*/
import { revalidatePath } from "next/cache"
import { z } from "zod"
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
import { trackEvent } from "@/shared/lib/track-event"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { getClassExists, getStudentIdsByClassId } from "@/modules/classes/data-access"
import { sendNotification, sendBatchNotifications } from "./dispatcher"
import {
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
getUnreadNotificationCount,
} from "./data-access"
import type { NotificationPayload, ChannelSendResult, Notification } from "./types"
/**
* Zod 校验通知负载sendNotificationAction 入参)
* 校验 userId / title / content 必填type 限定为枚举值
*/
const SendNotificationSchema = z.object({
userId: z.string().trim().min(1),
title: z.string().trim().min(1).max(255),
content: z.string().trim().min(1),
type: z.enum(["info", "warning", "error", "success"]),
metadata: z.record(z.string(), z.unknown()).optional(),
actionUrl: z.string().trim().max(500).optional(),
})
/**
* Zod 校验班级通知负载sendClassNotificationAction 入参)
* 与 SendNotificationSchema 类似,但不含 userId由班级学生列表填充
*/
const SendClassNotificationSchema = z.object({
title: z.string().trim().min(1).max(255),
content: z.string().trim().min(1),
type: z.enum(["info", "warning", "error", "success"]),
metadata: z.record(z.string(), z.unknown()).optional(),
actionUrl: z.string().trim().max(500).optional(),
})
/** Zod 校验classId 路径参数 */
const ClassIdSchema = z.string().trim().min(1)
/**
* 发送通知给指定用户。
*
* @param payload 通知负载payload.userId 为接收人)
*/
export async function sendNotificationAction(
payload: NotificationPayload
): Promise<ActionState<ChannelSendResult[]>> {
try {
await requirePermission(Permissions.MESSAGE_SEND)
const parsed = SendNotificationSchema.safeParse(payload)
if (!parsed.success) {
return {
success: false,
message: "Invalid payload",
errors: parsed.error.flatten().fieldErrors,
}
}
const results = await sendNotification(parsed.data)
const allSuccess = results.every((r) => r.success)
return {
success: allSuccess,
message: allSuccess ? "Notification sent" : "Some channels failed",
data: results,
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/**
* 发送班级通知(批量发送给班级所有学生)。
*
* 教师只能给自己所教班级发送通知(通过 dataScope 校验)。
*
* @param classId 班级 ID
* @param payload 通知负载模板payload.userId 会被覆盖为每个学生的 userId
*/
export async function sendClassNotificationAction(
classId: string,
payload: Omit<NotificationPayload, "userId">
): Promise<ActionState<ChannelSendResult[][]>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const parsedClassId = ClassIdSchema.safeParse(classId)
const parsedPayload = SendClassNotificationSchema.safeParse(payload)
if (!parsedClassId.success || !parsedPayload.success) {
const errors: Record<string, string[]> = {}
if (!parsedClassId.success) {
errors.classId = parsedClassId.error.flatten().formErrors
}
if (!parsedPayload.success) {
Object.assign(errors, parsedPayload.error.flatten().fieldErrors)
}
return {
success: false,
message: "Invalid input",
errors,
}
}
const validClassId = parsedClassId.data
// 权限校验: 教师只能给自己所教班级发通知;管理员可发任意班级
if (ctx.dataScope.type !== "all") {
const allowedClassIds =
ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : []
if (!allowedClassIds.includes(validClassId)) {
return { success: false, message: "You can only send notifications to your own classes" }
}
}
// 校验班级是否存在
const classExists = await getClassExists(validClassId)
if (!classExists) {
return { success: false, message: "Class not found" }
}
// 查询班级所有学生
const studentIds = await getStudentIdsByClassId(validClassId)
if (studentIds.length === 0) {
return { success: true, message: "No students in this class", data: [] }
}
// 构造每个学生的通知负载
const payloads: NotificationPayload[] = studentIds.map((studentId) => ({
...parsedPayload.data,
userId: studentId,
}))
const results = await sendBatchNotifications(payloads)
return {
success: true,
message: `Notification sent to ${studentIds.length} students`,
data: results,
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
// ---------------------------------------------------------------------------
// 站内通知 CRUD Server Actions
//
// 这些 Action 从 messaging/actions.ts 迁移而来,使 notifications 模块成为
// 通知相关 UI 组件的唯一数据来源,消除 messaging 与 notifications 在 UI 层
// 的双向耦合。权限复用 MESSAGE_READ任何能读消息的用户都能查看通知
// ---------------------------------------------------------------------------
/** Zod 校验:通知 ID 路径参数 */
const NotificationIdSchema = z.string().trim().min(1)
/**
* 获取当前用户的通知列表(分页)。
*/
export async function getNotificationsAction(
params?: { page?: number; pageSize?: number; unreadOnly?: boolean }
): Promise<ActionState<{ items: Notification[]; total: number; page: number; pageSize: number; totalPages: number }>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const result = await getNotifications(ctx.userId, params)
return { success: true, data: result }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/**
* 获取当前用户的未读通知计数。
*/
export async function getUnreadNotificationCountAction(): Promise<ActionState<number>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const count = await getUnreadNotificationCount(ctx.userId)
return { success: true, data: count }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/**
* 将单条通知标记为已读。
*/
export async function markNotificationAsReadAction(
notificationId: string
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const parsed = NotificationIdSchema.safeParse(notificationId)
if (!parsed.success) {
return { success: false, message: "Invalid notification id" }
}
await markNotificationAsRead(parsed.data, ctx.userId)
revalidatePath("/messages")
void trackEvent({
event: "notification.marked_read",
userId: ctx.userId,
targetId: parsed.data,
targetType: "notification",
})
return { success: true, message: "Notification marked as read" }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/**
* 将当前用户的所有未读通知标记为已读。
*/
export async function markAllNotificationsAsReadAction(): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
await markAllNotificationsAsRead(ctx.userId)
revalidatePath("/messages")
void trackEvent({
event: "notification.marked_all_read",
userId: ctx.userId,
targetType: "notification",
})
return { success: true, message: "All notifications marked as read" }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}