"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> { 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 ): Promise> { 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 = {} 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> { 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> { 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> { 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> { 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" } } }