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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user