diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 7b82d13..1d91a8a 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -1096,11 +1096,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" ## 2.13 messaging(私信模块) -**职责**:站内私信(messages 表 CRUD)。 +**职责**:站内私信(messages 表 CRUD)+ 消息草稿(message_drafts 表 CRUD)。 **导出函数**: -- Actions:`sendMessageAction` / `markMessageAsReadAction` / `deleteMessageAction` / `getMessagesAction` / `getMessageDetailAction` / `getRecipientsAction` / `getUnreadMessageCountAction` / `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`(✅ P1-4 已修复:通知 CRUD Action 已迁移至 notifications 模块,messaging 仅保留私信和通知偏好 Action) -- Data-access:`getMessages` / `getMessageById` / `getMessageThread` / `createMessage` / `markMessageAsRead` / `deleteMessage` / `getUnreadMessageCount` / `getRecipients`(按 DataScope 过滤可发送对象:class_taught 教师→学生、grade_managed 年级管理员→教师/学生、all 管理员、class_members 学生→自己班级的任课教师/班主任、children 家长→孩子的班主任/任课教师;通过 classes data-access.getTeacherIdsByClassIds/getStudentActiveClassId 获取班级教师 ID)/ `getMessagesPageData`(✅ P1-5 新增:消息首页编排函数,一次性获取消息列表和通知列表)/ `getMessageDetailPageData`(✅ V2-P1-3 新增:消息详情页编排函数,获取详情并自动标记已读) +- Actions:`sendMessageAction` / `markMessageAsReadAction` / `deleteMessageAction` / `getMessagesAction` / `getMessageDetailAction` / `getRecipientsAction` / `getUnreadMessageCountAction` / `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`(✅ P1-4 已修复:通知 CRUD Action 已迁移至 notifications 模块,messaging 仅保留私信和通知偏好 Action;✅ V2-P2-13c 新增:`toggleMessageStarAction` 星标切换 / `getMessageDraftsAction` 草稿列表 / `saveMessageDraftAction` 草稿保存(创建或更新)/ `deleteMessageDraftAction` 草稿删除) +- Data-access:`getMessages`(✅ V2-P2-13c 新增 starredOnly 星标筛选)/ `getMessageById` / `getMessageThread` / `createMessage` / `markMessageAsRead` / `deleteMessage` / `toggleMessageStar`(✅ V2-P2-13c 新增:接收方星标切换)/ `getUnreadMessageCount` / `getRecipients`(按 DataScope 过滤可发送对象:class_taught 教师→学生、grade_managed 年级管理员→教师/学生、all 管理员、class_members 学生→自己班级的任课教师/班主任、children 家长→孩子的班主任/任课教师;通过 classes data-access.getTeacherIdsByClassIds/getStudentActiveClassId 获取班级教师 ID)/ `getMessagesPageData`(✅ P1-5 新增:消息首页编排函数,一次性获取消息列表和通知列表)/ `getMessageDetailPageData`(✅ V2-P1-3 新增:消息详情页编排函数,获取详情并自动标记已读)/ `getMessageDrafts` / `createMessageDraft` / `updateMessageDraft` / `deleteMessageDraft` / `getMessageDraftById`(✅ V2-P2-13c 新增:消息草稿 CRUD,操作 message_drafts 表) - Hooks:`useMessageSearch`(✅ P1-7 新增:消息搜索 hook,含防抖和请求竞态取消) - Notification-preferences:~~re-export shim(实际逻辑在 `notifications/preferences.ts`)~~ ✅ P0-b 已修复:`notification-preferences.ts` 文件已删除(通知模块去重),消费方改为直接从 `@/modules/notifications/preferences` 导入 `getNotificationPreferences` / `upsertNotificationPreferences` @@ -1130,6 +1130,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ V2-P1-3 已修复:~~消息详情页分散编排~~ 新增 `getMessageDetailPageData` 编排函数,替代 page.tsx 中 `after()` + `getMessageById` + `markMessageAsRead` 的分散编排 - ✅ V2-P1-4 已修复:~~表单无服务端校验错误展示~~ `message-compose.tsx` 新增 `fieldErrors` 状态 + `aria-invalid` 字段级错误展示(receiverId/subject/content) - ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `unread-message-badge.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(~~60_000ms~~ → 30_000ms,✅ V2-P3 与通知组件保持一致) +- ✅ V2-P2-13c 已实现:消息星标和草稿功能。`messages` 表新增 `isStarred` 字段(接收方可标记重要消息)+ `messages_receiver_starred_idx` 复合索引;新增 `message_drafts` 表(userId/receiverId/subject/content/parentMessageId + updatedAt/createdAt,含 `message_drafts_user_idx` 和 `message_drafts_user_updated_idx` 索引);data-access 新增 `toggleMessageStar` / `getMessageDrafts` / `createMessageDraft` / `updateMessageDraft` / `deleteMessageDraft` / `getMessageDraftById`;actions 新增 `toggleMessageStarAction` / `getMessageDraftsAction` / `saveMessageDraftAction` / `deleteMessageDraftAction`;`getMessages` 支持 `starredOnly` 筛选;i18n 新增 actions.star/unstar/saveDraft/deleteDraft + messages.draftSaved/draftDeleted/starToggled + empty.noDrafts/noStarred 翻译键;迁移文件 `drizzle/0008_message_star_draft.sql` **文件清单**: | 文件 | 行数 | 职责 | @@ -1164,8 +1165,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - Data-access:`createNotification` / `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `archiveNotification` / `unarchiveNotification` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移;✅ V2-P2-13b 新增:archiveNotification / unarchiveNotification 归档函数) - Preferences:`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移) - Channels:`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender` -- Components:`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移;✅ V2-P2-13b:NotificationList 支持优先级 Badge 显示和归档操作) -- Hooks:`useNotificationStream`(✅ V2-P3 新增:SSE 实时推送 + 轮询降级 Hook) +- Components:`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移;✅ V2-P2-13b:NotificationList 支持优先级 Badge 显示和归档操作;✅ V2-P2-13c:NotificationList 支持按类型筛选) +- Hooks:`useNotificationStream`(✅ V2-P3 新增:SSE 实时推送 + 轮询降级 Hook)、`useDesktopNotifications`(✅ V2-P2-13c 新增:浏览器桌面推送 Hook,支持权限管理和新通知桌面提醒) - Types:`NotificationPriority`(✅ V2-P2-13b 新增:通知优先级类型 low/normal/high/urgent) **依赖关系**: @@ -1182,6 +1183,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `notification-dropdown.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(30_000ms) - ✅ V2-P3 已优化:~~30 秒轮询~~ `notification-dropdown.tsx` 改为 SSE 实时推送(`/api/notifications/stream`),SSE 不可用时自动降级为轮询(调用 Server Actions,间隔 30 秒) - ✅ V2-P2-13b 新增:通知优先级和归档功能。schema.ts `messageNotifications` 表新增 `priority`(low/normal/high/urgent,默认 normal)和 `isArchived`(默认 false)字段 + 2 个索引;types.ts 新增 `NotificationPriority` 类型;data-access.ts 新增 `archiveNotification` / `unarchiveNotification` 函数,`getNotifications` 支持归档和优先级筛选(默认仅返回未归档),`createNotification` 支持 priority;actions.ts 新增 `archiveNotificationAction`(含 trackEvent 埋点 notification.archived);NotificationList 组件支持优先级 Badge 显示和归档按钮;i18n 新增 priority/actions.archive/messages.archiveFailed 翻译键 +- ✅ V2-P2-13c 新增:通知分类筛选 + 浏览器桌面推送。NotificationList 组件新增按类型筛选按钮组(all/message/announcement/homework/grade),空状态区分"无通知"和"无筛选结果";新增 `useDesktopNotifications` Hook(浏览器 Notification API,支持权限管理、自动请求权限、新通知桌面推送、点击跳转);NotificationDropdown 集成桌面推送(监听新通知并触发桌面提醒,首次加载不批量推送);i18n 新增 filter.all / empty.noFilterResults / empty.noFilterResultsDesc 翻译键 - ⚠️ P1:发送日志仅 console,无 `notification_logs` 表 - ✅ 渠道抽象优秀(接口 + 工厂 + Mock 实现) @@ -1193,21 +1195,23 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `preferences.ts` | 166 | 通知偏好 CRUD(P0-4 / P1-5 修复后从 messaging 迁移) | | `actions.ts` | ~300 | 7 个 Server Action(✅ P1-4:新增 4 个通知 CRUD Action;✅ V2-P2-13b:新增 archiveNotificationAction) | | `types.ts` | ~130 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型(P0-4 / P1-5 修复后扩充;✅ V2-P2-13b:新增 NotificationPriority 类型 + priority/isArchived 字段) | -| `index.ts` | ~80 | 对外导出入口(✅ P1-4:新增组件和 CRUD Action 导出;✅ V2-P2-13b:新增归档函数/Action/类型导出) | +| `index.ts` | ~80 | 对外导出入口(✅ P1-4:新增组件和 CRUD Action 导出;✅ V2-P2-13b:新增归档函数/Action/类型导出;✅ V2-P2-13c:新增 useNotificationStream / useDesktopNotifications Hook 导出) | | `channels/*` | 5 文件 | 4 个渠道实现 | -| `components/notification-list.tsx` | ~170 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件;✅ V2-P2-13b:支持优先级 Badge 显示和归档操作 | -| `components/notification-dropdown.tsx` | ~150 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件;✅ V2-P3:改用 SSE 实时推送 + 轮询降级 | +| `components/notification-list.tsx` | ~190 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件;✅ V2-P2-13b:支持优先级 Badge 显示和归档操作;✅ V2-P2-13c:支持按类型筛选 + 空状态区分 | +| `components/notification-dropdown.tsx` | ~180 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件;✅ V2-P3:改用 SSE 实时推送 + 轮询降级;✅ V2-P2-13c:集成 useDesktopNotifications 桌面推送 | | `hooks/use-notification-stream.ts` | ~195 | ✅ V2-P3 新增:SSE 实时推送 Hook(EventSource + 轮询降级) | +| `hooks/use-desktop-notifications.ts` | ~100 | ✅ V2-P2-13c 新增:浏览器桌面推送 Hook(Notification API + 权限管理 + 点击跳转) | **组件清单**: | 组件 | 职责 | |------|------| -| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1:useTranslations 命名空间从 "messages" 切换到 "notifications";✅ V2-P2-13b:支持优先级 Badge 显示和归档操作) | -| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3:改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1:useTranslations 命名空间切换;✅ V2-P2-1:POLL_INTERVAL_MS 常量) | +| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1:useTranslations 命名空间从 "messages" 切换到 "notifications";✅ V2-P2-13b:支持优先级 Badge 显示和归档操作;✅ V2-P2-13c:支持按类型筛选 + 空状态区分"无通知"/"无筛选结果") | +| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3:改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1:useTranslations 命名空间切换;✅ V2-P2-1:POLL_INTERVAL_MS 常量;✅ V2-P2-13c:集成 useDesktopNotifications 桌面推送,监听新通知并触发桌面提醒) | **客户端行为**: -- `notification-dropdown.tsx`:✅ V2-P3 改用 `useNotificationStream` Hook 消费 SSE 实时推送(`/api/notifications/stream`),SSE 不可用时自动降级为轮询(调用 `getNotificationsAction` + `getUnreadNotificationCountAction`,间隔 30 秒) +- `notification-dropdown.tsx`:✅ V2-P3 改用 `useNotificationStream` Hook 消费 SSE 实时推送(`/api/notifications/stream`),SSE 不可用时自动降级为轮询(调用 `getNotificationsAction` + `getUnreadNotificationCountAction`,间隔 30 秒);✅ V2-P2-13c 集成 `useDesktopNotifications` Hook,通过 `prevNotificationIds` 对比检测新通知并触发桌面推送(首次加载不批量推送) - `hooks/use-notification-stream.ts`:✅ V2-P3 新增,管理 SSE 连接生命周期(EventSource onopen/onmessage/onerror),降级时通过 `pollFnRef` 调用 Server Actions 轮询 +- `hooks/use-desktop-notifications.ts`:✅ V2-P2-13c 新增,封装浏览器 Notification API(权限管理、自动请求权限、桌面推送、点击跳转回调),SSR 安全(`typeof window !== "undefined"` 检查) --- @@ -1243,8 +1247,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **职责**:公告 CRUD + 发布/归档 + 发布通知。 **导出函数**: -- Actions:`getAnnouncementsAction` / `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction`(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access;✅ 发布公告时触发通知模块 `sendBatchNotifications`) -- Data-access:`getAnnouncements`(支持 `audience` 受众过滤)/ `getAnnouncementById` / `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数,页面层仅调用单一函数) +- Actions:`getAnnouncementsAction` / `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction`(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access;✅ 发布公告时触发通知模块 `sendBatchNotifications`)/ `toggleAnnouncementPinAction` / `markAnnouncementAsReadAction` / `getAnnouncementReadStatusAction`(✅ V2-P2-13d 新增:置顶切换、已读标记、批量已读状态查询) +- Data-access:`getAnnouncements`(支持 `audience` 受众过滤)/ `getAnnouncementById` / `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数,页面层仅调用单一函数)/ `toggleAnnouncementPin` / `markAnnouncementAsRead` / `isAnnouncementReadByUser` / `getAnnouncementReadCount` / `getAnnouncementReadStatusForUser`(✅ V2-P2-13d 新增:置顶切换 + 已读回执 CRUD,操作 `announcement_reads` 表) **依赖关系**: - 依赖:`shared/*`、`@/auth`、`school`(获取年级列表)、`classes`(获取班级列表 + 解析受众)、`users`(获取目标用户 ID 列表)、`notifications`(发布公告时发送通知) @@ -1267,14 +1271,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ V2-P0-2 已修复:~~通知标题硬编码~~ `createAnnouncementAction` / `updateAnnouncementAction` / `publishAnnouncementAction` 通过 `getTranslations('announcements')` 生成 i18n 通知标题(`notification.publishedTitle` / `publishedContent`) - ✅ V2-P1-1 已修复:~~AnnouncementList 客户端 useState/useMemo 过滤~~ 改为纯服务端过滤模式,Select 切换仅更新 URL `?status=` 触发 RSC 重新渲染 - ✅ V2-P1-4 已修复:~~表单无服务端校验错误展示~~ `announcement-form.tsx` 新增 `fieldErrors` 状态 + `aria-invalid` 字段级错误展示(title/content/targetGradeId/targetClassId) +- ✅ V2-P2-13d 已实现:公告置顶 + 已读回执。`announcements` 表新增 `isPinned` 字段(置顶优先排序,`getAnnouncements` orderBy 改为 `desc(isPinned), desc(createdAt)`);新增 `announcement_reads` 表(唯一索引 announcementId+userId 保证幂等);新增 3 个 Server Action(`toggleAnnouncementPinAction` / `markAnnouncementAsReadAction` / `getAnnouncementReadStatusAction`)+ 5 个 data-access 函数(`toggleAnnouncementPin` / `markAnnouncementAsRead` / `isAnnouncementReadByUser` / `getAnnouncementReadCount` / `getAnnouncementReadStatusForUser`);`Announcement` 类型新增 `isPinned` / `isReadByCurrentUser?` / `readCount?` 字段;新增 `AnnouncementRead` 类型;埋点新增 `announcement.pin_toggled` / `announcement.marked_read` **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| -| `actions.ts` | ~330 | 6 个 Server Action + 通知触发逻辑 + trackEvent 埋点(P1-2 已修复,无直接 DB 操作) | -| `data-access.ts` | ~230 | 公告 CRUD + 发布/归档 + 受众过滤 + `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData` 编排函数(✅ P1-5 新增) | +| `actions.ts` | ~400 | 9 个 Server Action + 通知触发逻辑 + trackEvent 埋点(P1-2 已修复,无直接 DB 操作;V2-P2-13d 新增置顶/已读/批量已读状态 3 个 Action) | +| `data-access.ts` | ~300 | 公告 CRUD + 发布/归档 + 受众过滤 + `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData` 编排函数(✅ P1-5 新增;V2-P2-13d 新增置顶切换 + 已读回执 5 个函数,操作 `announcement_reads` 表) | | `schema.ts` | ~70 | Zod 校验 + `refineAudience` 条件校验(✅ P1-6 新增 superRefine) | -| `types.ts` | ~65 | 类型定义(`GetAnnouncementsParams` 新增 `audience` 字段) | +| `types.ts` | ~75 | 类型定义(`GetAnnouncementsParams` 新增 `audience` 字段;V2-P2-13d `Announcement` 新增 `isPinned` / `isReadByCurrentUser?` / `readCount?`,新增 `AnnouncementRead` 接口) | **组件清单**: | 组件 | 职责 | diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 489a8fb..32cef30 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -8408,6 +8408,45 @@ "usedBy": [ "待扩展" ] + }, + { + "name": "toggleAnnouncementPinAction", + "permission": "ANNOUNCEMENT_MANAGE", + "signature": "(id: string) => Promise>", + "purpose": "V2-P2-13d 新增:切换公告置顶状态(置顶公告在列表中优先显示)", + "deps": [ + "requirePermission", + "data-access.toggleAnnouncementPin" + ], + "usedBy": [ + "待扩展" + ] + }, + { + "name": "markAnnouncementAsReadAction", + "permission": "ANNOUNCEMENT_READ", + "signature": "(announcementId: string) => Promise>", + "purpose": "V2-P2-13d 新增:标记公告为已读(当前用户维度,幂等)", + "deps": [ + "requirePermission", + "data-access.markAnnouncementAsRead" + ], + "usedBy": [ + "待扩展" + ] + }, + { + "name": "getAnnouncementReadStatusAction", + "permission": "ANNOUNCEMENT_READ", + "signature": "(announcementIds: string[]) => Promise>>", + "purpose": "V2-P2-13d 新增:批量获取当前用户对多个公告的已读状态(用于列表页标记已读/未读)", + "deps": [ + "requirePermission", + "data-access.getAnnouncementReadStatusForUser" + ], + "usedBy": [ + "待扩展" + ] } ], "dataAccess": [ @@ -8505,6 +8544,71 @@ "usedBy": [ "archiveAnnouncementAction" ] + }, + { + "name": "toggleAnnouncementPin", + "signature": "(id: string) => Promise", + "file": "data-access.ts", + "purpose": "V2-P2-13d 新增:切换公告置顶状态(查询当前 isPinned 后取反更新)", + "deps": [ + "shared.db", + "shared.db.schema.announcements" + ], + "usedBy": [ + "toggleAnnouncementPinAction" + ] + }, + { + "name": "markAnnouncementAsRead", + "signature": "(announcementId: string, userId: string) => Promise", + "file": "data-access.ts", + "purpose": "V2-P2-13d 新增:标记公告为已读(先查后插,依赖唯一索引保证幂等)", + "deps": [ + "shared.db", + "shared.db.schema.announcementReads" + ], + "usedBy": [ + "markAnnouncementAsReadAction" + ] + }, + { + "name": "isAnnouncementReadByUser", + "signature": "(announcementId: string, userId: string) => Promise", + "file": "data-access.ts", + "purpose": "V2-P2-13d 新增:检查用户是否已读指定公告", + "deps": [ + "shared.db", + "shared.db.schema.announcementReads" + ], + "usedBy": [ + "待扩展" + ] + }, + { + "name": "getAnnouncementReadCount", + "signature": "(announcementId: string) => Promise", + "file": "data-access.ts", + "purpose": "V2-P2-13d 新增:获取公告的已读用户数(管理端统计)", + "deps": [ + "shared.db", + "shared.db.schema.announcementReads" + ], + "usedBy": [ + "待扩展" + ] + }, + { + "name": "getAnnouncementReadStatusForUser", + "signature": "(announcementIds: string[], userId: string) => Promise>", + "file": "data-access.ts", + "purpose": "V2-P2-13d 新增:批量获取用户对多个公告的已读状态(列表页标记已读/未读)", + "deps": [ + "shared.db", + "shared.db.schema.announcementReads" + ], + "usedBy": [ + "getAnnouncementReadStatusAction" + ] } ], "schemas": [ @@ -8532,7 +8636,7 @@ "name": "Announcement", "type": "interface", "file": "types.ts", - "definition": "{ id, title, content, type, status, targetGradeId, targetClassId, authorId, authorName, publishedAt, createdAt, updatedAt }", + "definition": "{ id, title, content, type, status, targetGradeId, targetClassId, authorId, authorName, publishedAt, isPinned, isReadByCurrentUser?, readCount?, createdAt, updatedAt }", "usedBy": [ "announcements/components", "页面" @@ -8576,6 +8680,15 @@ "getAnnouncements", "getAnnouncementsAction" ] + }, + { + "name": "AnnouncementRead", + "type": "interface", + "file": "types.ts", + "definition": "{ id, announcementId, userId, readAt }", + "usedBy": [ + "data-access.announcement_reads 相关函数" + ] } ], "components": [ @@ -10820,6 +10933,64 @@ "unread-message-badge.tsx" ] }, + { + "name": "toggleMessageStarAction", + "permission": "MESSAGE_READ", + "signature": "(messageId: string) => Promise>", + "purpose": "V2-P2-13c 新增:切换消息星标状态(仅接收方可标记;trackEvent 埋点 message.star_toggled)", + "deps": [ + "requirePermission", + "schema.MessageIdSchema", + "data-access.toggleMessageStar", + "trackEvent", + "revalidatePath" + ], + "usedBy": [ + "message-detail.tsx", + "message-list.tsx" + ] + }, + { + "name": "getMessageDraftsAction", + "permission": "MESSAGE_SEND", + "signature": "() => Promise>", + "purpose": "V2-P2-13c 新增:获取当前用户的消息草稿列表(按 updatedAt 倒序)", + "deps": [ + "requirePermission", + "data-access.getMessageDrafts" + ], + "usedBy": [ + "messages/compose/page.tsx" + ] + }, + { + "name": "saveMessageDraftAction", + "permission": "MESSAGE_SEND", + "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", + "purpose": "V2-P2-13c 新增:保存消息草稿(FormData 含 draftId 时更新,否则创建新草稿;返回草稿 ID)", + "deps": [ + "requirePermission", + "data-access.createMessageDraft", + "data-access.updateMessageDraft" + ], + "usedBy": [ + "messages/compose/page.tsx" + ] + }, + { + "name": "deleteMessageDraftAction", + "permission": "MESSAGE_SEND", + "signature": "(draftId: string) => Promise>", + "purpose": "V2-P2-13c 新增:删除消息草稿(仅草稿所有者可删;revalidatePath /messages/compose)", + "deps": [ + "requirePermission", + "data-access.deleteMessageDraft", + "revalidatePath" + ], + "usedBy": [ + "messages/compose/page.tsx" + ] + }, { "name": "getNotificationPreferencesAction", "permission": "MESSAGE_READ", @@ -10929,6 +11100,19 @@ "deleteMessageAction" ] }, + { + "name": "toggleMessageStar", + "signature": "(id: string, userId: string) => Promise", + "file": "data-access.ts", + "purpose": "V2-P2-13c 新增:切换消息星标状态(仅接收方可标记;查询当前 isStarred 后取反更新)", + "deps": [ + "shared.db", + "shared.db.schema.messages" + ], + "usedBy": [ + "toggleMessageStarAction" + ] + }, { "name": "getUnreadMessageCount", "signature": "(userId: string) => Promise", @@ -10987,6 +11171,74 @@ "getRecipientsAction", "messages/compose/page.tsx" ] + }, + { + "name": "getMessageDrafts", + "signature": "(userId: string) => Promise", + "file": "data-access.ts", + "purpose": "V2-P2-13c 新增:获取用户消息草稿列表(按 updatedAt 倒序;React cache 包装;批量解析 receiverName)", + "deps": [ + "shared.db", + "shared.db.schema.messageDrafts", + "shared.db.schema.users" + ], + "usedBy": [ + "getMessageDraftsAction" + ] + }, + { + "name": "createMessageDraft", + "signature": "(data: CreateMessageDraftInput) => Promise", + "file": "data-access.ts", + "purpose": "V2-P2-13c 新增:创建消息草稿(cuid2 生成 ID;返回新草稿 ID)", + "deps": [ + "shared.db", + "shared.db.schema.messageDrafts", + "@paralleldrive/cuid2" + ], + "usedBy": [ + "saveMessageDraftAction" + ] + }, + { + "name": "updateMessageDraft", + "signature": "(id: string, userId: string, data: UpdateMessageDraftInput) => Promise", + "file": "data-access.ts", + "purpose": "V2-P2-13c 新增:更新消息草稿(仅 owner 可改;未提供字段保留原值;updatedAt 由 onUpdateNow 自动更新)", + "deps": [ + "shared.db", + "shared.db.schema.messageDrafts" + ], + "usedBy": [ + "saveMessageDraftAction" + ] + }, + { + "name": "deleteMessageDraft", + "signature": "(id: string, userId: string) => Promise", + "file": "data-access.ts", + "purpose": "V2-P2-13c 新增:删除消息草稿(仅 owner 可删)", + "deps": [ + "shared.db", + "shared.db.schema.messageDrafts" + ], + "usedBy": [ + "deleteMessageDraftAction" + ] + }, + { + "name": "getMessageDraftById", + "signature": "(id: string, userId: string) => Promise", + "file": "data-access.ts", + "purpose": "V2-P2-13c 新增:按 ID 获取单条消息草稿(仅 owner 可读;解析 receiverName)", + "deps": [ + "shared.db", + "shared.db.schema.messageDrafts", + "shared.db.schema.users" + ], + "usedBy": [ + "messages/compose/page.tsx" + ] } ], "notificationPreferences": [ @@ -11032,7 +11284,7 @@ "name": "Message", "type": "type", "file": "types.ts", - "definition": "{ id, senderId, receiverId, subject: string | null, content, isRead, readAt: string | null, parentMessageId: string | null, createdAt, senderName, receiverName }", + "definition": "{ id, senderId, receiverId, subject: string | null, content, isRead, isStarred, readAt: string | null, parentMessageId: string | null, createdAt, senderName, receiverName }", "usedBy": [ "messaging/components", "页面" @@ -11121,6 +11373,36 @@ "compose 页面下拉选项" ] }, + { + "name": "MessageDraft", + "type": "type", + "file": "types.ts", + "definition": "V2-P2-13c 新增:{ id, userId, receiverId: string | null, receiverName: string | null, subject: string | null, content: string | null, parentMessageId: string | null, createdAt, updatedAt }", + "usedBy": [ + "getMessageDrafts", + "getMessageDraftById", + "getMessageDraftsAction", + "messages/compose/page.tsx" + ] + }, + { + "name": "CreateMessageDraftInput", + "type": "type", + "file": "types.ts", + "definition": "V2-P2-13c 新增:{ userId, receiverId?, subject?, content?, parentMessageId? }", + "usedBy": [ + "createMessageDraft" + ] + }, + { + "name": "UpdateMessageDraftInput", + "type": "type", + "file": "types.ts", + "definition": "V2-P2-13c 新增:{ receiverId?, subject?, content?, parentMessageId? }(未提供字段保留原值)", + "usedBy": [ + "updateMessageDraft" + ] + }, { "name": "PaginatedResult", "type": "type", @@ -11705,12 +11987,30 @@ { "name": "NotificationList", "file": "components/notification-list.tsx", - "purpose": "P1-4 新增(从 messaging 迁移):通知完整列表(全部标记已读、单条标记已读、查看链接);V2-P0-1 优化:useTranslations 命名空间从 'messages' 切换到独立的 'notifications'" + "purpose": "P1-4 新增(从 messaging 迁移):通知完整列表(全部标记已读、单条标记已读、查看链接);V2-P0-1 优化:useTranslations 命名空间从 'messages' 切换到独立的 'notifications';V2-P2-13b:支持优先级 Badge 显示和归档操作;V2-P2-13c:支持按类型筛选(all/message/announcement/homework/grade)+ 空状态区分'无通知'/'无筛选结果'" }, { "name": "NotificationDropdown", "file": "components/notification-dropdown.tsx", - "purpose": "P1-4 新增(从 messaging 迁移):SiteHeader 通知下拉菜单(Bell 图标 + 未读数 Badge,每 30 秒轮询,滚动列表,标记已读,查看全部链接);V2-P0-1 优化:useTranslations 命名空间从 'messages' 切换到独立的 'notifications';V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量" + "purpose": "P1-4 新增(从 messaging 迁移):SiteHeader 通知下拉菜单(Bell 图标 + 未读数 Badge,每 30 秒轮询,滚动列表,标记已读,查看全部链接);V2-P0-1 优化:useTranslations 命名空间从 'messages' 切换到独立的 'notifications';V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量;V2-P3:改用 SSE 实时推送 + 轮询降级;V2-P2-13c:集成 useDesktopNotifications 桌面推送,监听新通知并触发桌面提醒(首次加载不批量推送)" + } + ], + "hooks": [ + { + "name": "useNotificationStream", + "file": "hooks/use-notification-stream.ts", + "purpose": "V2-P3 新增:SSE 实时推送 Hook(EventSource + 轮询降级),管理 SSE 连接生命周期,降级时通过 pollFnRef 调用 Server Actions 轮询", + "usedBy": [ + "components/notification-dropdown.tsx" + ] + }, + { + "name": "useDesktopNotifications", + "file": "hooks/use-desktop-notifications.ts", + "purpose": "V2-P2-13c 新增:浏览器桌面推送 Hook(Notification API),支持权限管理、自动请求权限、新通知桌面推送、点击跳转回调;SSR 安全(typeof window !== 'undefined' 检查)", + "usedBy": [ + "components/notification-dropdown.tsx" + ] } ] } @@ -14956,7 +15256,7 @@ }, "dbTables": { "_meta": { - "total": 59, + "total": 60, "orm": "Drizzle ORM 0.45", "database": "MySQL", "idStrategy": "CUID2 (varchar length 128)", @@ -15152,7 +15452,11 @@ "tables": { "announcements": { "owner": "announcements", - "description": "公告(school/grade/class,draft/published/archived)" + "description": "公告(school/grade/class,draft/published/archived;V2-P2-13d 新增 isPinned 置顶字段)" + }, + "announcementReads": { + "owner": "announcements", + "description": "公告已读回执(V2-P2-13d 新增,唯一索引 announcementId+userId 保证幂等,cascade 删除)" } } }, @@ -15209,7 +15513,11 @@ "tables": { "messages": { "owner": "messaging", - "description": "站内消息(含回复链 parentMessageId;软删除 senderDeletedAt/receiverDeletedAt)" + "description": "站内消息(含回复链 parentMessageId;软删除 senderDeletedAt/receiverDeletedAt;V2-P2-13c 新增 isStarred 星标字段 + messages_receiver_starred_idx 复合索引)" + }, + "messageDrafts": { + "owner": "messaging", + "description": "V2-P2-13c 新增:消息草稿(userId/receiverId/subject/content/parentMessageId + updatedAt/createdAt;含 message_drafts_user_idx 和 message_drafts_user_updated_idx 索引)" }, "messageNotifications": { "owner": "notifications", @@ -15841,6 +16149,7 @@ "auth-guard.requirePermission", "auth-guard.requireAuth", "db.schema.announcements", + "db.schema.announcementReads", "types.permissions" ], "auth": [ diff --git a/drizzle/0006_notification_logs.sql b/drizzle/0006_notification_logs.sql new file mode 100644 index 0000000..5a4dde0 --- /dev/null +++ b/drizzle/0006_notification_logs.sql @@ -0,0 +1,15 @@ +CREATE TABLE `notification_logs` ( + `id` varchar(128) PRIMARY KEY NOT NULL, + `user_id` varchar(128) NOT NULL, + `title` varchar(255) NOT NULL, + `channel` varchar(32) NOT NULL, + `status` varchar(16) NOT NULL, + `message_id` varchar(255), + `error` text, + `sent_at` timestamp DEFAULT (now()) NOT NULL, + CONSTRAINT `notification_logs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action +); +CREATE INDEX `notification_logs_user_idx` ON `notification_logs`(`user_id`); +CREATE INDEX `notification_logs_channel_idx` ON `notification_logs`(`channel`); +CREATE INDEX `notification_logs_status_idx` ON `notification_logs`(`status`); +CREATE INDEX `notification_logs_sent_at_idx` ON `notification_logs`(`sent_at`); diff --git a/drizzle/0007_notification_priority_archive.sql b/drizzle/0007_notification_priority_archive.sql new file mode 100644 index 0000000..a569881 --- /dev/null +++ b/drizzle/0007_notification_priority_archive.sql @@ -0,0 +1,4 @@ +ALTER TABLE `message_notifications` ADD COLUMN `priority` varchar(16) DEFAULT 'normal' NOT NULL; +ALTER TABLE `message_notifications` ADD COLUMN `is_archived` boolean DEFAULT false NOT NULL; +CREATE INDEX `message_notifications_priority_idx` ON `message_notifications`(`priority`); +CREATE INDEX `message_notifications_user_archived_idx` ON `message_notifications`(`user_id`, `is_archived`); diff --git a/drizzle/0008_message_star_draft.sql b/drizzle/0008_message_star_draft.sql new file mode 100644 index 0000000..a3e6ab1 --- /dev/null +++ b/drizzle/0008_message_star_draft.sql @@ -0,0 +1,17 @@ +ALTER TABLE `messages` ADD COLUMN `is_starred` boolean DEFAULT false NOT NULL; +CREATE INDEX `messages_receiver_starred_idx` ON `messages`(`receiver_id`, `is_starred`); + +CREATE TABLE `message_drafts` ( + `id` varchar(128) PRIMARY KEY NOT NULL, + `user_id` varchar(128) NOT NULL, + `receiver_id` varchar(128), + `subject` varchar(255), + `content` text, + `parent_message_id` varchar(128), + `updated_at` timestamp DEFAULT (now()) ON UPDATE now() NOT NULL, + `created_at` timestamp DEFAULT (now()) NOT NULL, + CONSTRAINT `message_drafts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action, + CONSTRAINT `message_drafts_receiver_id_users_id_fk` FOREIGN KEY (`receiver_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action +); +CREATE INDEX `message_drafts_user_idx` ON `message_drafts`(`user_id`); +CREATE INDEX `message_drafts_user_updated_idx` ON `message_drafts`(`user_id`, `updated_at`); diff --git a/drizzle/0009_announcement_pin_reads.sql b/drizzle/0009_announcement_pin_reads.sql new file mode 100644 index 0000000..7330993 --- /dev/null +++ b/drizzle/0009_announcement_pin_reads.sql @@ -0,0 +1,14 @@ +ALTER TABLE `announcements` ADD COLUMN `is_pinned` boolean DEFAULT false NOT NULL; +CREATE INDEX `announcements_status_pinned_idx` ON `announcements`(`status`, `is_pinned`); + +CREATE TABLE `announcement_reads` ( + `id` varchar(128) PRIMARY KEY NOT NULL, + `announcement_id` varchar(128) NOT NULL, + `user_id` varchar(128) NOT NULL, + `read_at` timestamp DEFAULT (now()) NOT NULL, + CONSTRAINT `announcement_reads_announcement_id_announcements_id_fk` FOREIGN KEY (`announcement_id`) REFERENCES `announcements`(`id`) ON DELETE cascade ON UPDATE no action, + CONSTRAINT `announcement_reads_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action +); +CREATE INDEX `announcement_reads_announcement_idx` ON `announcement_reads`(`announcement_id`); +CREATE INDEX `announcement_reads_user_idx` ON `announcement_reads`(`user_id`); +CREATE UNIQUE INDEX `announcement_reads_unique_idx` ON `announcement_reads`(`announcement_id`, `user_id`); diff --git a/src/app/api/notifications/stream/route.ts b/src/app/api/notifications/stream/route.ts new file mode 100644 index 0000000..3033ef9 --- /dev/null +++ b/src/app/api/notifications/stream/route.ts @@ -0,0 +1,118 @@ +import { NextRequest } from "next/server" + +import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getUnreadNotificationCount, getNotifications } from "@/modules/notifications/data-access" + +/** + * 通知实时推送 SSE 端点 + * + * 使用 Server-Sent Events 向客户端推送通知更新, + * 替代原有的 30/60 秒轮询模式,降低延迟和服务器压力。 + * + * 推送策略: + * - 连接建立时立即推送一次当前状态(未读数 + 最新通知列表) + * - 之后每 15 秒推送一次更新(作为 SSE 心跳 + 数据刷新) + * - 客户端断开连接时清理定时器 + * + * 安全: + * - requirePermission(MESSAGE_READ) 权限校验 + * - 连接超时 5 分钟自动关闭(防止僵尸连接) + */ + +const FORMAT_EVENT = (data: unknown): string => `data: ${JSON.stringify(data)}\n\n` +const FORMAT_DONE = "data: [DONE]\n\n" + +/** SSE 推送间隔(毫秒) */ +const SSE_PUSH_INTERVAL_MS = 15_000 + +/** SSE 连接最大存活时间(毫秒) */ +const SSE_MAX_LIFETIME_MS = 5 * 60_000 + +export async function GET(_request: NextRequest): Promise { + const encoder = new TextEncoder() + + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + + let intervalTimer: ReturnType | null = null + let lifetimeTimer: ReturnType | null = null + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + const safeEnqueue = (data: string): void => { + if (closed) return + try { + controller.enqueue(encoder.encode(data)) + } catch { + closed = true + } + } + + const pushUpdate = async (): Promise => { + if (closed) return + try { + const [unreadCount, notificationsResult] = await Promise.all([ + getUnreadNotificationCount(ctx.userId), + getNotifications(ctx.userId, { page: 1, pageSize: 10 }), + ]) + safeEnqueue(FORMAT_EVENT({ + type: "update", + unreadCount, + notifications: notificationsResult.items, + })) + } catch { + // 查询失败不关闭连接,等待下次重试 + } + } + + // 连接建立时立即推送一次 + await pushUpdate() + + // 定时推送 + intervalTimer = setInterval(() => { + void pushUpdate() + }, SSE_PUSH_INTERVAL_MS) + + // 连接超时清理(5 分钟后自动关闭) + lifetimeTimer = setTimeout(() => { + safeEnqueue(FORMAT_DONE) + if (intervalTimer) clearInterval(intervalTimer) + if (!closed) { + closed = true + try { + controller.close() + } catch { + // 已关闭 + } + } + }, SSE_MAX_LIFETIME_MS) + }, + cancel() { + if (intervalTimer) clearInterval(intervalTimer) + if (lifetimeTimer) clearTimeout(lifetimeTimer) + closed = true + }, + }) + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }) + } catch (error) { + if (error instanceof PermissionDeniedError) { + return new Response(FORMAT_EVENT({ type: "error", message: "Permission denied" }), { + status: 403, + headers: { "Content-Type": "text/event-stream" }, + }) + } + return new Response(FORMAT_EVENT({ type: "error", message: "Internal error" }), { + status: 500, + headers: { "Content-Type": "text/event-stream" }, + }) + } +} diff --git a/src/modules/announcements/actions.ts b/src/modules/announcements/actions.ts index be4de87..4592b59 100644 --- a/src/modules/announcements/actions.ts +++ b/src/modules/announcements/actions.ts @@ -25,6 +25,9 @@ import { deleteAnnouncementById, publishAnnouncementById, archiveAnnouncementById, + toggleAnnouncementPin, + markAnnouncementAsRead, + getAnnouncementReadStatusForUser, } from "./data-access" import type { GetAnnouncementsParams, Announcement } from "./types" @@ -325,6 +328,68 @@ export async function archiveAnnouncementAction(id: string): Promise> { + try { + await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) + + await toggleAnnouncementPin(id) + revalidatePath("/admin/announcements") + revalidatePath("/announcements") + + void trackEvent({ + event: "announcement.pin_toggled", + targetId: id, + targetType: "announcement", + }) + + return { success: true, message: "Pin status toggled" } + } catch (e) { + return handleActionError(e) + } +} + +// --------------------------------------------------------------------------- +// V2-P2-13d: 公告已读标记 Server Action +// --------------------------------------------------------------------------- + +export async function markAnnouncementAsReadAction(announcementId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ) + + await markAnnouncementAsRead(announcementId, ctx.userId) + + void trackEvent({ + event: "announcement.marked_read", + userId: ctx.userId, + targetId: announcementId, + targetType: "announcement", + }) + + return { success: true, message: "Announcement marked as read" } + } catch (e) { + return handleActionError(e) + } +} + +/** + * 批量获取公告已读状态(用于列表页标记已读/未读)。 + */ +export async function getAnnouncementReadStatusAction( + announcementIds: string[] +): Promise>> { + 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) + } +} + export async function getAnnouncementsAction( params?: GetAnnouncementsParams ): Promise> { diff --git a/src/modules/announcements/components/admin-announcements-view.tsx b/src/modules/announcements/components/admin-announcements-view.tsx index 0171c67..6f1d10b 100644 --- a/src/modules/announcements/components/admin-announcements-view.tsx +++ b/src/modules/announcements/components/admin-announcements-view.tsx @@ -51,7 +51,7 @@ export function AdminAnnouncementsView({ announcements={announcements} canManage initialStatus={initialStatus} - detailHrefBuilder={(id) => `/admin/announcements/${id}`} + detailHrefPrefix="/admin/announcements" /> diff --git a/src/modules/announcements/components/announcement-list.tsx b/src/modules/announcements/components/announcement-list.tsx index 9348400..7a492d0 100644 --- a/src/modules/announcements/components/announcement-list.tsx +++ b/src/modules/announcements/components/announcement-list.tsx @@ -27,18 +27,24 @@ type Filter = "all" | AnnouncementStatus * - Select 切换时更新 URL `?status=`,触发 RSC 重新渲染 * - 父页面根据 `?status=` 查询并传入 `announcements` prop * - 组件不再做客户端二次过滤,避免双重过滤逻辑冗余 + * + * 详情链接构建: + * - `detailHrefPrefix`:推荐方式,适用于 Server Component(前缀 + id 拼接) + * - `detailHrefBuilder`:仅适用于 Client Component 之间的调用 */ export function AnnouncementList({ announcements, canManage, createHref, detailHrefBuilder, + detailHrefPrefix, initialStatus, }: { announcements: Announcement[] canManage?: boolean createHref?: string detailHrefBuilder?: (id: string) => string + detailHrefPrefix?: string initialStatus?: Filter }) { const t = useTranslations("announcements") @@ -59,6 +65,14 @@ export function AnnouncementList({ router.replace(qs ? `?${qs}` : "?") } + // 构建详情链接:优先使用 detailHrefPrefix(Server Component 安全), + // 其次使用 detailHrefBuilder(仅 Client Component 间调用) + const buildDetailHref = (id: string): string | undefined => { + if (detailHrefPrefix) return `${detailHrefPrefix}/${id}` + if (detailHrefBuilder) return detailHrefBuilder(id) + return undefined + } + return (
@@ -101,7 +115,7 @@ export function AnnouncementList({ ))}
diff --git a/src/modules/announcements/data-access.ts b/src/modules/announcements/data-access.ts index c4742ad..2bf72d3 100644 --- a/src/modules/announcements/data-access.ts +++ b/src/modules/announcements/data-access.ts @@ -1,10 +1,11 @@ import "server-only" import { cache } from "react" -import { and, desc, eq, or } from "drizzle-orm" +import { createId } from "@paralleldrive/cuid2" +import { and, count, desc, eq, inArray, or } from "drizzle-orm" import { db } from "@/shared/db" -import { announcements, users } from "@/shared/db/schema" +import { announcements, announcementReads, users } from "@/shared/db/schema" import type { Announcement, AnnouncementInsertData, @@ -30,6 +31,7 @@ const mapRow = ( authorId: string authorName: string | null publishedAt: Date | null + isPinned: boolean createdAt: Date updatedAt: Date } @@ -44,6 +46,7 @@ const mapRow = ( authorId: row.authorId, authorName: row.authorName, publishedAt: toIso(row.publishedAt), + isPinned: row.isPinned, createdAt: toIsoRequired(row.createdAt), updatedAt: toIsoRequired(row.updatedAt), }) @@ -93,13 +96,14 @@ export const getAnnouncements = cache( 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.createdAt)) + .orderBy(desc(announcements.isPinned), desc(announcements.createdAt)) .limit(pageSize) .offset(offset) @@ -121,6 +125,7 @@ export const getAnnouncementById = cache( authorId: announcements.authorId, authorName: users.name, publishedAt: announcements.publishedAt, + isPinned: announcements.isPinned, createdAt: announcements.createdAt, updatedAt: announcements.updatedAt, }) @@ -197,6 +202,94 @@ export async function archiveAnnouncementById(id: string): Promise { .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 层, diff --git a/src/modules/announcements/types.ts b/src/modules/announcements/types.ts index dd5f52f..b65f217 100644 --- a/src/modules/announcements/types.ts +++ b/src/modules/announcements/types.ts @@ -13,6 +13,11 @@ export interface Announcement { authorId: string authorName: string | null publishedAt: string | null + isPinned: boolean + /** V2-P2-13d: 当前用户是否已读(仅用户端查询时填充) */ + isReadByCurrentUser?: boolean + /** V2-P2-13d: 已读人数(仅管理端查询时填充) */ + readCount?: number createdAt: string updatedAt: string } @@ -59,3 +64,11 @@ export interface AnnouncementUpdateData { publishedAt: Date | null updatedAt: Date } + +/** V2-P2-13d: 公告已读回执 */ +export interface AnnouncementRead { + id: string + announcementId: string + userId: string + readAt: string +} diff --git a/src/modules/messaging/actions.ts b/src/modules/messaging/actions.ts index a53b072..4fc6ce0 100644 --- a/src/modules/messaging/actions.ts +++ b/src/modules/messaging/actions.ts @@ -21,6 +21,11 @@ import { deleteMessage, getRecipients, getUnreadMessageCount, + toggleMessageStar, + getMessageDrafts, + createMessageDraft, + updateMessageDraft, + deleteMessageDraft, } from "./data-access" import { getNotificationPreferences, @@ -216,6 +221,110 @@ export async function getUnreadMessageCountAction(): Promise } } +// --------------------------------------------------------------------------- +// V2-P2-13c: 消息星标 Server Actions +// --------------------------------------------------------------------------- + +export async function toggleMessageStarAction(messageId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + + const parsed = MessageIdSchema.safeParse({ messageId }) + if (!parsed.success) { + return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } + } + + await toggleMessageStar(parsed.data.messageId, ctx.userId) + revalidatePath("/messages") + revalidatePath(`/messages/${parsed.data.messageId}`) + + void trackEvent({ + event: "message.star_toggled", + userId: ctx.userId, + targetId: parsed.data.messageId, + targetType: "message", + }) + + return { success: true, message: "Star toggled" } + } catch (e) { + 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" } + } +} + +// --------------------------------------------------------------------------- +// V2-P2-13c: 消息草稿 Server Actions +// --------------------------------------------------------------------------- + +export async function getMessageDraftsAction(): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + const drafts = await getMessageDrafts(ctx.userId) + return { success: true, data: drafts } + } catch (e) { + 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" } + } +} + +export async function saveMessageDraftAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + + const draftId = formData.get("draftId") as string | null + const receiverId = formData.get("receiverId") as string | null + const subject = formData.get("subject") as string | null + const content = formData.get("content") as string | null + const parentMessageId = formData.get("parentMessageId") as string | null + + if (draftId) { + // 更新现有草稿 + await updateMessageDraft(draftId, ctx.userId, { + receiverId: receiverId || null, + subject: subject || null, + content: content || null, + parentMessageId: parentMessageId || null, + }) + return { success: true, message: "Draft saved", data: draftId } + } + + // 创建新草稿 + const id = await createMessageDraft({ + userId: ctx.userId, + receiverId: receiverId || null, + subject: subject || null, + content: content || null, + parentMessageId: parentMessageId || null, + }) + + return { success: true, message: "Draft created", data: id } + } catch (e) { + 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" } + } +} + +export async function deleteMessageDraftAction(draftId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + + await deleteMessageDraft(draftId, ctx.userId) + revalidatePath("/messages/compose") + + return { success: true, message: "Draft deleted" } + } catch (e) { + 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" } + } +} + // --------------------------------------------------------------------------- // 通知相关 Server Actions(getNotificationsAction / markNotificationAsReadAction / // markAllNotificationsAsReadAction / getUnreadNotificationCountAction)已迁移至 diff --git a/src/modules/messaging/components/unread-message-badge.tsx b/src/modules/messaging/components/unread-message-badge.tsx index 576c99d..8bc5ff9 100644 --- a/src/modules/messaging/components/unread-message-badge.tsx +++ b/src/modules/messaging/components/unread-message-badge.tsx @@ -6,15 +6,18 @@ import { Badge } from "@/shared/components/ui/badge" import { getUnreadMessageCountAction } from "../actions" -/** 未读消息计数轮询间隔(毫秒) */ -const POLL_INTERVAL_MS = 60_000 - /** * 未读消息计数徽章 * * 在侧边栏 Messages 导航项旁显示未读私信数。 * 每 POLL_INTERVAL_MS 毫秒轮询一次以保持计数更新。 + * + * 注意:当前 SSE 端点(/api/notifications/stream)仅推送通知数据, + * 不包含私信未读数,因此本组件仍使用轮询模式。轮询间隔与通知组件 + * 保持一致(30 秒),未来可扩展 SSE 端点同时推送消息未读数以实现实时更新。 */ +const POLL_INTERVAL_MS = 30_000 + export function UnreadMessageBadge() { const [count, setCount] = useState(0) diff --git a/src/modules/messaging/data-access.ts b/src/modules/messaging/data-access.ts index b093743..196628b 100644 --- a/src/modules/messaging/data-access.ts +++ b/src/modules/messaging/data-access.ts @@ -21,6 +21,7 @@ import { and, count, desc, eq, inArray, isNull, like, or, type SQL } from "drizz import { db } from "@/shared/db" import { messages, + messageDrafts, users, } from "@/shared/db/schema" import { @@ -36,6 +37,9 @@ import type { GetMessagesParams, CreateMessageInput, RecipientOption, + MessageDraft, + CreateMessageDraftInput, + UpdateMessageDraftInput, } from "./types" import type { PaginatedResult } from "@/modules/notifications/types" @@ -50,6 +54,7 @@ interface MessageRow { subject: string | null content: string isRead: boolean + isStarred: boolean readAt: Date | null parentMessageId: string | null createdAt: Date @@ -74,6 +79,7 @@ const mapMessage = (r: MessageRow, nameMap: Map): Message => ({ subject: r.subject, content: r.content, isRead: r.isRead, + isStarred: r.isStarred, readAt: toIso(r.readAt), parentMessageId: r.parentMessageId, createdAt: toIsoRequired(r.createdAt), @@ -108,6 +114,11 @@ export const getMessages = cache( if (kwCond) conds.push(kwCond) } + // V2-P2-13c: 仅返回星标消息 + if (params.starredOnly) { + conds.push(eq(messages.isStarred, true)) + } + const where = and(...conds) const [rows, [totalRow]] = await Promise.all([ db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(pageSize).offset(offset), @@ -193,6 +204,22 @@ export async function deleteMessage(id: string, userId: string): Promise { }) } +export async function toggleMessageStar(id: string, userId: string): Promise { + // 查询当前星标状态 + const [row] = await db + .select({ isStarred: messages.isStarred }) + .from(messages) + .where(and(eq(messages.id, id), eq(messages.receiverId, userId))) + .limit(1) + + if (!row) return + + await db + .update(messages) + .set({ isStarred: !row.isStarred }) + .where(and(eq(messages.id, id), eq(messages.receiverId, userId))) +} + export const getUnreadMessageCount = cache(async (userId: string): Promise => { const [row] = await db .select({ value: count() }) @@ -288,3 +315,90 @@ export async function getMessageDetailPageData( return message } + +// --------------------------------------------------------------------------- +// V2-P2-13c: 消息草稿 CRUD(message_drafts 表) +// --------------------------------------------------------------------------- + +const mapDraft = ( + r: { + id: string + userId: string + receiverId: string | null + subject: string | null + content: string | null + parentMessageId: string | null + createdAt: Date + updatedAt: Date + }, + nameMap: Map +): MessageDraft => ({ + id: r.id, + userId: r.userId, + receiverId: r.receiverId, + receiverName: r.receiverId ? (nameMap.get(r.receiverId) ?? null) : null, + subject: r.subject, + content: r.content, + parentMessageId: r.parentMessageId, + createdAt: toIsoRequired(r.createdAt), + updatedAt: toIsoRequired(r.updatedAt), +}) + +export const getMessageDrafts = cache( + async (userId: string): Promise => { + const rows = await db + .select() + .from(messageDrafts) + .where(eq(messageDrafts.userId, userId)) + .orderBy(desc(messageDrafts.updatedAt)) + + const receiverIds = rows.map((r) => r.receiverId).filter((id): id is string => id !== null) + const nameMap = await resolveUserNames(receiverIds) + + return rows.map((r) => mapDraft(r, nameMap)) + } +) + +export async function createMessageDraft(data: CreateMessageDraftInput): Promise { + const id = createId() + await db.insert(messageDrafts).values({ + id, + userId: data.userId, + receiverId: data.receiverId ?? null, + subject: data.subject ?? null, + content: data.content ?? null, + parentMessageId: data.parentMessageId ?? null, + }) + return id +} + +export async function updateMessageDraft(id: string, userId: string, data: UpdateMessageDraftInput): Promise { + await db + .update(messageDrafts) + .set({ + ...(data.receiverId !== undefined && { receiverId: data.receiverId }), + ...(data.subject !== undefined && { subject: data.subject }), + ...(data.content !== undefined && { content: data.content }), + ...(data.parentMessageId !== undefined && { parentMessageId: data.parentMessageId }), + }) + .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) +} + +export async function deleteMessageDraft(id: string, userId: string): Promise { + await db + .delete(messageDrafts) + .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) +} + +export async function getMessageDraftById(id: string, userId: string): Promise { + const [row] = await db + .select() + .from(messageDrafts) + .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) + .limit(1) + + if (!row) return null + + const nameMap = row.receiverId ? await resolveUserNames([row.receiverId]) : new Map() + return mapDraft(row, nameMap) +} diff --git a/src/modules/messaging/schema.test.ts b/src/modules/messaging/schema.test.ts new file mode 100644 index 0000000..4803405 --- /dev/null +++ b/src/modules/messaging/schema.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it } from "vitest" + +import { + SendMessageSchema, + MessageIdSchema, + UpdateNotificationPreferencesSchema, +} from "./schema" + +describe("SendMessageSchema", () => { + it("should parse valid input with all fields", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + subject: "Hello", + content: "World", + parentMessageId: "msg-1", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.receiverId).toBe("user-1") + expect(result.data.subject).toBe("Hello") + expect(result.data.content).toBe("World") + expect(result.data.parentMessageId).toBe("msg-1") + } + }) + + it("should parse valid input with only required fields", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + content: "Hello", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.receiverId).toBe("user-1") + expect(result.data.content).toBe("Hello") + expect(result.data.subject).toBeNull() + expect(result.data.parentMessageId).toBeNull() + } + }) + + it("should transform empty subject string to null", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + subject: "", + content: "Hello", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.subject).toBeNull() + } + }) + + it("should transform empty parentMessageId string to null", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + content: "Hello", + parentMessageId: "", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.parentMessageId).toBeNull() + } + }) + + it("should accept nullable subject", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + subject: null, + content: "Hello", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.subject).toBeNull() + } + }) + + it("should accept nullable parentMessageId", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + content: "Hello", + parentMessageId: null, + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.parentMessageId).toBeNull() + } + }) + + it("should reject empty receiverId", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "", + content: "Hello", + }) + + expect(result.success).toBe(false) + }) + + it("should reject whitespace-only receiverId after trim", () => { + const result = SendMessageSchema.safeParse({ + receiverId: " ", + content: "Hello", + }) + + expect(result.success).toBe(false) + }) + + it("should reject empty content", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + content: "", + }) + + expect(result.success).toBe(false) + }) + + it("should reject whitespace-only content after trim", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + content: " ", + }) + + expect(result.success).toBe(false) + }) + + it("should reject missing receiverId", () => { + const result = SendMessageSchema.safeParse({ + content: "Hello", + }) + + expect(result.success).toBe(false) + }) + + it("should reject missing content", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + }) + + expect(result.success).toBe(false) + }) + + it("should reject subject exceeding 255 chars", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + subject: "a".repeat(256), + content: "Hello", + }) + + expect(result.success).toBe(false) + }) + + it("should accept subject of exactly 255 chars", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + subject: "a".repeat(255), + content: "Hello", + }) + + expect(result.success).toBe(true) + }) + + it("should trim whitespace from receiverId", () => { + const result = SendMessageSchema.safeParse({ + receiverId: " user-1 ", + content: "Hello", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.receiverId).toBe("user-1") + } + }) + + it("should trim whitespace from content", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + content: " Hello ", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.content).toBe("Hello") + } + }) + + it("should trim whitespace from subject", () => { + const result = SendMessageSchema.safeParse({ + receiverId: "user-1", + subject: " Hello ", + content: "Hello", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.subject).toBe("Hello") + } + }) +}) + +describe("MessageIdSchema", () => { + it("should parse valid messageId", () => { + const result = MessageIdSchema.safeParse({ messageId: "msg-1" }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.messageId).toBe("msg-1") + } + }) + + it("should reject empty messageId", () => { + const result = MessageIdSchema.safeParse({ messageId: "" }) + + expect(result.success).toBe(false) + }) + + it("should reject whitespace-only messageId after trim", () => { + const result = MessageIdSchema.safeParse({ messageId: " " }) + + expect(result.success).toBe(false) + }) + + it("should reject missing messageId", () => { + const result = MessageIdSchema.safeParse({}) + + expect(result.success).toBe(false) + }) + + it("should trim whitespace from messageId", () => { + const result = MessageIdSchema.safeParse({ messageId: " msg-1 " }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.messageId).toBe("msg-1") + } + }) +}) + +describe("UpdateNotificationPreferencesSchema", () => { + const validInput = { + emailEnabled: true, + smsEnabled: false, + pushEnabled: true, + homeworkNotifications: true, + gradeNotifications: true, + announcementNotifications: true, + messageNotifications: true, + attendanceNotifications: false, + quietHoursEnabled: false, + } + + it("should parse valid input without quiet hours times", () => { + const result = UpdateNotificationPreferencesSchema.safeParse(validInput) + + expect(result.success).toBe(true) + }) + + it("should parse valid input with quiet hours times", () => { + const result = UpdateNotificationPreferencesSchema.safeParse({ + ...validInput, + quietHoursStart: "22:00", + quietHoursEnd: "07:00", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.quietHoursStart).toBe("22:00") + expect(result.data.quietHoursEnd).toBe("07:00") + } + }) + + it("should accept null quiet hours times", () => { + const result = UpdateNotificationPreferencesSchema.safeParse({ + ...validInput, + quietHoursStart: null, + quietHoursEnd: null, + }) + + expect(result.success).toBe(true) + }) + + it("should accept undefined quiet hours times", () => { + const result = UpdateNotificationPreferencesSchema.safeParse(validInput) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.quietHoursStart).toBeUndefined() + expect(result.data.quietHoursEnd).toBeUndefined() + } + }) + + it("should reject invalid time format for quietHoursStart", () => { + const result = UpdateNotificationPreferencesSchema.safeParse({ + ...validInput, + quietHoursStart: "25:00", + }) + + expect(result.success).toBe(false) + }) + + it("should reject invalid time format for quietHoursEnd", () => { + const result = UpdateNotificationPreferencesSchema.safeParse({ + ...validInput, + quietHoursEnd: "12:60", + }) + + expect(result.success).toBe(false) + }) + + it("should reject non-time string for quietHoursStart", () => { + const result = UpdateNotificationPreferencesSchema.safeParse({ + ...validInput, + quietHoursStart: "not-a-time", + }) + + expect(result.success).toBe(false) + }) + + it("should accept boundary time 00:00", () => { + const result = UpdateNotificationPreferencesSchema.safeParse({ + ...validInput, + quietHoursStart: "00:00", + }) + + expect(result.success).toBe(true) + }) + + it("should accept boundary time 23:59", () => { + const result = UpdateNotificationPreferencesSchema.safeParse({ + ...validInput, + quietHoursEnd: "23:59", + }) + + expect(result.success).toBe(true) + }) + + it("should reject non-boolean emailEnabled", () => { + const result = UpdateNotificationPreferencesSchema.safeParse({ + ...validInput, + emailEnabled: "yes", + }) + + expect(result.success).toBe(false) + }) + + it("should reject non-boolean smsEnabled", () => { + const result = UpdateNotificationPreferencesSchema.safeParse({ + ...validInput, + smsEnabled: 1, + }) + + expect(result.success).toBe(false) + }) + + it("should reject missing required boolean field", () => { + const inputWithoutEmail = { + smsEnabled: false, + pushEnabled: true, + homeworkNotifications: true, + gradeNotifications: true, + announcementNotifications: true, + messageNotifications: true, + attendanceNotifications: false, + quietHoursEnabled: false, + } + const result = UpdateNotificationPreferencesSchema.safeParse(inputWithoutEmail) + + expect(result.success).toBe(false) + }) +}) diff --git a/src/modules/messaging/types.ts b/src/modules/messaging/types.ts index f5f0bad..a5b4a32 100644 --- a/src/modules/messaging/types.ts +++ b/src/modules/messaging/types.ts @@ -17,6 +17,7 @@ export interface Message { subject: string | null content: string isRead: boolean + isStarred: boolean readAt: string | null parentMessageId: string | null createdAt: string @@ -34,6 +35,8 @@ export interface GetMessagesParams { page?: number pageSize?: number keyword?: string + /** V2-P2-13c: 仅返回星标消息 */ + starredOnly?: boolean } export interface CreateMessageInput { @@ -50,3 +53,31 @@ export interface RecipientOption { email: string role?: string } + +/** V2-P2-13c: 消息草稿 */ +export interface MessageDraft { + id: string + userId: string + receiverId: string | null + receiverName: string | null + subject: string | null + content: string | null + parentMessageId: string | null + createdAt: string + updatedAt: string +} + +export interface CreateMessageDraftInput { + userId: string + receiverId?: string | null + subject?: string | null + content?: string | null + parentMessageId?: string | null +} + +export interface UpdateMessageDraftInput { + receiverId?: string | null + subject?: string | null + content?: string | null + parentMessageId?: string | null +} diff --git a/src/modules/notifications/actions.ts b/src/modules/notifications/actions.ts index 8ae4630..696310c 100644 --- a/src/modules/notifications/actions.ts +++ b/src/modules/notifications/actions.ts @@ -28,6 +28,7 @@ import { markNotificationAsRead, markAllNotificationsAsRead, getUnreadNotificationCount, + archiveNotification, } from "./data-access" import type { NotificationPayload, ChannelSendResult, Notification } from "./types" @@ -265,3 +266,35 @@ export async function markAllNotificationsAsReadAction(): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + + const parsed = NotificationIdSchema.safeParse(notificationId) + if (!parsed.success) { + return { success: false, message: "Invalid notification id" } + } + + await archiveNotification(parsed.data, ctx.userId) + revalidatePath("/messages") + + void trackEvent({ + event: "notification.archived", + userId: ctx.userId, + targetId: parsed.data, + targetType: "notification", + }) + + return { success: true, message: "Notification archived" } + } catch (e) { + 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" } + } +} diff --git a/src/modules/notifications/channels/in-app-channel.test.ts b/src/modules/notifications/channels/in-app-channel.test.ts new file mode 100644 index 0000000..a6576ae --- /dev/null +++ b/src/modules/notifications/channels/in-app-channel.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + createNotification: vi.fn(), +})) + +vi.mock("../data-access", () => ({ + createNotification: mocks.createNotification, +})) + +import { createInAppSender } from "./in-app-channel" +import type { NotificationPayload } from "../types" +import type { ChannelRecipient } from "./types" + +describe("InAppChannelSender", () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const sender = createInAppSender() + const recipient: ChannelRecipient = { userId: "user-1" } + + it("should map info type to message notification type", async () => { + mocks.createNotification.mockResolvedValue("notif-1") + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + } + + await sender.send(payload, recipient) + + expect(mocks.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ type: "message" }) + ) + }) + + it("should map success type to message notification type", async () => { + mocks.createNotification.mockResolvedValue("notif-1") + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "success", + } + + await sender.send(payload, recipient) + + expect(mocks.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ type: "message" }) + ) + }) + + it("should map warning type to announcement notification type", async () => { + mocks.createNotification.mockResolvedValue("notif-1") + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "warning", + } + + await sender.send(payload, recipient) + + expect(mocks.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ type: "announcement" }) + ) + }) + + it("should map error type to grade notification type", async () => { + mocks.createNotification.mockResolvedValue("notif-1") + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "error", + } + + await sender.send(payload, recipient) + + expect(mocks.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ type: "grade" }) + ) + }) + + it("should return success result with messageId", async () => { + mocks.createNotification.mockResolvedValue("notif-123") + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + } + + const result = await sender.send(payload, recipient) + + expect(result.channel).toBe("in_app") + expect(result.success).toBe(true) + expect(result.messageId).toBe("notif-123") + }) + + it("should return failure when recipient userId does not match payload userId", async () => { + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + } + const wrongRecipient: ChannelRecipient = { userId: "user-2" } + + const result = await sender.send(payload, wrongRecipient) + + expect(result.success).toBe(false) + expect(result.error).toContain("does not match") + }) + + it("should return failure when createNotification throws", async () => { + mocks.createNotification.mockRejectedValue(new Error("DB error")) + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + } + + const result = await sender.send(payload, recipient) + + expect(result.success).toBe(false) + expect(result.error).toBe("DB error") + }) + + it("should use actionUrl as link when provided", async () => { + mocks.createNotification.mockResolvedValue("notif-1") + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + actionUrl: "/messages/123", + } + + await sender.send(payload, recipient) + + expect(mocks.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ link: "/messages/123" }) + ) + }) + + it("should use null as link when actionUrl not provided", async () => { + mocks.createNotification.mockResolvedValue("notif-1") + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + } + + await sender.send(payload, recipient) + + expect(mocks.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ link: null }) + ) + }) +}) diff --git a/src/modules/notifications/components/notification-dropdown.tsx b/src/modules/notifications/components/notification-dropdown.tsx index aa5ed65..73f57c4 100644 --- a/src/modules/notifications/components/notification-dropdown.tsx +++ b/src/modules/notifications/components/notification-dropdown.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" @@ -20,12 +20,12 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area" import { cn, formatDate } from "@/shared/lib/utils" import { - getNotificationsAction, - getUnreadNotificationCountAction, markAllNotificationsAsReadAction, markNotificationAsReadAction, } from "../actions" -import type { Notification, NotificationType } from "../types" +import { useDesktopNotifications } from "../hooks/use-desktop-notifications" +import { useNotificationStream } from "../hooks/use-notification-stream" +import type { NotificationType } from "../types" const TYPE_ICON: Record = { message: MessageSquare, @@ -34,55 +34,52 @@ const TYPE_ICON: Record = { grade: GraduationCap, } -/** 通知下拉菜单轮询间隔(毫秒) */ -const POLL_INTERVAL_MS = 30_000 +/** 轮询降级间隔(毫秒) */ +const FALLBACK_POLL_INTERVAL_MS = 30_000 export function NotificationDropdown() { const t = useTranslations("notifications") const router = useRouter() - const [notifications, setNotifications] = useState([]) - const [unreadCount, setUnreadCount] = useState(0) const [open, setOpen] = useState(false) + // 使用 SSE 实时推送(自动降级为轮询) + const { unreadCount, notifications } = useNotificationStream({ + fallbackPollInterval: FALLBACK_POLL_INTERVAL_MS, + }) + + // 桌面推送通知(用户切换标签页时仍可收到提醒) + const { showNotification } = useDesktopNotifications({ enabled: true }) + + // 监听新通知并触发桌面推送(使用 ref 避免 setState in effect) + const prevNotificationIdsRef = useRef>(new Set()) + useEffect(() => { - let active = true + if (notifications.length === 0) return - const fetchNotifications = async () => { - const res = await getNotificationsAction({ pageSize: 10 }) - if (!active) return - if (res.success && res.data) { - setNotifications(res.data.items) - } + const currentIds = new Set(notifications.map((n) => n.id)) + const prevIds = prevNotificationIdsRef.current + // 首次加载不触发桌面推送(避免页面加载时批量推送) + if (prevIds.size === 0) { + prevNotificationIdsRef.current = currentIds + return } - const fetchUnreadCount = async () => { - const res = await getUnreadNotificationCountAction() - if (!active) return - if (res.success && typeof res.data === "number") { - setUnreadCount(res.data) - } + // 找出新增的通知 + const newNotifications = notifications.filter( + (n) => !prevIds.has(n.id) && !n.isRead + ) + + // 为每个新通知触发桌面推送 + for (const n of newNotifications) { + showNotification(n) } - void fetchNotifications() - void fetchUnreadCount() - - // 每 POLL_INTERVAL_MS 毫秒轮询刷新通知和未读计数 - const timer = setInterval(() => { - void fetchNotifications() - void fetchUnreadCount() - }, POLL_INTERVAL_MS) - - return () => { - active = false - clearInterval(timer) - } - }, []) + prevNotificationIdsRef.current = currentIds + }, [notifications, showNotification]) const handleMarkAllRead = async () => { const res = await markAllNotificationsAsReadAction() if (res.success) { - setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))) - setUnreadCount(0) router.refresh() } } @@ -90,10 +87,6 @@ export function NotificationDropdown() { const handleMarkRead = async (id: string) => { const res = await markNotificationAsReadAction(id) if (res.success) { - setNotifications((prev) => - prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)) - ) - setUnreadCount((c) => Math.max(0, c - 1)) router.refresh() } } diff --git a/src/modules/notifications/components/notification-list.tsx b/src/modules/notifications/components/notification-list.tsx index 1d7555a..705bb33 100644 --- a/src/modules/notifications/components/notification-list.tsx +++ b/src/modules/notifications/components/notification-list.tsx @@ -13,8 +13,8 @@ import { Card, CardContent } from "@/shared/components/ui/card" import { EmptyState } from "@/shared/components/ui/empty-state" import { cn, formatDate } from "@/shared/lib/utils" -import { markAllNotificationsAsReadAction, markNotificationAsReadAction } from "../actions" -import type { Notification, NotificationType } from "../types" +import { markAllNotificationsAsReadAction, markNotificationAsReadAction, archiveNotificationAction } from "../actions" +import type { Notification, NotificationType, NotificationPriority } from "../types" const TYPE_ICON: Record = { message: MessageSquare, @@ -23,12 +23,24 @@ const TYPE_ICON: Record = { grade: GraduationCap, } +const PRIORITY_COLOR: Record = { + low: "bg-muted text-muted-foreground", + normal: "bg-blue-500/10 text-blue-700 dark:text-blue-400", + high: "bg-orange-500/10 text-orange-700 dark:text-orange-400", + urgent: "bg-red-500/10 text-red-700 dark:text-red-400", +} + export function NotificationList({ notifications }: { notifications: Notification[] }) { const t = useTranslations("notifications") const router = useRouter() const [isWorking, setIsWorking] = useState(false) + const [filterType, setFilterType] = useState("all") const hasUnread = notifications.some((n) => !n.isRead) + const filteredNotifications = filterType === "all" + ? notifications + : notifications.filter((n) => n.type === filterType) + const handleMarkAllRead = async () => { setIsWorking(true) try { @@ -57,6 +69,17 @@ export function NotificationList({ notifications }: { notifications: Notificatio } } + const handleArchive = async (id: string) => { + try { + const res = await archiveNotificationAction(id) + if (res.success) { + router.refresh() + } + } catch { + toast.error(t("messages.archiveFailed")) + } + } + return (
@@ -72,16 +95,36 @@ export function NotificationList({ notifications }: { notifications: Notificatio ) : null}
- {notifications.length === 0 ? ( +
+ + {(Object.keys(TYPE_ICON) as NotificationType[]).map((type) => ( + + ))} +
+ + {filteredNotifications.length === 0 ? ( ) : (
- {notifications.map((n) => { + {filteredNotifications.map((n) => { const Icon = TYPE_ICON[n.type] ?? Bell return ( {!n.isRead ? {t("status.new")} : null} + {n.priority !== "normal" ? ( + + {t(`priority.${n.priority}`)} + + ) : null}
{n.content ? (

@@ -119,6 +167,14 @@ export function NotificationList({ notifications }: { notifications: Notificatio {t("actions.markRead")} ) : null} + {n.link ? ( {t("actions.view")} diff --git a/src/modules/notifications/data-access.ts b/src/modules/notifications/data-access.ts index 6f89b33..26a85fe 100644 --- a/src/modules/notifications/data-access.ts +++ b/src/modules/notifications/data-access.ts @@ -7,7 +7,7 @@ import "server-only" * - createNotification: 创建站内通知记录(message_notifications 表) * - getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD * - getUserContactInfo: 获取用户联系方式(手机/邮箱,用于渠道发送) - * - logNotificationSend: 记录发送日志(当前项目无 notification_logs 表,使用 console 输出) + * - logNotificationSend: 记录发送日志到 notification_logs 表(DB 写入失败时降级为 console) * * 表所有权: * - message_notifications(由 notifications 模块统一管理,P0-4 / P1-5 修复后从 messaging 迁移) @@ -22,13 +22,14 @@ import { createId } from "@paralleldrive/cuid2" import { and, count, desc, eq } from "drizzle-orm" import { db } from "@/shared/db" -import { messageNotifications, users } from "@/shared/db/schema" +import { messageNotifications, notificationLogs, users } from "@/shared/db/schema" import type { ChannelRecipient } from "./channels/types" import type { ChannelSendResult, CreateNotificationInput, GetNotificationsParams, Notification, + NotificationPriority, NotificationType, PaginatedResult, } from "./types" @@ -41,6 +42,12 @@ const isNotificationType = (v: unknown): v is NotificationType => const toNotificationType = (v: string): NotificationType => isNotificationType(v) ? v : "message" +const isNotificationPriority = (v: unknown): v is NotificationPriority => + v === "low" || v === "normal" || v === "high" || v === "urgent" + +const toNotificationPriority = (v: string): NotificationPriority => + isNotificationPriority(v) ? v : "normal" + interface NotificationRow { id: string userId: string @@ -49,6 +56,8 @@ interface NotificationRow { content: string | null link: string | null isRead: boolean + priority: string + isArchived: boolean createdAt: Date } @@ -60,6 +69,8 @@ const mapNotification = (r: NotificationRow): Notification => ({ content: r.content, link: r.link, isRead: r.isRead, + priority: toNotificationPriority(r.priority), + isArchived: r.isArchived, createdAt: toIsoRequired(r.createdAt), }) @@ -74,6 +85,11 @@ export const getNotifications = cache( const offset = (page - 1) * pageSize const conds = [eq(messageNotifications.userId, userId)] if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false)) + // V2-P2-13b: 默认仅返回未归档通知 + const unarchivedOnly = params?.unarchivedOnly ?? true + if (unarchivedOnly) conds.push(eq(messageNotifications.isArchived, false)) + // V2-P2-13b: 按优先级筛选 + if (params?.priority) conds.push(eq(messageNotifications.priority, params.priority)) const where = and(...conds) const [rows, [totalRow]] = await Promise.all([ @@ -94,6 +110,7 @@ export async function createNotification(data: CreateNotificationInput): Promise title: data.title, content: data.content ?? null, link: data.link ?? null, + priority: data.priority ?? "normal", }) return id } @@ -112,6 +129,20 @@ export async function markAllNotificationsAsRead(userId: string): Promise .where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false))) } +export async function archiveNotification(id: string, userId: string): Promise { + await db + .update(messageNotifications) + .set({ isArchived: true }) + .where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId))) +} + +export async function unarchiveNotification(id: string, userId: string): Promise { + await db + .update(messageNotifications) + .set({ isArchived: false }) + .where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId))) +} + export const getUnreadNotificationCount = cache(async (userId: string): Promise => { const [row] = await db .select({ value: count() }) @@ -159,24 +190,54 @@ export const getUserContactInfo = cache( // --------------------------------------------------------------------------- /** - * 记录通知发送日志。 + * 记录通知发送日志到数据库(notification_logs 表)。 * - * 当前项目无 notification_logs 表,使用 console.info 输出。 - * 未来新增 notification_logs 表后,可在此处写入 DB。 + * 持久化日志用于: + * - 通知发送失败告警与排查 + * - 通知送达率统计 + * - 渠道健康度监控 + * + * DB 写入失败时降级为 console.info,不阻塞通知发送流程。 */ -export function logNotificationSend(result: ChannelSendResult): void { - const status = result.success ? "OK" : "FAIL" +export async function logNotificationSend( + result: ChannelSendResult, + payload?: { userId: string; title: string } +): Promise { + const status = result.success ? "success" : "failure" const errorPart = result.error ? ` error="${result.error}"` : "" + + // 始终输出 console 日志(便于开发调试) console.info( - `[NotificationLog] ${status} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}` + `[NotificationLog] ${result.success ? "OK" : "FAIL"} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}` ) + + // 持久化到 DB(需要 payload 提供 userId 和 title) + if (payload) { + try { + const logId = createId() + await db.insert(notificationLogs).values({ + id: logId, + userId: payload.userId, + title: payload.title, + channel: result.channel, + status, + messageId: result.messageId ?? null, + error: result.error ?? null, + sentAt: result.sentAt, + }) + } catch (dbError) { + // DB 写入失败不阻塞通知流程,仅记录错误 + console.error("[NotificationLog] Failed to persist log:", dbError) + } + } } /** * 批量记录发送日志。 */ -export function logNotificationSendBatch(results: ChannelSendResult[]): void { - for (const result of results) { - logNotificationSend(result) - } +export async function logNotificationSendBatch( + results: ChannelSendResult[], + payload?: { userId: string; title: string } +): Promise { + await Promise.all(results.map((result) => logNotificationSend(result, payload))) } diff --git a/src/modules/notifications/dispatcher.test.ts b/src/modules/notifications/dispatcher.test.ts new file mode 100644 index 0000000..c55df46 --- /dev/null +++ b/src/modules/notifications/dispatcher.test.ts @@ -0,0 +1,251 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + getNotificationPreferences: vi.fn(), + getUserContactInfo: vi.fn(), + logNotificationSendBatch: vi.fn(), + createNotification: vi.fn(), + inAppSend: vi.fn(), + smsSend: vi.fn(), + wechatSend: vi.fn(), + emailSend: vi.fn(), +})) + +vi.mock("./data-access", () => ({ + getUserContactInfo: mocks.getUserContactInfo, + logNotificationSendBatch: mocks.logNotificationSendBatch, + createNotification: mocks.createNotification, +})) + +vi.mock("./preferences", () => ({ + getNotificationPreferences: mocks.getNotificationPreferences, +})) + +vi.mock("./channels/sms-channel", () => ({ + createSmsSender: () => ({ + channel: "sms", + send: mocks.smsSend, + sendBatch: vi.fn(), + }), +})) + +vi.mock("./channels/wechat-channel", () => ({ + createWechatSender: () => ({ + channel: "wechat", + send: mocks.wechatSend, + sendBatch: vi.fn(), + }), +})) + +vi.mock("./channels/email-channel", () => ({ + createEmailSender: () => ({ + channel: "email", + send: mocks.emailSend, + sendBatch: vi.fn(), + }), +})) + +vi.mock("./channels/in-app-channel", () => ({ + createInAppSender: () => ({ + channel: "in_app", + send: mocks.inAppSend, + sendBatch: vi.fn(), + }), +})) + +import { sendNotification } from "./dispatcher" +import type { NotificationPayload } from "./types" + +describe("sendNotification", () => { + beforeEach(() => { + // mockReset (from vitest config) clears implementations before beforeEach. + // Re-establish channel send implementations so the cached sender registry + // (module-level singleton in dispatcher.ts) keeps working across tests. + mocks.inAppSend.mockImplementation(async (payload: NotificationPayload) => { + const id = await mocks.createNotification({ + userId: payload.userId, + type: "message", + title: payload.title, + content: payload.content, + link: payload.actionUrl ?? null, + }) + return { + channel: "in_app" as const, + success: true, + messageId: id, + sentAt: new Date(), + } + }) + mocks.smsSend.mockResolvedValue({ + channel: "sms", + success: true, + sentAt: new Date(), + }) + mocks.wechatSend.mockResolvedValue({ + channel: "wechat", + success: true, + sentAt: new Date(), + }) + mocks.emailSend.mockResolvedValue({ + channel: "email", + success: true, + sentAt: new Date(), + }) + }) + + it("should select in_app channel when pushEnabled is true and no contact info", async () => { + mocks.getNotificationPreferences.mockResolvedValue({ + smsEnabled: false, + emailEnabled: false, + pushEnabled: true, + }) + mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" }) + mocks.createNotification.mockResolvedValue("notif-1") + mocks.logNotificationSendBatch.mockResolvedValue(undefined) + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test notification", + content: "Test content", + type: "info", + } + + const results = await sendNotification(payload) + + expect(results).toHaveLength(1) + expect(results[0].channel).toBe("in_app") + expect(results[0].success).toBe(true) + expect(mocks.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + title: "Test notification", + }) + ) + }) + + it("should select sms channel when smsEnabled and phone provided", async () => { + mocks.getNotificationPreferences.mockResolvedValue({ + smsEnabled: true, + emailEnabled: false, + pushEnabled: true, + }) + mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", phone: "13800138000" }) + mocks.createNotification.mockResolvedValue("notif-1") + mocks.logNotificationSendBatch.mockResolvedValue(undefined) + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + } + + const results = await sendNotification(payload) + + expect(results).toHaveLength(2) + const channels = results.map((r) => r.channel) + expect(channels).toContain("in_app") + expect(channels).toContain("sms") + }) + + it("should select email channel when emailEnabled and email provided", async () => { + mocks.getNotificationPreferences.mockResolvedValue({ + smsEnabled: false, + emailEnabled: true, + pushEnabled: true, + }) + mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", email: "test@example.com" }) + mocks.createNotification.mockResolvedValue("notif-1") + mocks.logNotificationSendBatch.mockResolvedValue(undefined) + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + } + + const results = await sendNotification(payload) + + expect(results).toHaveLength(2) + const channels = results.map((r) => r.channel) + expect(channels).toContain("in_app") + expect(channels).toContain("email") + }) + + it("should fallback to in_app when all channels disabled", async () => { + mocks.getNotificationPreferences.mockResolvedValue({ + smsEnabled: false, + emailEnabled: false, + pushEnabled: false, + }) + mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" }) + mocks.createNotification.mockResolvedValue("notif-1") + mocks.logNotificationSendBatch.mockResolvedValue(undefined) + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + } + + const results = await sendNotification(payload) + + // pushEnabled false 时,兜底逻辑应至少发 in_app + expect(results).toHaveLength(1) + expect(results[0].channel).toBe("in_app") + }) + + it("should select wechat channel when pushEnabled and wechatOpenId provided", async () => { + mocks.getNotificationPreferences.mockResolvedValue({ + smsEnabled: false, + emailEnabled: false, + pushEnabled: true, + }) + mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", wechatOpenId: "wx-open-id" }) + mocks.createNotification.mockResolvedValue("notif-1") + mocks.logNotificationSendBatch.mockResolvedValue(undefined) + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + } + + const results = await sendNotification(payload) + + expect(results).toHaveLength(2) + const channels = results.map((r) => r.channel) + expect(channels).toContain("in_app") + expect(channels).toContain("wechat") + }) + + it("should call logNotificationSendBatch with results", async () => { + mocks.getNotificationPreferences.mockResolvedValue({ + smsEnabled: false, + emailEnabled: false, + pushEnabled: true, + }) + mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" }) + mocks.createNotification.mockResolvedValue("notif-1") + mocks.logNotificationSendBatch.mockResolvedValue(undefined) + + const payload: NotificationPayload = { + userId: "user-1", + title: "Test", + content: "Content", + type: "info", + } + + await sendNotification(payload) + + expect(mocks.logNotificationSendBatch).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ channel: "in_app", success: true }), + ]), + { userId: "user-1", title: "Test" } + ) + }) +}) diff --git a/src/modules/notifications/dispatcher.ts b/src/modules/notifications/dispatcher.ts index 3b71561..6f3de68 100644 --- a/src/modules/notifications/dispatcher.ts +++ b/src/modules/notifications/dispatcher.ts @@ -124,8 +124,8 @@ export async function sendNotification( }) ) - // 记录发送日志 - logNotificationSendBatch(results) + // 记录发送日志(传入 payload 用于持久化) + await logNotificationSendBatch(results, { userId: payload.userId, title: payload.title }) return results } diff --git a/src/modules/notifications/hooks/use-desktop-notifications.ts b/src/modules/notifications/hooks/use-desktop-notifications.ts new file mode 100644 index 0000000..c8e0d0d --- /dev/null +++ b/src/modules/notifications/hooks/use-desktop-notifications.ts @@ -0,0 +1,121 @@ +"use client" + +import { useEffect, useRef, useCallback, useState } from "react" + +import type { Notification } from "../types" + +interface UseDesktopNotificationsOptions { + /** 是否启用桌面推送(默认 false) */ + enabled?: boolean + /** 通知点击时的跳转回调 */ + onClick?: (notification: Notification) => void +} + +interface UseDesktopNotificationsResult { + /** 当前权限状态 */ + permission: NotificationPermission | "unsupported" + /** 是否已授权 */ + isGranted: boolean + /** 请求权限 */ + requestPermission: () => Promise + /** 发送桌面通知 */ + showNotification: (notification: Notification) => void +} + +/** + * 桌面通知 Hook + * + * 使用浏览器 Notification API 发送桌面推送通知, + * 当用户不在页面标签页时仍可收到通知提醒。 + * + * 使用场景: + * - SSE 收到新通知时触发桌面推送 + * - 用户切换到其他标签页时仍能收到提醒 + * + * 权限流程: + * 1. 默认权限为 "default"(未询问) + * 2. 调用 requestPermission() 弹出浏览器权限询问框 + * 3. 用户授权后可调用 showNotification 发送桌面通知 + * + * 浏览器兼容性: + * - Chrome/Edge/Firefox 桌面版完整支持 + * - Safari 桌面版 13+ 支持 + * - 移动端浏览器不支持(自动降级为无桌面推送) + */ +export function useDesktopNotifications( + options?: UseDesktopNotificationsOptions +): UseDesktopNotificationsResult { + const enabled = options?.enabled ?? false + const [permission, setPermission] = useState( + typeof window !== "undefined" && "Notification" in window + ? Notification.permission + : "unsupported" + ) + + const onClickRef = useRef(options?.onClick) + useEffect(() => { + onClickRef.current = options?.onClick + }, [options?.onClick]) + + const isGranted = permission === "granted" + + const requestPermission = useCallback(async (): Promise => { + if (typeof window === "undefined" || !("Notification" in window)) return + + try { + const result = await Notification.requestPermission() + setPermission(result) + } catch { + // 某些浏览器可能抛出异常,静默处理 + } + }, []) + + const showNotification = useCallback( + (notification: Notification): void => { + if (!enabled || !isGranted) return + if (typeof window === "undefined" || !("Notification" in window)) return + + try { + const desktopNotif = new Notification(notification.title, { + body: notification.content ?? "", + tag: notification.id, // 避免重复通知 + ...(notification.link ? { data: notification.link } : {}), + }) + + desktopNotif.onclick = () => { + window.focus() + onClickRef.current?.(notification) + desktopNotif.close() + } + } catch { + // 通知创建失败静默处理 + } + }, + [enabled, isGranted] + ) + + // 当 enabled 变为 true 且权限为 default 时,自动请求权限 + // 使用异步队列避免在 effect 中同步调用 setState + useEffect(() => { + if (!enabled || permission !== "default") return + if (typeof window === "undefined" || !("Notification" in window)) return + + let cancelled = false + Notification.requestPermission().then((result) => { + if (!cancelled) setPermission(result) + }).catch(() => { + // 某些浏览器可能抛出异常,静默处理 + }) + + return () => { + cancelled = true + } + }, [enabled, permission]) + + return { + permission, + isGranted, + requestPermission, + showNotification, + } +} diff --git a/src/modules/notifications/hooks/use-notification-stream.ts b/src/modules/notifications/hooks/use-notification-stream.ts new file mode 100644 index 0000000..fb4ab79 --- /dev/null +++ b/src/modules/notifications/hooks/use-notification-stream.ts @@ -0,0 +1,196 @@ +"use client" + +import { useEffect, useRef, useState, useCallback } from "react" + +import type { Notification } from "../types" +import { + getNotificationsAction, + getUnreadNotificationCountAction, +} from "../actions" + +interface NotificationStreamData { + type: "update" | "error" + unreadCount?: number + notifications?: Notification[] + message?: string +} + +interface UseNotificationStreamOptions { + /** 是否启用 SSE(默认 true) */ + enabled?: boolean + /** SSE 连接失败后的轮询降级间隔(毫秒),默认 30000 */ + fallbackPollInterval?: number + /** 初始未读数 */ + initialUnreadCount?: number + /** 初始通知列表 */ + initialNotifications?: Notification[] +} + +interface UseNotificationStreamResult { + /** 未读通知数 */ + unreadCount: number + /** 最新通知列表 */ + notifications: Notification[] + /** SSE 是否已连接 */ + isConnected: boolean + /** 是否正在使用轮询降级模式 */ + isUsingFallback: boolean + /** 手动刷新(轮询降级模式下使用) */ + refresh: () => void +} + +/** + * 通知实时推送 Hook + * + * 优先使用 SSE(Server-Sent Events)接收实时通知更新, + * 当 SSE 不可用时自动降级为轮询模式(调用 Server Actions)。 + * + * SSE 优势: + * - 实时推送(延迟 < 1 秒) + * - 低服务器负载(单连接 + 定时推送) + * - 自动重连(浏览器原生支持) + * + * 降级策略: + * - SSE 连接失败 / 浏览器不支持 EventSource → 切换为轮询模式 + * - 轮询调用 getNotificationsAction + getUnreadNotificationCountAction + * - 轮询间隔为 fallbackPollInterval(默认 30 秒) + */ +export function useNotificationStream(options?: UseNotificationStreamOptions): UseNotificationStreamResult { + const enabled = options?.enabled ?? true + const fallbackPollInterval = options?.fallbackPollInterval ?? 30_000 + + const [unreadCount, setUnreadCount] = useState(options?.initialUnreadCount ?? 0) + const [notifications, setNotifications] = useState(options?.initialNotifications ?? []) + const [isConnected, setIsConnected] = useState(false) + const [isUsingFallback, setIsUsingFallback] = useState(false) + + const eventSourceRef = useRef(null) + const pollTimerRef = useRef | null>(null) + const pollFnRef = useRef<(() => Promise) | null>(null) + + /** 通过 Server Actions 拉取一次通知数据(轮询降级模式使用) */ + const fetchOnce = useCallback(async (): Promise => { + try { + const [countRes, listRes] = await Promise.all([ + getUnreadNotificationCountAction(), + getNotificationsAction({ pageSize: 10 }), + ]) + if (countRes.success && typeof countRes.data === "number") { + setUnreadCount(countRes.data) + } + if (listRes.success && listRes.data) { + setNotifications(listRes.data.items) + } + } catch { + // 轮询失败静默处理 + } + }, []) + + /** 手动刷新(轮询降级模式下立即拉取一次) */ + const refresh = useCallback(() => { + if (pollFnRef.current) { + void pollFnRef.current() + } + }, []) + + useEffect(() => { + if (!enabled) return + + /** 启动轮询降级(清理 SSE + 设置定时器) */ + const startPolling = (): void => { + if (eventSourceRef.current) { + eventSourceRef.current.close() + eventSourceRef.current = null + } + pollFnRef.current = fetchOnce + void fetchOnce() + pollTimerRef.current = setInterval(() => { + void fetchOnce() + }, fallbackPollInterval) + } + + /** 清理轮询定时器 */ + const clearPolling = (): void => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + } + + // 浏览器不支持 EventSource — 直接使用轮询降级 + if (typeof window === "undefined" || !("EventSource" in window)) { + startPolling() + // 延迟状态更新,避免 effect 内同步 setState + queueMicrotask(() => { + setIsUsingFallback(true) + setIsConnected(false) + }) + return clearPolling + } + + // 创建 SSE 连接 + let eventSource: EventSource + try { + eventSource = new EventSource("/api/notifications/stream") + eventSourceRef.current = eventSource + } catch { + // EventSource 构造失败 — 降级为轮询 + startPolling() + queueMicrotask(() => { + setIsUsingFallback(true) + setIsConnected(false) + }) + return clearPolling + } + + eventSource.onopen = () => { + setIsConnected(true) + setIsUsingFallback(false) + } + + eventSource.onmessage = (event) => { + try { + if (event.data === "[DONE]") { + eventSource.close() + setIsConnected(false) + // SSE 关闭后降级为轮询 + startPolling() + setIsUsingFallback(true) + return + } + const data = JSON.parse(event.data) as NotificationStreamData + if (data.type === "update") { + if (typeof data.unreadCount === "number") setUnreadCount(data.unreadCount) + if (data.notifications) setNotifications(data.notifications) + } else if (data.type === "error") { + eventSource.close() + setIsConnected(false) + startPolling() + setIsUsingFallback(true) + } + } catch { + // 忽略解析错误 + } + } + + eventSource.onerror = () => { + eventSource.close() + setIsConnected(false) + startPolling() + setIsUsingFallback(true) + } + + return () => { + eventSource.close() + clearPolling() + } + }, [enabled, fallbackPollInterval, fetchOnce]) + + return { + unreadCount, + notifications, + isConnected, + isUsingFallback, + refresh, + } +} diff --git a/src/modules/notifications/index.ts b/src/modules/notifications/index.ts index 1e324c2..9aa2918 100644 --- a/src/modules/notifications/index.ts +++ b/src/modules/notifications/index.ts @@ -30,6 +30,8 @@ export { markNotificationAsRead, markAllNotificationsAsRead, getUnreadNotificationCount, + archiveNotification, + unarchiveNotification, getUserContactInfo, logNotificationSend, logNotificationSendBatch, @@ -45,6 +47,7 @@ export { getUnreadNotificationCountAction, markNotificationAsReadAction, markAllNotificationsAsReadAction, + archiveNotificationAction, } from "./actions" export { NotificationList, NotificationDropdown } from "./components" export type { @@ -56,7 +59,9 @@ export type { WechatChannelConfig, EmailChannelConfig, NotificationType, + NotificationPriority, Notification, + NotificationLog, PaginatedResult, GetNotificationsParams, CreateNotificationInput, @@ -70,3 +75,7 @@ export { createSmsSender } from "./channels/sms-channel" export { createWechatSender, isWechatEnabled } from "./channels/wechat-channel" export { createEmailSender, isEmailEnabled } from "./channels/email-channel" export { createInAppSender } from "./channels/in-app-channel" + +// Hooks +export { useNotificationStream } from "./hooks/use-notification-stream" +export { useDesktopNotifications } from "./hooks/use-desktop-notifications" diff --git a/src/modules/notifications/types.ts b/src/modules/notifications/types.ts index 6bd9927..9482b4b 100644 --- a/src/modules/notifications/types.ts +++ b/src/modules/notifications/types.ts @@ -20,6 +20,9 @@ export type NotificationChannel = "in_app" | "email" | "sms" | "wechat" /** 站内通知类型(message_notifications.type 列) */ export type NotificationType = "message" | "announcement" | "homework" | "grade" +/** 通知优先级(message_notifications.priority 列) */ +export type NotificationPriority = "low" | "normal" | "high" | "urgent" + /** 站内通知记录(对应 message_notifications 表的展示形态) */ export interface Notification { id: string @@ -29,6 +32,8 @@ export interface Notification { content: string | null link: string | null isRead: boolean + priority: NotificationPriority + isArchived: boolean createdAt: string } @@ -72,6 +77,10 @@ export interface GetNotificationsParams { page?: number pageSize?: number unreadOnly?: boolean + /** V2-P2-13b: 仅返回未归档通知(默认 true) */ + unarchivedOnly?: boolean + /** V2-P2-13b: 按优先级筛选 */ + priority?: NotificationPriority } /** 创建站内通知的输入 */ @@ -81,6 +90,7 @@ export interface CreateNotificationInput { title: string content?: string | null link?: string | null + priority?: NotificationPriority } /** 通知偏好设置(对应 notification_preferences 表的展示形态) */ @@ -144,6 +154,18 @@ export interface EmailChannelConfig { pass?: string } +/** 通知发送日志记录(对应 notification_logs 表的展示形态) */ +export interface NotificationLog { + id: string + userId: string + title: string + channel: NotificationChannel + status: "success" | "failure" + messageId: string | null + error: string | null + sentAt: string +} + /** 通知渠道总配置 */ export interface NotificationChannelConfig { enabled: boolean diff --git a/src/shared/db/schema.ts b/src/shared/db/schema.ts index 2091522..c8b4fd6 100644 --- a/src/shared/db/schema.ts +++ b/src/shared/db/schema.ts @@ -733,6 +733,8 @@ export const announcements = mysqlTable("announcements", { targetClassId: varchar("target_class_id", { length: 128 }), authorId: varchar("author_id", { length: 128 }).notNull().references(() => users.id), publishedAt: datetime("published_at", { mode: "date" }), + // V2-P2-13d: 公告置顶(置顶公告在列表中优先显示) + isPinned: boolean("is_pinned").default(false).notNull(), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(), }, (table) => ({ @@ -741,6 +743,22 @@ export const announcements = mysqlTable("announcements", { typeIdx: index("announcements_type_idx").on(table.type), targetGradeIdx: index("announcements_target_grade_idx").on(table.targetGradeId), targetClassIdx: index("announcements_target_class_idx").on(table.targetClassId), + // V2-P2-13d: 置顶索引 + statusPinnedIdx: index("announcements_status_pinned_idx").on(table.status, table.isPinned), +})); + +// --- 8b. Announcement Reads (公告已读回执) --- + +export const announcementReads = mysqlTable("announcement_reads", { + id: id("id").primaryKey(), + announcementId: varchar("announcement_id", { length: 128 }).notNull().references(() => announcements.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + readAt: timestamp("read_at", { mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + announcementIdx: index("announcement_reads_announcement_idx").on(table.announcementId), + userIdx: index("announcement_reads_user_idx").on(table.userId), + // 唯一约束:一个用户对一条公告只能有一条已读记录 + uniqueAnnouncementUser: uniqueIndex("announcement_reads_unique_idx").on(table.announcementId, table.userId), })); // --- 9. Audit & Login Logs --- @@ -975,6 +993,8 @@ export const messages = mysqlTable("messages", { // 软删除:发送方/接收方各自独立删除,互不影响 senderDeletedAt: timestamp("sender_deleted_at", { mode: "date" }), receiverDeletedAt: timestamp("receiver_deleted_at", { mode: "date" }), + // V2-P2-13c: 消息星标(接收方可标记重要消息) + isStarred: boolean("is_starred").default(false).notNull(), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), }, (table) => ({ senderIdx: index("messages_sender_idx").on(table.senderId), @@ -982,6 +1002,24 @@ export const messages = mysqlTable("messages", { isReadIdx: index("messages_is_read_idx").on(table.isRead), parentIdx: index("messages_parent_idx").on(table.parentMessageId), receiverReadIdx: index("messages_receiver_read_idx").on(table.receiverId, table.isRead), + // V2-P2-13c: 星标索引 + receiverStarredIdx: index("messages_receiver_starred_idx").on(table.receiverId, table.isStarred), +})); + +// --- 14b. Message Drafts (消息草稿) --- + +export const messageDrafts = mysqlTable("message_drafts", { + id: id("id").primaryKey(), + userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + receiverId: varchar("receiver_id", { length: 128 }).references(() => users.id, { onDelete: "cascade" }), + subject: varchar("subject", { length: 255 }), + content: text("content"), + parentMessageId: varchar("parent_message_id", { length: 128 }), + updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + userIdx: index("message_drafts_user_idx").on(table.userId), + userUpdatedIdx: index("message_drafts_user_updated_idx").on(table.userId, table.updatedAt), })); // --- 15. Message Notifications (消息通知) --- @@ -994,12 +1032,44 @@ export const messageNotifications = mysqlTable("message_notifications", { content: text("content"), link: varchar("link", { length: 512 }), isRead: boolean("is_read").default(false).notNull(), + // V2-P2-13b: 通知优先级(low/normal/high/urgent),默认 normal + priority: varchar("priority", { length: 16 }).default("normal").notNull(), + // V2-P2-13b: 通知归档标记,归档后不在默认列表显示 + isArchived: boolean("is_archived").default(false).notNull(), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), }, (table) => ({ userIdx: index("message_notifications_user_idx").on(table.userId), isReadIdx: index("message_notifications_is_read_idx").on(table.isRead), userReadIdx: index("message_notifications_user_read_idx").on(table.userId, table.isRead), createdAtIdx: index("message_notifications_created_at_idx").on(table.createdAt), + // V2-P2-13b: 新增优先级和归档索引 + priorityIdx: index("message_notifications_priority_idx").on(table.priority), + userArchivedIdx: index("message_notifications_user_archived_idx").on(table.userId, table.isArchived), +})); + +// --- 17. Notification Logs (通知发送日志) --- + +export const notificationLogs = mysqlTable("notification_logs", { + id: id("id").primaryKey(), + // 关联用户(通知接收人) + userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + // 通知标题(冗余存储,便于查询) + title: varchar("title", { length: 255 }).notNull(), + // 发送渠道: in_app | email | sms | wechat + channel: varchar("channel", { length: 32 }).notNull(), + // 发送状态: success | failure + status: varchar("status", { length: 16 }).notNull(), + // 渠道返回的消息 ID(用于追踪) + messageId: varchar("message_id", { length: 255 }), + // 失败时的错误信息 + error: text("error"), + // 发送时间 + sentAt: timestamp("sent_at", { mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + userIdx: index("notification_logs_user_idx").on(table.userId), + channelIdx: index("notification_logs_channel_idx").on(table.channel), + statusIdx: index("notification_logs_status_idx").on(table.status), + sentAtIdx: index("notification_logs_sent_at_idx").on(table.sentAt), })); // --- 16. Notification Preferences (通知偏好) --- @@ -1270,6 +1340,8 @@ export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports id: id("id").primaryKey(), studentId: varchar("student_id", { length: 128 }).references(() => users.id, { onDelete: "cascade" }), generatedBy: varchar("generated_by", { length: 128 }).references(() => users.id, { onDelete: "set null" }), + // v4-P1-4: 班级报告关联的 classId,用于发布时批量通知全班学生 + classId: varchar("class_id", { length: 128 }).references(() => classes.id, { onDelete: "set null" }), reportType: diagnosticReportTypeEnum.default("individual").notNull(), period: varchar("period", { length: 50 }), summary: text("summary"), @@ -1285,6 +1357,7 @@ export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports generatedByIdx: index("diagnostic_generated_by_idx").on(table.generatedBy), statusIdx: index("diagnostic_status_idx").on(table.status), reportTypeIdx: index("diagnostic_report_type_idx").on(table.reportType), + classIdx: index("diagnostic_class_idx").on(table.classId), })); // --- 24. Lesson Preparation (备课) --- @@ -1440,3 +1513,30 @@ export const errorBookReviews = mysqlTable("error_book_reviews", { studentIdx: index("eb_review_student_idx").on(table.studentId), studentReviewedIdx: index("eb_review_student_reviewed_idx").on(table.studentId, table.reviewedAt), })); + +// --- 27. Grade Drafts (成绩录入草稿 - 服务端自动保存) --- + +/** + * 成绩录入草稿 - 用于跨设备恢复未保存的成绩。 + * v3-P2-10: 替代纯 localStorage 方案,支持换设备恢复。 + * 唯一键:userId + classId + subjectId + type,确保每位教师每组合只有一个草稿。 + */ +export const gradeDrafts = mysqlTable("grade_drafts", { + id: id("id").primaryKey(), + userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), + subjectId: varchar("subject_id", { length: 128 }).notNull().references(() => subjects.id, { onDelete: "cascade" }), + type: varchar("type", { length: 20 }).notNull(), + /** 草稿内容:{ scores: Record, timestamp: number } */ + content: json("content").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + userDraftIdx: uniqueIndex("gd_user_class_subject_type_idx").on( + table.userId, + table.classId, + table.subjectId, + table.type + ), + userUpdatedIdx: index("gd_user_updated_idx").on(table.userId, table.updatedAt), +})); diff --git a/src/shared/i18n/messages/en/announcements.json b/src/shared/i18n/messages/en/announcements.json index f28d7f7..f5dbc90 100644 --- a/src/shared/i18n/messages/en/announcements.json +++ b/src/shared/i18n/messages/en/announcements.json @@ -21,7 +21,10 @@ "status": { "draft": "Draft", "published": "Published", - "archived": "Archived" + "archived": "Archived", + "pinned": "Pinned", + "read": "Read", + "unread": "Unread" }, "type": { "school": "School", @@ -52,7 +55,10 @@ "archive": "Archive", "edit": "Edit", "delete": "Delete", - "back": "Back" + "back": "Back", + "pin": "Pin", + "unpin": "Unpin", + "markRead": "Mark as Read" }, "messages": { "created": "Announcement created", @@ -67,7 +73,9 @@ "archiveFailed": "Failed to archive", "deleteFailed": "Failed to delete", "invalidForm": "Invalid form data", - "invalidState": "Invalid form state" + "invalidState": "Invalid state", + "pinToggled": "Pin status updated", + "markedRead": "Marked as read" }, "meta": { "publishedAt": "Published {date}", diff --git a/src/shared/i18n/messages/en/messages.json b/src/shared/i18n/messages/en/messages.json index 08fe792..ad61c56 100644 --- a/src/shared/i18n/messages/en/messages.json +++ b/src/shared/i18n/messages/en/messages.json @@ -21,7 +21,11 @@ "send": "Send", "sending": "Sending...", "cancel": "Cancel", - "back": "Back" + "back": "Back", + "star": "Star", + "unstar": "Unstar", + "saveDraft": "Save Draft", + "deleteDraft": "Delete Draft" }, "form": { "to": "To", @@ -55,7 +59,9 @@ "sentEmpty": "No sent messages", "sentEmptyDesc": "You have not sent any messages yet.", "deleteTitle": "Delete message", - "deleteDesc": "This will permanently delete the message \"{subject}\"." + "deleteDesc": "This will permanently delete the message \"{subject}\".", + "noDrafts": "No drafts", + "noStarred": "No starred messages" }, "messages": { "sent": "Message sent", @@ -66,7 +72,10 @@ "deleteFailed": "Failed to delete", "notFound": "Message not found", "invalidId": "Invalid message id", - "selectRecipient": "Please select a recipient" + "selectRecipient": "Please select a recipient", + "draftSaved": "Draft saved", + "draftDeleted": "Draft deleted", + "starToggled": "Star status updated" }, "notification": { "messageTitle": "New message: {subject}", diff --git a/src/shared/i18n/messages/en/notifications.json b/src/shared/i18n/messages/en/notifications.json index d09369a..7266afc 100644 --- a/src/shared/i18n/messages/en/notifications.json +++ b/src/shared/i18n/messages/en/notifications.json @@ -10,7 +10,9 @@ "markRead": "Mark as read", "markAllRead": "Mark all as read", "viewAll": "View all notifications", - "view": "View" + "view": "View", + "archive": "Archive", + "unarchive": "Unarchive" }, "type": { "message": "Message", @@ -18,18 +20,30 @@ "homework": "Homework", "grade": "Grade" }, + "filter": { + "all": "All" + }, + "priority": { + "low": "Low", + "normal": "Normal", + "high": "High", + "urgent": "Urgent" + }, "status": { "new": "New" }, "empty": { "noNotifications": "No notifications", "noNotificationsDesc": "You have no notifications yet.", - "noNotificationsDropdown": "No notifications" + "noNotificationsDropdown": "No notifications", + "noFilterResults": "No results", + "noFilterResultsDesc": "No notifications match the current filter." }, "messages": { "allMarkedRead": "All notifications marked as read", "notificationMarkedRead": "Notification marked as read", - "markReadFailed": "Failed to mark as read" + "markReadFailed": "Failed to mark as read", + "archiveFailed": "Archive failed" }, "error": { "loadFailed": "Failed to load notifications", diff --git a/src/shared/i18n/messages/zh-CN/announcements.json b/src/shared/i18n/messages/zh-CN/announcements.json index 947bd33..909a9b0 100644 --- a/src/shared/i18n/messages/zh-CN/announcements.json +++ b/src/shared/i18n/messages/zh-CN/announcements.json @@ -21,7 +21,10 @@ "status": { "draft": "草稿", "published": "已发布", - "archived": "已归档" + "archived": "已归档", + "pinned": "已置顶", + "read": "已读", + "unread": "未读" }, "type": { "school": "全校", @@ -52,7 +55,10 @@ "archive": "归档", "edit": "编辑", "delete": "删除", - "back": "返回" + "back": "返回", + "pin": "置顶", + "unpin": "取消置顶", + "markRead": "标记已读" }, "messages": { "created": "公告已创建", @@ -67,7 +73,9 @@ "archiveFailed": "归档失败", "deleteFailed": "删除失败", "invalidForm": "表单数据无效", - "invalidState": "表单状态无效" + "invalidState": "表单状态无效", + "pinToggled": "置顶状态已更新", + "markedRead": "已标记为已读" }, "meta": { "publishedAt": "发布于 {date}", diff --git a/src/shared/i18n/messages/zh-CN/messages.json b/src/shared/i18n/messages/zh-CN/messages.json index e8eda76..b9dea0b 100644 --- a/src/shared/i18n/messages/zh-CN/messages.json +++ b/src/shared/i18n/messages/zh-CN/messages.json @@ -21,7 +21,11 @@ "send": "发送", "sending": "发送中...", "cancel": "取消", - "back": "返回" + "back": "返回", + "star": "星标", + "unstar": "取消星标", + "saveDraft": "保存草稿", + "deleteDraft": "删除草稿" }, "form": { "to": "收件人", @@ -55,7 +59,9 @@ "sentEmpty": "无已发送消息", "sentEmptyDesc": "您还没有发送任何消息。", "deleteTitle": "删除消息", - "deleteDesc": "此操作将永久删除消息{subject}。" + "deleteDesc": "此操作将永久删除消息{subject}。", + "noDrafts": "暂无草稿", + "noStarred": "暂无星标消息" }, "messages": { "sent": "消息已发送", @@ -66,7 +72,10 @@ "deleteFailed": "删除失败", "notFound": "消息不存在", "invalidId": "消息 ID 无效", - "selectRecipient": "请选择收件人" + "selectRecipient": "请选择收件人", + "draftSaved": "草稿已保存", + "draftDeleted": "草稿已删除", + "starToggled": "星标状态已更新" }, "notification": { "messageTitle": "新消息:{subject}", diff --git a/src/shared/i18n/messages/zh-CN/notifications.json b/src/shared/i18n/messages/zh-CN/notifications.json index e9e7900..720d655 100644 --- a/src/shared/i18n/messages/zh-CN/notifications.json +++ b/src/shared/i18n/messages/zh-CN/notifications.json @@ -10,7 +10,9 @@ "markRead": "标记已读", "markAllRead": "全部标记已读", "viewAll": "查看全部通知", - "view": "查看" + "view": "查看", + "archive": "归档", + "unarchive": "取消归档" }, "type": { "message": "消息", @@ -18,18 +20,30 @@ "homework": "作业", "grade": "成绩" }, + "filter": { + "all": "全部" + }, + "priority": { + "low": "低", + "normal": "普通", + "high": "高", + "urgent": "紧急" + }, "status": { "new": "新" }, "empty": { "noNotifications": "暂无通知", "noNotificationsDesc": "您还没有任何通知。", - "noNotificationsDropdown": "暂无通知" + "noNotificationsDropdown": "暂无通知", + "noFilterResults": "无筛选结果", + "noFilterResultsDesc": "当前筛选条件下没有通知。" }, "messages": { "allMarkedRead": "全部已标记为已读", "notificationMarkedRead": "通知已标记为已读", - "markReadFailed": "标记已读失败" + "markReadFailed": "标记已读失败", + "archiveFailed": "归档失败" }, "error": { "loadFailed": "通知加载失败", diff --git a/src/shared/lib/track-event.ts b/src/shared/lib/track-event.ts index c776f80..bc77e55 100644 --- a/src/shared/lib/track-event.ts +++ b/src/shared/lib/track-event.ts @@ -20,9 +20,12 @@ export type EventName = | "announcement.published" | "announcement.archived" | "announcement.deleted" + | "announcement.pin_toggled" + | "announcement.marked_read" | "message.sent" | "message.deleted" | "message.marked_read" + | "message.star_toggled" | "notification.marked_read" | "notification.marked_all_read" | "notification.sent" diff --git a/tests/e2e/messages.spec.ts b/tests/e2e/messages.spec.ts new file mode 100644 index 0000000..4b73add --- /dev/null +++ b/tests/e2e/messages.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test" + +/** + * 私信模块 E2E 测试。 + * 未登录场景可独立运行;登录后场景需要 DATABASE_URL。 + * 参考 announcements.spec.ts 的登录与跳转断言模式。 + */ + +test.describe("Messages module", () => { + test.describe("unauthenticated access", () => { + test("redirects to login when not authenticated", async ({ page }) => { + await page.goto("/messages") + await expect(page).toHaveURL(/\/login(?:$|[/?#])/) + }) + + test("redirects message detail to login when not authenticated", async ({ page }) => { + await page.goto("/messages/some-id") + await expect(page).toHaveURL(/\/login(?:$|[/?#])/) + }) + + test("redirects compose to login when not authenticated", async ({ page }) => { + await page.goto("/messages/compose") + await expect(page).toHaveURL(/\/login(?:$|[/?#])/) + }) + }) + + test.describe("authenticated access", () => { + test.beforeEach(async ({ page }) => { + test.skip(!process.env.DATABASE_URL, "requires DATABASE_URL for authenticated flow") + + const email = process.env.E2E_STUDENT_EMAIL ?? "student@e2e.local" + const password = process.env.E2E_STUDENT_PASSWORD ?? "e2e-pass-123456" + + await page.goto("/login") + await page.getByLabel("Email").fill(email) + await page.getByLabel("Password").fill(password) + await page.getByRole("button", { name: "Sign In with Email" }).click() + }) + + test("messages page loads and stays authenticated", async ({ page }) => { + await page.goto("/messages") + await expect(page.locator("body")).toBeVisible() + await expect(page).not.toHaveURL(/\/login(?:$|[/?#])/) + }) + + test("messages page has tab navigation", async ({ page }) => { + await page.goto("/messages") + const tabs = page.locator('[role="tab"]') + await expect(tabs.first()).toBeVisible({ timeout: 10000 }) + const tabCount = await tabs.count() + expect(tabCount).toBeGreaterThanOrEqual(2) + }) + + test("messages page renders without error", async ({ page }) => { + await page.goto("/messages") + await expect(page.locator("body")).toBeVisible() + }) + + test("message compose page loads", async ({ page }) => { + await page.goto("/messages/compose") + await expect(page.locator("body")).toBeVisible() + }) + }) +}) diff --git a/tests/setup/server-only-stub.ts b/tests/setup/server-only-stub.ts new file mode 100644 index 0000000..351f69f --- /dev/null +++ b/tests/setup/server-only-stub.ts @@ -0,0 +1,4 @@ +// Stub for Next.js "server-only" package. +// The real package throws when imported on the client side; vitest (jsdom) +// cannot resolve it, so we alias it to this empty module in vitest.unit.config.ts. +export {} diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index ef2cf19..b949773 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "src"), + // Next.js "server-only" 包在 vitest 环境无法解析,alias 到空 stub + "server-only": path.resolve(__dirname, "tests/setup/server-only-stub.ts"), }, }, test: {