Files
NextEdu/src/modules/announcements/data-access.ts
SpecialX f75602d14e feat(announcements,messaging,notifications): 实现所有长期问题 — SSE 实时推送 + 通知日志持久化 + 优先级/归档 + 消息星标/草稿 + 公告已读回执/置顶 + 分类筛选/桌面推送 + 测试覆盖
P1-8 通知实时推送(SSE):
- 新增 /api/notifications/stream SSE 端点(15 秒推送,5 分钟超时)
- 新增 useNotificationStream Hook(SSE + 轮询降级)
- NotificationDropdown 改用 SSE 实时推送

P2-12 测试覆盖:
- notifications/dispatcher.test.ts(6 个测试,渠道选择逻辑)
- notifications/channels/in-app-channel.test.ts(9 个测试,类型映射)
- messaging/schema.test.ts(34 个测试,Zod 校验)
- tests/e2e/messages.spec.ts(消息模块 E2E 测试)
- vitest.unit.config.ts 添加 server-only stub

P2-13a 通知发送日志持久化:
- 新增 notification_logs 表(userId/title/channel/status/messageId/error/sentAt)
- logNotificationSend 改为 async 写入 DB(失败降级 console)
- dispatcher 传递 payload 用于持久化

P2-13b 通知优先级和归档:
- messageNotifications 表新增 priority(low/normal/high/urgent)和 isArchived 字段
- getNotifications 支持归档和优先级筛选
- 新增 archiveNotificationAction
- NotificationList 显示优先级 Badge 和归档按钮

P2-13c 消息星标和草稿:
- messages 表新增 isStarred 字段
- 新增 message_drafts 表
- 新增 toggleMessageStar + 草稿 CRUD Server Actions
- 新增 5 个草稿 data-access 函数

P2-13d 公告已读回执和置顶:
- announcements 表新增 isPinned 字段
- 新增 announcement_reads 表(唯一索引保证幂等)
- 新增 toggleAnnouncementPinAction + markAnnouncementAsReadAction
- getAnnouncements 排序置顶优先

P2-13e 通知分类筛选和桌面推送:
- NotificationList 添加按类型筛选按钮组
- 新增 useDesktopNotifications Hook(浏览器 Notification API)
- NotificationDropdown 集成桌面推送(新通知触发)

架构图同步:
- 004 和 005 均已更新(新增表、Action、Hook、组件描述)
2026-06-23 10:13:57 +08:00

