Files
NextEdu/src/modules/announcements/actions.ts
SpecialX 2b95fd668b feat(announcements): add tests, skeleton, pagination, and service context
- Add announcement-card test for component testing

- Add is-announcement-visible test and schema test for logic testing

- Add announcement-list-skeleton for loading states

- Add announcement-pagination for list pagination

- Add announcements-service-context and default-announcements-service for service layer
2026-07-03 10:24:50 +08:00

427 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 { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { getTranslations } from "next-intl/server"
import { requirePermission, PermissionDeniedError } 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 { sendBatchNotifications } from "@/modules/notifications"
import type { NotificationPayload } from "@/modules/notifications"
import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema"
import {
getAnnouncements,
getAnnouncementById,
insertAnnouncement,
updateAnnouncementById,
deleteAnnouncementById,
publishAnnouncementById,
archiveAnnouncementById,
toggleAnnouncementPin,
markAnnouncementAsRead,
getAnnouncementReadStatusForUser,
getAnnouncementByIdForUser,
resolveAnnouncementTargetUserIds,
} from "./data-access"
import type { GetAnnouncementsParams, Announcement } from "./types"
/**
* P1-7: 统一错误处理。
* - 记录错误堆栈到 console.error 便于生产排查
* - 上报 trackEvent 用于监控告警
* - 返回 i18n 化的 message不向客户端泄露内部错误信息
*/
async function handleActionError(
e: unknown,
actionName: string,
t: (key: string) => string
): Promise<ActionState<never>> {
console.error(`[announcements] ${actionName} failed:`, e)
void trackEvent({
event: "announcement.action_error",
targetType: "announcement",
properties: {
action: actionName,
errorName: e instanceof Error ? e.constructor.name : "unknown",
errorMessage: e instanceof Error ? e.message : String(e),
},
})
if (e instanceof PermissionDeniedError) {
return { success: false, message: t("messages.permissionDenied") }
}
if (e instanceof Error && e.message === "FORBIDDEN_ANNOUNCEMENT") {
return { success: false, message: t("messages.forbidden") }
}
return { success: false, message: t("messages.unexpectedError") }
}
/**
* 发布公告后向目标用户批量发送通知。
* 通知发送失败不影响公告发布本身,仅记录日志。
* P1-5: resolveTargetUserIds 已下沉到 data-access.resolveAnnouncementTargetUserIds。
*/
async function notifyAnnouncementPublished(announcement: Announcement): Promise<void> {
try {
const targetUserIds = await resolveAnnouncementTargetUserIds(announcement)
if (targetUserIds.length === 0) return
const t = await getTranslations("announcements")
const title = t("notification.publishedTitle", { title: announcement.title })
const content = t("notification.publishedContent")
const payloads: NotificationPayload[] = targetUserIds.map((userId) => ({
userId,
title,
content,
type: "info",
actionUrl: `/announcements/${announcement.id}`,
metadata: {
announcementId: announcement.id,
announcementType: announcement.type,
},
}))
await sendBatchNotifications(payloads)
} catch (error) {
// 通知发送失败不阻塞公告发布流程,仅记录错误
console.error("Failed to send announcement notifications:", error)
}
}
export async function createAnnouncementAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try {
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const parsed = CreateAnnouncementSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
type: formData.get("type") || undefined,
status: formData.get("status") || undefined,
targetGradeId: formData.get("targetGradeId") || undefined,
targetClassId: formData.get("targetClassId") || undefined,
publishedAt: formData.get("publishedAt") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: t("messages.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
const input = parsed.data
const isPublished = input.status === "published"
const publishedAt = isPublished
? input.publishedAt
? new Date(input.publishedAt)
: new Date()
: input.publishedAt
? new Date(input.publishedAt)
: null
const id = await insertAnnouncement({
id: createId(),
title: input.title,
content: input.content,
type: input.type,
status: input.status,
targetGradeId: input.targetGradeId,
targetClassId: input.targetClassId,
authorId: ctx.userId,
publishedAt,
})
// 如果创建时直接发布,触发通知(失败不阻塞)
if (isPublished) {
const created = await getAnnouncementById(id)
if (created) {
await notifyAnnouncementPublished(created)
}
}
revalidatePath("/admin/announcements")
revalidatePath("/announcements")
void trackEvent({
event: isPublished ? "announcement.published" : "announcement.created",
userId: ctx.userId,
targetId: id,
targetType: "announcement",
properties: { type: input.type, status: input.status },
})
return { success: true, message: isPublished ? t("messages.published") : t("messages.created"), data: id }
} catch (e) {
return handleActionError(e, "createAnnouncementAction", t)
}
}
export async function updateAnnouncementAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: t("messages.notFound") }
const parsed = UpdateAnnouncementSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
type: formData.get("type") || undefined,
status: formData.get("status") || undefined,
targetGradeId: formData.get("targetGradeId") || undefined,
targetClassId: formData.get("targetClassId") || undefined,
publishedAt: formData.get("publishedAt") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: t("messages.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
const input = parsed.data
const isPublished = input.status === "published"
const wasPublished = existing.status === "published"
const publishedAt = isPublished
? existing.publishedAt
? new Date(existing.publishedAt)
: new Date()
: input.publishedAt
? new Date(input.publishedAt)
: null
await updateAnnouncementById(id, {
title: input.title,
content: input.content,
type: input.type,
status: input.status,
targetGradeId: input.targetGradeId,
targetClassId: input.targetClassId,
publishedAt,
updatedAt: new Date(),
})
// 当公告从非发布状态变为发布状态时,触发通知(失败不阻塞)
if (isPublished && !wasPublished) {
const updated = await getAnnouncementById(id)
if (updated) {
await notifyAnnouncementPublished(updated)
}
}
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")
void trackEvent({
event: isPublished && !wasPublished ? "announcement.published" : "announcement.updated",
targetId: id,
targetType: "announcement",
properties: { type: input.type, status: input.status, wasPublished },
})
return {
success: true,
message: isPublished && !wasPublished ? t("messages.published") : t("messages.updated"),
data: id,
}
} catch (e) {
return handleActionError(e, "updateAnnouncementAction", t)
}
}
export async function deleteAnnouncementAction(id: string): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: t("messages.notFound") }
await deleteAnnouncementById(id)
revalidatePath("/admin/announcements")
revalidatePath("/announcements")
void trackEvent({
event: "announcement.deleted",
targetId: id,
targetType: "announcement",
properties: { type: existing.type, status: existing.status },
})
return { success: true, message: t("messages.deleted") }
} catch (e) {
return handleActionError(e, "deleteAnnouncementAction", t)
}
}
export async function publishAnnouncementAction(id: string): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: t("messages.notFound") }
const publishedAt = existing.publishedAt
? new Date(existing.publishedAt)
: new Date()
await publishAnnouncementById(id, publishedAt)
// 发布成功后触发通知(失败不阻塞)
await notifyAnnouncementPublished(existing)
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")
void trackEvent({
event: "announcement.published",
targetId: id,
targetType: "announcement",
properties: { type: existing.type },
})
return { success: true, message: t("messages.published") }
} catch (e) {
return handleActionError(e, "publishAnnouncementAction", t)
}
}
export async function archiveAnnouncementAction(id: string): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: t("messages.notFound") }
await archiveAnnouncementById(id)
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")
void trackEvent({
event: "announcement.archived",
targetId: id,
targetType: "announcement",
properties: { type: existing.type },
})
return { success: true, message: t("messages.archived") }
} catch (e) {
return handleActionError(e, "archiveAnnouncementAction", t)
}
}
// ---------------------------------------------------------------------------
// V2-P2-13d: 公告置顶 Server Action
// P0-3: 新增资源存在性二次校验
// ---------------------------------------------------------------------------
export async function toggleAnnouncementPinAction(id: string): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
// P0-3: 资源存在性校验
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: t("messages.notFound") }
await toggleAnnouncementPin(id)
revalidatePath("/admin/announcements")
revalidatePath("/announcements")
void trackEvent({
event: "announcement.pin_toggled",
targetId: id,
targetType: "announcement",
})
return { success: true, message: t("messages.pinToggled") }
} catch (e) {
return handleActionError(e, "toggleAnnouncementPinAction", t)
}
}
// ---------------------------------------------------------------------------
// V2-P2-13d: 公告已读标记 Server Action
// P0-3: 新增资源可见性二次校验(防止对草稿/他人班级公告写入已读记录)
// ---------------------------------------------------------------------------
export async function markAnnouncementAsReadAction(announcementId: string): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try {
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
// P0-3: 资源可见性二次校验——必须为已发布且对当前用户受众可见
const visible = await getAnnouncementByIdForUser(announcementId, ctx.userId, ctx.dataScope)
if (!visible) {
// 不可见时抛出特定错误,由 handleActionError 转为 i18n 文案
throw new Error("FORBIDDEN_ANNOUNCEMENT")
}
await markAnnouncementAsRead(announcementId, ctx.userId)
void trackEvent({
event: "announcement.marked_read",
userId: ctx.userId,
targetId: announcementId,
targetType: "announcement",
})
return { success: true, message: t("messages.markedRead") }
} catch (e) {
return handleActionError(e, "markAnnouncementAsReadAction", t)
}
}
/**
* 批量获取公告已读状态(用于列表页标记已读/未读)。
* P1-2: 列表组件消费此 Action 展示已读/未读视觉区分。
*/
export async function getAnnouncementReadStatusAction(
announcementIds: string[]
): Promise<ActionState<Record<string, boolean>>> {
const t = await getTranslations("announcements")
try {
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
const statusMap = await getAnnouncementReadStatusForUser(announcementIds, ctx.userId)
return { success: true, data: Object.fromEntries(statusMap) }
} catch (e) {
return handleActionError(e, "getAnnouncementReadStatusAction", t)
}
}
export async function getAnnouncementsAction(
params?: GetAnnouncementsParams
): Promise<ActionState<Announcement[]>> {
const t = await getTranslations("announcements")
try {
await requirePermission(Permissions.ANNOUNCEMENT_READ)
const data = await getAnnouncements(params)
return { success: true, data }
} catch (e) {
return handleActionError(e, "getAnnouncementsAction", t)
}
}