feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates
parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
@@ -30,7 +30,12 @@ import {
|
||||
getUnreadNotificationCount,
|
||||
archiveNotification,
|
||||
} from "./data-access"
|
||||
import type { NotificationPayload, ChannelSendResult, Notification } from "./types"
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
upsertNotificationPreferences,
|
||||
} from "./preferences"
|
||||
import { UpdateNotificationPreferencesSchema } from "./schema"
|
||||
import type { NotificationPayload, ChannelSendResult, Notification, NotificationPreferences, UpdateNotificationPreferencesInput } from "./types"
|
||||
|
||||
/**
|
||||
* Zod 校验:通知负载(sendNotificationAction 入参)
|
||||
@@ -298,3 +303,81 @@ export async function archiveNotificationAction(
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 通知偏好 Server Actions
|
||||
//
|
||||
// V3-P1-3: 从 messaging/actions.ts 迁移至 notifications/actions.ts,
|
||||
// 使通知偏好的 data-access、schema、actions 位于同一模块。
|
||||
// 权限复用 MESSAGE_READ(任何能读消息的用户都能管理通知偏好)。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取当前用户的通知偏好。
|
||||
*/
|
||||
export async function getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
const prefs = await getNotificationPreferences(ctx.userId)
|
||||
return { success: true, data: prefs }
|
||||
} 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" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前用户的通知偏好。
|
||||
*
|
||||
* 从 FormData 中解析布尔值(checkbox 提交 "on" 或不提交)和时间字符串。
|
||||
*/
|
||||
export async function updateNotificationPreferencesAction(
|
||||
prevState: ActionState<NotificationPreferences> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<NotificationPreferences>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
|
||||
const parseBool = (key: string): boolean => formData.get(key) === "on"
|
||||
const parseTime = (key: string): string | null => {
|
||||
const v = formData.get(key)
|
||||
if (typeof v !== "string") return null
|
||||
const trimmed = v.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
const parsed = UpdateNotificationPreferencesSchema.safeParse({
|
||||
emailEnabled: parseBool("emailEnabled"),
|
||||
smsEnabled: parseBool("smsEnabled"),
|
||||
pushEnabled: parseBool("pushEnabled"),
|
||||
homeworkNotifications: parseBool("homeworkNotifications"),
|
||||
gradeNotifications: parseBool("gradeNotifications"),
|
||||
announcementNotifications: parseBool("announcementNotifications"),
|
||||
messageNotifications: parseBool("messageNotifications"),
|
||||
attendanceNotifications: parseBool("attendanceNotifications"),
|
||||
quietHoursEnabled: parseBool("quietHoursEnabled"),
|
||||
quietHoursStart: parseTime("quietHoursStart"),
|
||||
quietHoursEnd: parseTime("quietHoursEnd"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const input: UpdateNotificationPreferencesInput = parsed.data
|
||||
|
||||
const updated = await upsertNotificationPreferences(ctx.userId, input)
|
||||
if (!updated) {
|
||||
return { success: false, message: "Failed to update notification preferences" }
|
||||
}
|
||||
|
||||
revalidatePath("/settings")
|
||||
|
||||
return { success: true, message: "Notification preferences updated", data: updated }
|
||||
} 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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ function getSmsConfig(): {
|
||||
accessKeySecret: string | undefined
|
||||
signName: string | undefined
|
||||
templateCode: string | undefined
|
||||
smsSdkAppId: string | undefined
|
||||
} {
|
||||
const rawProvider = process.env.SMS_PROVIDER ?? "mock"
|
||||
return {
|
||||
@@ -46,6 +47,7 @@ function getSmsConfig(): {
|
||||
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
|
||||
signName: process.env.SMS_SIGN_NAME,
|
||||
templateCode: process.env.SMS_TEMPLATE_CODE,
|
||||
smsSdkAppId: process.env.SMS_SDK_APP_ID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +200,7 @@ class TencentSmsSender implements NotificationChannelSender {
|
||||
const params = buildTemplateParams(payload)
|
||||
const response = await client.SendSms({
|
||||
PhoneNumberSet: [`+86${recipient.phone}`],
|
||||
SmsSdkAppId: this.config.templateCode ?? "",
|
||||
SmsSdkAppId: this.config.smsSdkAppId ?? "",
|
||||
SignName: this.config.signName ?? "",
|
||||
TemplateId: this.config.templateCode ?? "",
|
||||
TemplateParamSet: [params.title, params.content],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
|
||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -32,6 +32,7 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
announcement: Megaphone,
|
||||
homework: PenTool,
|
||||
grade: GraduationCap,
|
||||
diagnostic: Stethoscope,
|
||||
}
|
||||
|
||||
/** 轮询降级间隔(毫秒) */
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
|
||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -21,8 +21,11 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
announcement: Megaphone,
|
||||
homework: PenTool,
|
||||
grade: GraduationCap,
|
||||
diagnostic: Stethoscope,
|
||||
}
|
||||
|
||||
const TYPE_KEYS: NotificationType[] = ["message", "announcement", "homework", "grade", "diagnostic"]
|
||||
|
||||
const PRIORITY_COLOR: Record<NotificationPriority, string> = {
|
||||
low: "bg-muted text-muted-foreground",
|
||||
normal: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
@@ -103,7 +106,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
>
|
||||
{t("filter.all")}
|
||||
</Button>
|
||||
{(Object.keys(TYPE_ICON) as NotificationType[]).map((type) => (
|
||||
{TYPE_KEYS.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={filterType === type ? "default" : "outline"}
|
||||
|
||||
@@ -37,7 +37,7 @@ import type {
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const isNotificationType = (v: unknown): v is NotificationType =>
|
||||
v === "message" || v === "announcement" || v === "homework" || v === "grade"
|
||||
v === "message" || v === "announcement" || v === "homework" || v === "grade" || v === "diagnostic"
|
||||
|
||||
const toNotificationType = (v: string): NotificationType =>
|
||||
isNotificationType(v) ? v : "message"
|
||||
@@ -207,6 +207,7 @@ export async function logNotificationSend(
|
||||
const errorPart = result.error ? ` error="${result.error}"` : ""
|
||||
|
||||
// 始终输出 console 日志(便于开发调试)
|
||||
// TODO V3-P2-8: 接入统一日志服务(shared/lib/logger),替换 console.info
|
||||
console.info(
|
||||
`[NotificationLog] ${result.success ? "OK" : "FAIL"} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
|
||||
)
|
||||
@@ -227,6 +228,7 @@ export async function logNotificationSend(
|
||||
})
|
||||
} catch (dbError) {
|
||||
// DB 写入失败不阻塞通知流程,仅记录错误
|
||||
// TODO V3-P2-8: 接入统一日志服务(shared/lib/logger),替换 console.error
|
||||
console.error("[NotificationLog] Failed to persist log:", dbError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,12 +141,6 @@ export async function sendNotification(
|
||||
export async function sendBatchNotifications(
|
||||
payloads: NotificationPayload[]
|
||||
): Promise<ChannelSendResult[][]> {
|
||||
// 并行处理每个 payload
|
||||
const results = await Promise.all(payloads.map((p) => sendNotification(p)))
|
||||
|
||||
// 汇总日志
|
||||
const flatResults = results.flat()
|
||||
logNotificationSendBatch(flatResults)
|
||||
|
||||
return results
|
||||
// 并行处理每个 payload(每条通知的日志已在 sendNotification 内部记录)
|
||||
return Promise.all(payloads.map((p) => sendNotification(p)))
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ export {
|
||||
markNotificationAsReadAction,
|
||||
markAllNotificationsAsReadAction,
|
||||
archiveNotificationAction,
|
||||
getNotificationPreferencesAction,
|
||||
updateNotificationPreferencesAction,
|
||||
} from "./actions"
|
||||
export { UpdateNotificationPreferencesSchema } from "./schema"
|
||||
export type { UpdateNotificationPreferencesFormInput } from "./schema"
|
||||
export { NotificationList, NotificationDropdown } from "./components"
|
||||
export type {
|
||||
NotificationChannel,
|
||||
|
||||
142
src/modules/notifications/schema.test.ts
Normal file
142
src/modules/notifications/schema.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { UpdateNotificationPreferencesSchema } from "./schema"
|
||||
|
||||
/**
|
||||
* 通知偏好 Schema 测试
|
||||
*
|
||||
* V3-P1-3: 从 messaging/schema.test.ts 迁移至 notifications/schema.test.ts,
|
||||
* 与 notifications/schema.ts 位于同一模块。
|
||||
*/
|
||||
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)
|
||||
})
|
||||
})
|
||||
25
src/modules/notifications/schema.ts
Normal file
25
src/modules/notifications/schema.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* 校验通知偏好更新表单(8 个布尔字段 + 免打扰时段,来自 checkbox/FormData)
|
||||
*
|
||||
* V3-P1-3: 从 messaging/schema.ts 迁移至 notifications/schema.ts,
|
||||
* 使通知偏好校验逻辑与其消费方(notifications 模块)位于同一模块。
|
||||
*/
|
||||
export const UpdateNotificationPreferencesSchema = z.object({
|
||||
emailEnabled: z.boolean(),
|
||||
smsEnabled: z.boolean(),
|
||||
pushEnabled: z.boolean(),
|
||||
homeworkNotifications: z.boolean(),
|
||||
gradeNotifications: z.boolean(),
|
||||
announcementNotifications: z.boolean(),
|
||||
messageNotifications: z.boolean(),
|
||||
attendanceNotifications: z.boolean(),
|
||||
quietHoursEnabled: z.boolean(),
|
||||
quietHoursStart: z.string().trim().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time format").nullable().optional(),
|
||||
quietHoursEnd: z.string().trim().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time format").nullable().optional(),
|
||||
})
|
||||
|
||||
export type UpdateNotificationPreferencesFormInput = z.infer<
|
||||
typeof UpdateNotificationPreferencesSchema
|
||||
>
|
||||
@@ -18,7 +18,7 @@
|
||||
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
|
||||
|
||||
/** 站内通知类型(message_notifications.type 列) */
|
||||
export type NotificationType = "message" | "announcement" | "homework" | "grade"
|
||||
export type NotificationType = "message" | "announcement" | "homework" | "grade" | "diagnostic"
|
||||
|
||||
/** 通知优先级(message_notifications.priority 列) */
|
||||
export type NotificationPriority = "low" | "normal" | "high" | "urgent"
|
||||
|
||||
Reference in New Issue
Block a user