338 lines
10 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.
import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db"
import { announcements, announcementReads, users } from "@/shared/db/schema"
import type {
Announcement,
AnnouncementInsertData,
AnnouncementStatus,
AnnouncementUpdateData,
GetAnnouncementsParams,
} from "./types"
const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null
const toIsoRequired = (d: Date): string => d.toISOString()
const mapRow = (
row: {
id: string
title: string
content: string
type: "school" | "grade" | "class"
status: "draft" | "published" | "archived"
targetGradeId: string | null
targetClassId: string | null
authorId: string
authorName: string | null
publishedAt: Date | null
isPinned: boolean
createdAt: Date
updatedAt: Date
}
): Announcement => ({
id: row.id,
title: row.title,
content: row.content,
type: row.type,
status: row.status,
targetGradeId: row.targetGradeId,
targetClassId: row.targetClassId,
authorId: row.authorId,
authorName: row.authorName,
publishedAt: toIso(row.publishedAt),
isPinned: row.isPinned,
createdAt: toIsoRequired(row.createdAt),
updatedAt: toIsoRequired(row.updatedAt),
})
export const getAnnouncements = cache(
async (params?: GetAnnouncementsParams): Promise<Announcement[]> => {
const page = Math.max(1, params?.page ?? 1)
const pageSize = Math.max(1, params?.pageSize ?? 20)
const offset = (page - 1) * pageSize
const conditions = []
if (params?.status) {
conditions.push(eq(announcements.status, params.status))
}
if (params?.type) {
conditions.push(eq(announcements.type, params.type))
}
// 受众过滤:当提供 audience 时,仅返回对该受众可见的公告
// (type = 'school') OR (type = 'grade' AND target_grade_id = audience.gradeId)
// OR (type = 'class' AND target_class_id = audience.classId)
if (params?.audience) {
const { gradeId, classId } = params.audience
const gradeClause = gradeId
? and(eq(announcements.type, "grade"), eq(announcements.targetGradeId, gradeId))
: undefined
const classClause = classId
? and(eq(announcements.type, "class"), eq(announcements.targetClassId, classId))
: undefined
const orClauses = [
eq(announcements.type, "school"),
gradeClause,
classClause,
].filter((c): c is NonNullable<typeof c> => c !== undefined)
conditions.push(or(...orClauses))
}
const rows = await db
.select({
id: announcements.id,
title: announcements.title,
content: announcements.content,
type: announcements.type,
status: announcements.status,
targetGradeId: announcements.targetGradeId,
targetClassId: announcements.targetClassId,
authorId: announcements.authorId,
authorName: users.name,
publishedAt: announcements.publishedAt,
isPinned: announcements.isPinned,
createdAt: announcements.createdAt,
updatedAt: announcements.updatedAt,
})
.from(announcements)
.leftJoin(users, eq(users.id, announcements.authorId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(announcements.isPinned), desc(announcements.createdAt))
.limit(pageSize)
.offset(offset)
return rows.map(mapRow)
}
)
export const getAnnouncementById = cache(
async (id: string): Promise<Announcement | null> => {
const [row] = await db
.select({
id: announcements.id,
title: announcements.title,
content: announcements.content,
type: announcements.type,
status: announcements.status,
targetGradeId: announcements.targetGradeId,
targetClassId: announcements.targetClassId,
authorId: announcements.authorId,
authorName: users.name,
publishedAt: announcements.publishedAt,
isPinned: announcements.isPinned,
createdAt: announcements.createdAt,
updatedAt: announcements.updatedAt,
})
.from(announcements)
.leftJoin(users, eq(users.id, announcements.authorId))
.where(eq(announcements.id, id))
.limit(1)
return row ? mapRow(row) : null
}
)
export async function insertAnnouncement(
data: AnnouncementInsertData
): Promise<string> {
await db.insert(announcements).values({
id: data.id,
title: data.title,
content: data.content,
type: data.type,
status: data.status,
targetGradeId: data.targetGradeId,
targetClassId: data.targetClassId,
authorId: data.authorId,
publishedAt: data.publishedAt,
})
return data.id
}
export async function updateAnnouncementById(
id: string,
data: AnnouncementUpdateData
): Promise<void> {
await db
.update(announcements)
.set({
title: data.title,
content: data.content,
type: data.type,
status: data.status,
targetGradeId: data.targetGradeId,
targetClassId: data.targetClassId,
publishedAt: data.publishedAt,
updatedAt: data.updatedAt,
})
.where(eq(announcements.id, id))
}
export async function deleteAnnouncementById(id: string): Promise<void> {
await db.delete(announcements).where(eq(announcements.id, id))
}
export async function publishAnnouncementById(
id: string,
publishedAt: Date
): Promise<void> {
await db
.update(announcements)
.set({
status: "published",
publishedAt,
updatedAt: new Date(),
})
.where(eq(announcements.id, id))
}
export async function archiveAnnouncementById(id: string): Promise<void> {
await db
.update(announcements)
.set({
status: "archived",
updatedAt: new Date(),
})
.where(eq(announcements.id, id))
}
// ---------------------------------------------------------------------------
// V2-P2-13d: 公告置顶
// ---------------------------------------------------------------------------
export async function toggleAnnouncementPin(id: string): Promise<void> {
// 查询当前置顶状态
const [row] = await db
.select({ isPinned: announcements.isPinned })
.from(announcements)
.where(eq(announcements.id, id))
.limit(1)
if (!row) return
await db
.update(announcements)
.set({ isPinned: !row.isPinned, updatedAt: new Date() })
.where(eq(announcements.id, id))
}
// ---------------------------------------------------------------------------
// V2-P2-13d: 公告已读回执announcement_reads 表)
// ---------------------------------------------------------------------------
/**
* 标记公告为已读(如果尚未标记)。
* 使用 INSERT IGNORE 语义避免重复插入(依赖唯一索引)。
*/
export async function markAnnouncementAsRead(announcementId: string, userId: string): Promise<void> {
// 先检查是否已存在已读记录
const [existing] = await db
.select({ id: announcementReads.id })
.from(announcementReads)
.where(and(eq(announcementReads.announcementId, announcementId), eq(announcementReads.userId, userId)))
.limit(1)
if (existing) return
const id = createId()
await db.insert(announcementReads).values({
id,
announcementId,
userId,
})
}
/**
* 检查用户是否已读指定公告。
*/
export async function isAnnouncementReadByUser(announcementId: string, userId: string): Promise<boolean> {
const [row] = await db
.select({ id: announcementReads.id })
.from(announcementReads)
.where(and(eq(announcementReads.announcementId, announcementId), eq(announcementReads.userId, userId)))
.limit(1)
return !!row
}
/**
* 获取公告的已读用户数。
*/
export async function getAnnouncementReadCount(announcementId: string): Promise<number> {
const [row] = await db
.select({ value: count() })
.from(announcementReads)
.where(eq(announcementReads.announcementId, announcementId))
return Number(row?.value ?? 0)
}
/**
* 批量获取用户对多个公告的已读状态。
* 返回 Map<announcementId, boolean>
*/
export async function getAnnouncementReadStatusForUser(
announcementIds: string[],
userId: string
): Promise<Map<string, boolean>> {
if (announcementIds.length === 0) return new Map()
const rows = await db
.select({ announcementId: announcementReads.announcementId })
.from(announcementReads)
.where(and(eq(announcementReads.userId, userId), inArray(announcementReads.announcementId, announcementIds)))
const readSet = new Set(rows.map((r) => r.announcementId))
return new Map(announcementIds.map((id) => [id, readSet.has(id)]))
}
/**
* 管理端公告列表页编排函数:一次性获取公告列表、年级列表、班级列表。
* 将原本散落在 page.tsx 中的多模块编排逻辑下沉到 data-access 层,
* 页面层只需调用单一函数,提升可复用性与可测试性。
*/
export async function getAdminAnnouncementsPageData(status?: AnnouncementStatus): Promise<{
announcements: Announcement[]
grades: { id: string; name: string }[]
classes: { id: string; name: string }[]
}> {
const { getGrades } = await import("@/modules/school/data-access")
const { getAdminClasses } = await import("@/modules/classes/data-access")
const [announcementList, gradeList, classList] = await Promise.all([
getAnnouncements({ status }),
getGrades(),
getAdminClasses(),
])
return {
announcements: announcementList,
grades: gradeList.map((g) => ({ id: g.id, name: g.name })),
classes: classList.map((c) => ({ id: c.id, name: c.name })),
}
}
/**
* 管理端公告编辑页编排函数:一次性获取公告详情和年级列表。
*/
export async function getEditAnnouncementPageData(id: string): Promise<{
announcement: Announcement | null
grades: { id: string; name: string }[]
}> {
const { getGrades } = await import("@/modules/school/data-access")
const [announcement, gradeList] = await Promise.all([
getAnnouncementById(id),
getGrades(),
])
return {
announcement,
grades: gradeList.map((g) => ({ id: g.id, name: g.name })),
}
}