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
This commit is contained in:
SpecialX
2026-07-03 10:24:50 +08:00
parent 2859ef74f2
commit 2b95fd668b
15 changed files with 1539 additions and 206 deletions

View File

@@ -10,11 +10,6 @@ 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 { getAllUserIds, getUserIdsByGradeId } from "@/modules/users/data-access"
import {
getStudentIdsByClassId,
getTeacherIdsByClassIds,
} from "@/modules/classes/data-access"
import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema"
import {
@@ -28,50 +23,51 @@ import {
toggleAnnouncementPin,
markAnnouncementAsRead,
getAnnouncementReadStatusForUser,
getAnnouncementByIdForUser,
resolveAnnouncementTargetUserIds,
} from "./data-access"
import type { GetAnnouncementsParams, Announcement } from "./types"
function handleActionError(e: unknown): ActionState<never> {
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" }
}
/**
* 根据公告类型解析目标用户 ID 列表
* - school: 全校所有用户
* - grade: 该年级下所有用户
* - class: 该班级学生 + 任课教师 + 班主任
* P1-7: 统一错误处理
* - 记录错误堆栈到 console.error 便于生产排查
* - 上报 trackEvent 用于监控告警
* - 返回 i18n 化的 message不向客户端泄露内部错误信息
*/
async function resolveTargetUserIds(announcement: Announcement): Promise<string[]> {
if (announcement.type === "school") {
return getAllUserIds()
}
async function handleActionError(
e: unknown,
actionName: string,
t: (key: string) => string
): Promise<ActionState<never>> {
console.error(`[announcements] ${actionName} failed:`, e)
if (announcement.type === "grade" && announcement.targetGradeId) {
return getUserIdsByGradeId(announcement.targetGradeId)
}
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 (announcement.type === "class" && announcement.targetClassId) {
const [studentIds, teacherIds] = await Promise.all([
getStudentIdsByClassId(announcement.targetClassId),
getTeacherIdsByClassIds([announcement.targetClassId]),
])
return Array.from(new Set([...studentIds, ...teacherIds]))
if (e instanceof PermissionDeniedError) {
return { success: false, message: t("messages.permissionDenied") }
}
return []
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 resolveTargetUserIds(announcement)
const targetUserIds = await resolveAnnouncementTargetUserIds(announcement)
if (targetUserIds.length === 0) return
const t = await getTranslations("announcements")
@@ -101,6 +97,7 @@ 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)
@@ -117,7 +114,7 @@ export async function createAnnouncementAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
message: t("messages.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -163,9 +160,9 @@ export async function createAnnouncementAction(
properties: { type: input.type, status: input.status },
})
return { success: true, message: "Announcement created", data: id }
return { success: true, message: isPublished ? t("messages.published") : t("messages.created"), data: id }
} catch (e) {
return handleActionError(e)
return handleActionError(e, "createAnnouncementAction", t)
}
}
@@ -174,11 +171,12 @@ export async function updateAnnouncementAction(
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: "Announcement not found" }
if (!existing) return { success: false, message: t("messages.notFound") }
const parsed = UpdateAnnouncementSchema.safeParse({
title: formData.get("title"),
@@ -193,7 +191,7 @@ export async function updateAnnouncementAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
message: t("messages.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -239,18 +237,23 @@ export async function updateAnnouncementAction(
properties: { type: input.type, status: input.status, wasPublished },
})
return { success: true, message: "Announcement updated", data: id }
return {
success: true,
message: isPublished && !wasPublished ? t("messages.published") : t("messages.updated"),
data: id,
}
} catch (e) {
return handleActionError(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: "Announcement not found" }
if (!existing) return { success: false, message: t("messages.notFound") }
await deleteAnnouncementById(id)
@@ -264,18 +267,19 @@ export async function deleteAnnouncementAction(id: string): Promise<ActionState<
properties: { type: existing.type, status: existing.status },
})
return { success: true, message: "Announcement deleted" }
return { success: true, message: t("messages.deleted") }
} catch (e) {
return handleActionError(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: "Announcement not found" }
if (!existing) return { success: false, message: t("messages.notFound") }
const publishedAt = existing.publishedAt
? new Date(existing.publishedAt)
@@ -296,18 +300,19 @@ export async function publishAnnouncementAction(id: string): Promise<ActionState
properties: { type: existing.type },
})
return { success: true, message: "Announcement published" }
return { success: true, message: t("messages.published") }
} catch (e) {
return handleActionError(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: "Announcement not found" }
if (!existing) return { success: false, message: t("messages.notFound") }
await archiveAnnouncementById(id)
@@ -322,20 +327,26 @@ export async function archiveAnnouncementAction(id: string): Promise<ActionState
properties: { type: existing.type },
})
return { success: true, message: "Announcement archived" }
return { success: true, message: t("messages.archived") }
} catch (e) {
return handleActionError(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")
@@ -346,20 +357,29 @@ export async function toggleAnnouncementPinAction(id: string): Promise<ActionSta
targetType: "announcement",
})
return { success: true, message: "Pin status toggled" }
return { success: true, message: t("messages.pinToggled") }
} catch (e) {
return handleActionError(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({
@@ -369,35 +389,38 @@ export async function markAnnouncementAsReadAction(announcementId: string): Prom
targetType: "announcement",
})
return { success: true, message: "Announcement marked as read" }
return { success: true, message: t("messages.markedRead") }
} catch (e) {
return handleActionError(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)
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)
return handleActionError(e, "getAnnouncementsAction", t)
}
}