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、组件描述)
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
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 })),
|
||
}
|
||
}
|