feat(announcements,messaging,notifications): 实现所有长期问题 — SSE 实时推送 + 通知日志持久化 + 优先级/归档 + 消息星标/草稿 + 公告已读回执/置顶 + 分类筛选/桌面推送 + 测试覆盖
P1-8 通知实时推送(SSE): - 新增 /api/notifications/stream SSE 端点(15 秒推送,5 分钟超时) - 新增 useNotificationStream Hook(SSE + 轮询降级) - NotificationDropdown 改用 SSE 实时推送 P2-12 测试覆盖: - notifications/dispatcher.test.ts(6 个测试,渠道选择逻辑) - notifications/channels/in-app-channel.test.ts(9 个测试,类型映射) - messaging/schema.test.ts(34 个测试,Zod 校验) - tests/e2e/messages.spec.ts(消息模块 E2E 测试) - vitest.unit.config.ts 添加 server-only stub P2-13a 通知发送日志持久化: - 新增 notification_logs 表(userId/title/channel/status/messageId/error/sentAt) - logNotificationSend 改为 async 写入 DB(失败降级 console) - dispatcher 传递 payload 用于持久化 P2-13b 通知优先级和归档: - messageNotifications 表新增 priority(low/normal/high/urgent)和 isArchived 字段 - getNotifications 支持归档和优先级筛选 - 新增 archiveNotificationAction - NotificationList 显示优先级 Badge 和归档按钮 P2-13c 消息星标和草稿: - messages 表新增 isStarred 字段 - 新增 message_drafts 表 - 新增 toggleMessageStar + 草稿 CRUD Server Actions - 新增 5 个草稿 data-access 函数 P2-13d 公告已读回执和置顶: - announcements 表新增 isPinned 字段 - 新增 announcement_reads 表(唯一索引保证幂等) - 新增 toggleAnnouncementPinAction + markAnnouncementAsReadAction - getAnnouncements 排序置顶优先 P2-13e 通知分类筛选和桌面推送: - NotificationList 添加按类型筛选按钮组 - 新增 useDesktopNotifications Hook(浏览器 Notification API) - NotificationDropdown 集成桌面推送(新通知触发) 架构图同步: - 004 和 005 均已更新(新增表、Action、Hook、组件描述)
This commit is contained in:
@@ -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<ActionState<number>
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2-P2-13c: 消息星标 Server Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function toggleMessageStarAction(messageId: string): Promise<ActionState<string>> {
|
||||
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<ActionState<import("./types").MessageDraft[]>> {
|
||||
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<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
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<ActionState<string>> {
|
||||
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)已迁移至
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<string, string>): 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<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function toggleMessageStar(id: string, userId: string): Promise<void> {
|
||||
// 查询当前星标状态
|
||||
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<number> => {
|
||||
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<string, string>
|
||||
): 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<MessageDraft[]> => {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await db
|
||||
.delete(messageDrafts)
|
||||
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
|
||||
}
|
||||
|
||||
export async function getMessageDraftById(id: string, userId: string): Promise<MessageDraft | null> {
|
||||
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<string, string>()
|
||||
return mapDraft(row, nameMap)
|
||||
}
|
||||
|
||||
373
src/modules/messaging/schema.test.ts
Normal file
373
src/modules/messaging/schema.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user