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 => { 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 => 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 => { 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 { 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 { 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 { await db.delete(announcements).where(eq(announcements.id, id)) } export async function publishAnnouncementById( id: string, publishedAt: Date ): Promise { await db .update(announcements) .set({ status: "published", publishedAt, updatedAt: new Date(), }) .where(eq(announcements.id, id)) } export async function archiveAnnouncementById(id: string): Promise { 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 { // 查询当前置顶状态 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 { // 先检查是否已存在已读记录 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 { 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 { const [row] = await db .select({ value: count() }) .from(announcementReads) .where(eq(announcementReads.announcementId, announcementId)) return Number(row?.value ?? 0) } /** * 批量获取用户对多个公告的已读状态。 * 返回 Map */ export async function getAnnouncementReadStatusForUser( announcementIds: string[], userId: string ): Promise> { 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 })), } }