Files
NextEdu/src/modules/messaging/actions.ts
SpecialX a4d096a6fc 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
2026-06-22 13:57:31 +08:00

313 lines
12 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"
import { revalidatePath } from "next/cache"
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { sendNotification } from "@/modules/notifications/dispatcher"
import {
SendMessageSchema,
MessageIdSchema,
UpdateNotificationPreferencesSchema,
} from "./schema"
import {
getMessages,
getMessageById,
createMessage,
markMessageAsRead,
deleteMessage,
getRecipients,
getUnreadMessageCount,
} from "./data-access"
import {
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
getUnreadNotificationCount,
} from "@/modules/notifications/data-access"
import {
getNotificationPreferences,
upsertNotificationPreferences,
} from "@/modules/notifications/preferences"
import type { Message, MessageType, RecipientOption } from "./types"
import type {
Notification,
NotificationPreferences,
UpdateNotificationPreferencesInput,
} from "@/modules/notifications/types"
export async function sendMessageAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const parsed = SendMessageSchema.safeParse({
receiverId: formData.get("receiverId"),
subject: formData.get("subject") || undefined,
content: formData.get("content"),
parentMessageId: formData.get("parentMessageId") || undefined,
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input = parsed.data
if (input.receiverId === ctx.userId) {
return { success: false, message: "Cannot send a message to yourself" }
}
const id = await createMessage({
senderId: ctx.userId,
receiverId: input.receiverId,
subject: input.subject,
content: input.content,
parentMessageId: input.parentMessageId,
})
// Notify the receiver about the new message via the notifications dispatcher.
// This respects user notification preferences (SMS/WeChat/Email/In-App).
await sendNotification({
userId: input.receiverId,
type: "info",
title: input.subject ? `New message: ${input.subject}` : "New message",
content: input.content.slice(0, 200),
actionUrl: `/messages/${id}`,
metadata: { messageType: "message", messageId: id },
})
revalidatePath("/messages")
revalidatePath(`/messages/${id}`)
return { success: true, message: "Message sent", data: id }
} 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 markMessageAsReadAction(messageId: string): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const parsed = MessageIdSchema.safeParse({ messageId })
if (!parsed.success) {
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
}
await markMessageAsRead(parsed.data.messageId, ctx.userId)
revalidatePath("/messages")
revalidatePath(`/messages/${parsed.data.messageId}`)
return { success: true, message: "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 deleteMessageAction(messageId: string): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_DELETE)
const parsed = MessageIdSchema.safeParse({ messageId })
if (!parsed.success) {
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
}
await deleteMessage(parsed.data.messageId, ctx.userId)
revalidatePath("/messages")
revalidatePath(`/messages/${parsed.data.messageId}`)
return { success: true, message: "Message deleted" }
} 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 getMessagesAction(
params: { type: MessageType; page?: number; pageSize?: number; keyword?: string }
): Promise<ActionState<{ items: Message[]; total: number; page: number; pageSize: number; totalPages: number }>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const result = await getMessages({ userId: 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 getMessageDetailAction(messageId: string): Promise<ActionState<Message>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const parsed = MessageIdSchema.safeParse({ messageId })
if (!parsed.success) {
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
}
const validMessageId = parsed.data.messageId
const message = await getMessageById(validMessageId, ctx.userId)
if (!message) return { success: false, message: "Message not found" }
// Auto-mark as read when viewed by receiver
if (!message.isRead && message.receiverId === ctx.userId) {
await markMessageAsRead(validMessageId, ctx.userId)
revalidatePath("/messages")
}
return { success: true, data: message }
} 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 getRecipientsAction(): Promise<ActionState<RecipientOption[]>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const recipients = await getRecipients(ctx.userId, ctx.dataScope)
return { success: true, data: recipients }
} 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 getUnreadMessageCountAction(): Promise<ActionState<number>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const count = await getUnreadMessageCount(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 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 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 markNotificationAsReadAction(
notificationId: string
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
await markNotificationAsRead(notificationId, ctx.userId)
revalidatePath("/messages")
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")
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" }
}
}
export async function getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const prefs = await getNotificationPreferences(ctx.userId)
return { success: true, data: prefs }
} 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 updateNotificationPreferencesAction(
prevState: ActionState<NotificationPreferences> | null,
formData: FormData
): Promise<ActionState<NotificationPreferences>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
// 从 FormData 中解析布尔值checkbox 提交 "on" 或不提交)
const parseBool = (key: string): boolean => formData.get(key) === "on"
// 从 FormData 中解析时间字符串("HH:mm"),空字符串转为 null
const parseTime = (key: string): string | null => {
const v = formData.get(key)
if (typeof v !== "string") return null
const trimmed = v.trim()
return trimmed.length > 0 ? trimmed : null
}
const parsed = UpdateNotificationPreferencesSchema.safeParse({
emailEnabled: parseBool("emailEnabled"),
smsEnabled: parseBool("smsEnabled"),
pushEnabled: parseBool("pushEnabled"),
homeworkNotifications: parseBool("homeworkNotifications"),
gradeNotifications: parseBool("gradeNotifications"),
announcementNotifications: parseBool("announcementNotifications"),
messageNotifications: parseBool("messageNotifications"),
attendanceNotifications: parseBool("attendanceNotifications"),
quietHoursEnabled: parseBool("quietHoursEnabled"),
quietHoursStart: parseTime("quietHoursStart"),
quietHoursEnd: parseTime("quietHoursEnd"),
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input: UpdateNotificationPreferencesInput = parsed.data
const updated = await upsertNotificationPreferences(ctx.userId, input)
if (!updated) {
return { success: false, message: "Failed to update notification preferences" }
}
revalidatePath("/settings")
return { success: true, message: "Notification preferences updated", data: updated }
} 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" }
}
}