Files
NextEdu/src/modules/messaging/actions-core.ts

376 lines
14 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 { invalidateFor } from "@/shared/lib/cache"
import { getTranslations } from "next-intl/server"
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
import { trackEvent } from "@/shared/lib/track-event"
import { Permissions } from "@/shared/types/permissions"
import type { AuthContext } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { sendNotification } from "@/modules/notifications/dispatcher"
import {
SendMessageSchema,
MessageIdSchema,
} from "./schema"
import {
getMessages,
getMessageById,
getMessageThread,
createMessage,
markMessageAsRead,
deleteMessage,
getRecipients,
getUnreadMessageCount,
toggleMessageStar,
isReceiverAllowed,
recallMessage,
isEitherUserBlocked,
getMessageAttachments,
} from "./data-access"
import type { Message, MessageType, RecipientOption, MessageAttachment } from "./types"
export async function sendMessageAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
let ctx: AuthContext | null = null
let parsedInput: {
receiverId: string
subject: string | null
content: string
parentMessageId: string | null
} | null = null
try {
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
parsedInput = input
if (input.receiverId === ctx.userId) {
return { success: false, message: "Cannot send a message to yourself" }
}
// P0-1: Server Action 二次校验收件人是否在 sender 的 DataScope 允许范围内
// 防止绕过前端 Select 直接构造 FormData 提交任意 receiverId 越权发送
const allowed = await isReceiverAllowed(ctx.userId, input.receiverId, ctx.dataScope)
if (!allowed) {
return { success: false, message: "Recipient not allowed" }
}
// P2-5: 屏蔽校验 —— 发送方或接收方任一屏蔽对方,则禁止发送
const blocked = await isEitherUserBlocked(ctx.userId, input.receiverId)
if (blocked) {
return { success: false, message: "Cannot send message due to block" }
}
// P0-6: 校验 parentMessageId 属于当前用户参与的会话
// 防止将回复消息注入到他人会话线程
if (input.parentMessageId) {
const parent = await getMessageById(input.parentMessageId, ctx.userId)
if (!parent) {
return { success: false, message: "Parent message not accessible" }
}
}
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).
const t = await getTranslations("messages")
const notifyTitle = input.subject
? t("notification.messageTitle", { subject: input.subject })
: t("notification.messageTitleNoSubject")
await sendNotification({
userId: input.receiverId,
type: "info",
title: notifyTitle,
content: input.content.slice(0, 200),
actionUrl: `/messages/${id}`,
metadata: { messageType: "message", messageId: id },
})
await invalidateFor("messaging.send", { id })
void trackEvent({
event: "message.sent",
userId: ctx.userId,
targetId: id,
targetType: "message",
properties: { receiverId: input.receiverId, hasSubject: !!input.subject },
})
return { success: true, message: "Message sent", data: id }
} catch (e) {
// P1-9: 失败埋点(权限拒绝除外,避免噪音)
if (ctx && parsedInput && !(e instanceof PermissionDeniedError)) {
void trackEvent({
event: "message.send_failed",
userId: ctx.userId,
targetType: "message",
properties: {
receiverId: parsedInput.receiverId,
reason: e instanceof Error ? e.message : "unknown",
},
})
}
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)
await invalidateFor("messaging.markRead", { id: parsed.data.messageId })
void trackEvent({
event: "message.marked_read",
userId: ctx.userId,
targetId: parsed.data.messageId,
targetType: "message",
})
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)
await invalidateFor("messaging.delete", { id: parsed.data.messageId })
void trackEvent({
event: "message.deleted",
userId: ctx.userId,
targetId: parsed.data.messageId,
targetType: "message",
})
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
starredOnly?: boolean
/** P2-11: 按发件人筛选 */
senderId?: string
/** P2-11: 按日期范围筛选 */
dateFrom?: string
dateTo?: string
/** P2-11: 按已读状态筛选 */
isRead?: boolean
}
): 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" }
}
}
/**
* P1-3: 获取消息线程(根消息 + 回复链)。
*
* 替代直接调用 data-access.getMessageThread统一通过 Server Action 入口
* 做权限校验。返回的 Message[] 已按时间倒序(与 data-access 一致),
* UI 层可按需 reverse 为正序展示。
*
* 注:原 getMessageDetailAction 已删除P0-4 死代码,详情数据由
* getMessageDetailPageData 编排函数 + RSC 直接获取,组件通过 props 接收)。
*/
export async function getMessageThreadAction(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 }
}
// data-access.getMessageThread 内部会校验当前用户对根消息的访问权
const thread = await getMessageThread(parsed.data.messageId, ctx.userId)
return { success: true, data: thread }
} 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" }
}
}
// ---------------------------------------------------------------------------
// V2-P2-13c: 消息星标 Server Actions
// ---------------------------------------------------------------------------
export async function toggleMessageStarAction(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 toggleMessageStar(parsed.data.messageId, ctx.userId)
await invalidateFor("messaging.toggleStar", { id: parsed.data.messageId })
void trackEvent({
event: "message.star_toggled",
userId: ctx.userId,
targetId: parsed.data.messageId,
targetType: "message",
})
return { success: true, message: "Star toggled" }
} 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" }
}
}
// ---------------------------------------------------------------------------
// P2-4: 消息撤回 Server Action2 分钟窗口内仅发送方可撤回)
// ---------------------------------------------------------------------------
export async function recallMessageAction(
messageId: string
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const parsed = MessageIdSchema.safeParse({ messageId })
if (!parsed.success) {
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
}
const result = await recallMessage(parsed.data.messageId, ctx.userId)
if (result === "not_found") {
return { success: false, message: "Message not found or not owned" }
}
if (result === "already_recalled") {
return { success: false, message: "Message already recalled" }
}
if (result === "expired") {
return { success: false, message: "Recall window expired" }
}
await invalidateFor("messaging.recall", { id: parsed.data.messageId })
void trackEvent({
event: "message.recalled",
userId: ctx.userId,
targetId: parsed.data.messageId,
targetType: "message",
})
return { success: true, message: "Message recalled" }
} 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" }
}
}
// ---------------------------------------------------------------------------
// P2-3: 消息附件 Server Actions
// ---------------------------------------------------------------------------
/**
* P2-3: 获取指定消息的附件列表。
*
* 校验当前用户对消息有访问权(必须是发送方或接收方),防止越权访问附件。
* 附件存储复用 files 模块的 fileAttachments 表targetType="message")。
*/
export async function getMessageAttachmentsAction(
messageId: string
): Promise<ActionState<MessageAttachment[]>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
// 校验访问权:必须是消息的发送方或接收方
const msg = await getMessageById(messageId, ctx.userId)
if (!msg) {
return { success: false, message: "Message not found" }
}
const attachments = await getMessageAttachments(messageId)
return { success: true, data: attachments }
} 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" }
}
}