376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
"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 Action(2 分钟窗口内仅发送方可撤回)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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" }
|
||
}
|
||
}
|