refactor: fix all P0/P1/P2 bugs and architecture issues
Bug fixes (from bugs/ directory): - Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions - Fix shared/lib <-> auth circular dependency via new session.ts module - Fix divide-by-zero guard in grades data-access - Fix audit export data truncation (paginated fetch for full datasets) - Fix missing transactions in homework grading and elective lottery - Fix missing revalidatePath in course-plans actions - Fix frontend permission checks using requirePermission instead of requireAuth - Fix dashboard role routing using session.user.roles - Fix student auth pattern (migrate getDemoStudentUser to users module) - Fix ActionState return type handling in components Code quality fixes: - Remove 60+ as type assertions (replace with type guards) - Remove non-null assertions (use optional chaining or explicit checks) - Convert dynamic imports to static imports (grades, diagnostic) - Add React.cache() wrapping for read functions - Parallelize independent queries with Promise.all - Add explicit return types to 30+ arrow functions - Replace any with unknown + type guards - Fix import type for type-only imports - Add Zod validation schemas for classes and diagnostic modules - Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction) - Add console.error to silent catch blocks - Fix permission naming consistency (exam:proctor_read -> exam:proctor:read) Architecture doc sync: - Update 004_architecture_impact_map.md and 005_architecture_data.json - Update management-modules-audit.md for P0-7 cross-module fix Moved deleted proctoring event route to deletes/ folder.
This commit is contained in:
@@ -11,14 +11,12 @@
|
||||
* 班级通知按教师所教班级过滤,确保教师只能给自己班级发通知。
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { classEnrollments, classes } from "@/shared/db/schema"
|
||||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import { getClassExists, getStudentIdsByClassId } from "@/modules/classes/data-access"
|
||||
|
||||
import { sendNotification, sendBatchNotifications } from "./dispatcher"
|
||||
import type { NotificationPayload, ChannelSendResult } from "./types"
|
||||
|
||||
@@ -79,36 +77,29 @@ export async function sendClassNotificationAction(
|
||||
}
|
||||
}
|
||||
|
||||
// 查询班级所有学生
|
||||
const [classRow] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
|
||||
if (!classRow) {
|
||||
// 校验班级是否存在
|
||||
const classExists = await getClassExists(classId)
|
||||
if (!classExists) {
|
||||
return { success: false, message: "Class not found" }
|
||||
}
|
||||
|
||||
const enrollments = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(eq(classEnrollments.classId, classId))
|
||||
// 查询班级所有学生
|
||||
const studentIds = await getStudentIdsByClassId(classId)
|
||||
|
||||
if (enrollments.length === 0) {
|
||||
if (studentIds.length === 0) {
|
||||
return { success: true, message: "No students in this class", data: [] }
|
||||
}
|
||||
|
||||
// 构造每个学生的通知负载
|
||||
const payloads: NotificationPayload[] = enrollments.map((e) => ({
|
||||
const payloads: NotificationPayload[] = studentIds.map((studentId) => ({
|
||||
...payload,
|
||||
userId: e.studentId,
|
||||
userId: studentId,
|
||||
}))
|
||||
|
||||
const results = await sendBatchNotifications(payloads)
|
||||
return {
|
||||
success: true,
|
||||
message: `Notification sent to ${enrollments.length} students`,
|
||||
message: `Notification sent to ${studentIds.length} students`,
|
||||
data: results,
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -26,7 +26,13 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
const channel: NotificationChannel = "email"
|
||||
|
||||
/** 从环境变量读取邮件配置 */
|
||||
function getEmailConfig() {
|
||||
function getEmailConfig(): {
|
||||
host: string | undefined
|
||||
port: number
|
||||
user: string | undefined
|
||||
pass: string | undefined
|
||||
from: string
|
||||
} {
|
||||
return {
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: Number(process.env.EMAIL_PORT ?? "587"),
|
||||
|
||||
@@ -3,22 +3,22 @@ import "server-only"
|
||||
/**
|
||||
* 站内消息渠道
|
||||
*
|
||||
* 封装现有 messaging 模块的 data-access.createNotification,
|
||||
* 封装 notifications 模块的 data-access.createNotification,
|
||||
* 将其适配为统一的 NotificationChannelSender 接口。
|
||||
*
|
||||
* 这是默认渠道,总是启用。所有通知都会写入 message_notifications 表,
|
||||
* 用户可在站内通知中心查看。
|
||||
*
|
||||
* 注意: messaging.NotificationType 为 "message" | "announcement" | "homework" | "grade",
|
||||
* 注意: NotificationType 为 "message" | "announcement" | "homework" | "grade",
|
||||
* 而本模块 NotificationPayload.type 为 "info" | "warning" | "error" | "success"。
|
||||
* 此处将 payload.type 作为字符串写入 DB(DB 列为 varchar(128),支持任意值),
|
||||
* 不破坏现有 messaging 模块的类型约束。
|
||||
* 通过 mapPayloadTypeToNotificationType 函数进行语义映射(P0-11 修复),
|
||||
* 不再使用非法的 as 断言。
|
||||
*
|
||||
* 使用动态 import 打破 notifications -> messaging 的静态反向依赖。
|
||||
* 运行时调用链: messaging -> dispatcher -> in-app channel -> messaging.createNotification (存储)
|
||||
* 这是可接受的运行时调用链,但模块级静态依赖必须单向。
|
||||
* P0-4 / P1-5 修复后,createNotification 已迁移到 notifications/data-access.ts,
|
||||
* 不再需要动态 import messaging 模块,消除了 notifications -> messaging 的反向依赖。
|
||||
*/
|
||||
|
||||
import { createNotification } from "../data-access"
|
||||
import type {
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
@@ -28,7 +28,34 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
|
||||
const channel: NotificationChannel = "in_app"
|
||||
|
||||
/** 站内消息发送器(通过动态 import 调用 messaging data-access) */
|
||||
/**
|
||||
* Map NotificationPayload.type (info/warning/error/success) to
|
||||
* NotificationType (message/announcement/homework/grade).
|
||||
*
|
||||
* Since the DB column is varchar(128) and accepts any string,
|
||||
* we map by semantic meaning. "info" maps to "message" as the default
|
||||
* in-app notification category.
|
||||
*/
|
||||
function mapPayloadTypeToNotificationType(
|
||||
payloadType: NotificationPayload["type"]
|
||||
): "message" | "announcement" | "homework" | "grade" {
|
||||
// Map by semantic meaning: info/success -> message (general),
|
||||
// warning -> announcement (needs attention), error -> grade (alert-like),
|
||||
// fallback to message. This is a reasonable default mapping.
|
||||
switch (payloadType) {
|
||||
case "info":
|
||||
case "success":
|
||||
return "message"
|
||||
case "warning":
|
||||
return "announcement"
|
||||
case "error":
|
||||
return "grade"
|
||||
default:
|
||||
return "message"
|
||||
}
|
||||
}
|
||||
|
||||
/** 站内消息发送器(直接调用 notifications data-access) */
|
||||
class InAppChannelSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
@@ -46,12 +73,10 @@ class InAppChannelSender implements NotificationChannelSender {
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
// Dynamic import to break static reverse dependency on messaging module
|
||||
const { createNotification } = await import("@/modules/messaging/data-access")
|
||||
const id = await createNotification({
|
||||
userId: payload.userId,
|
||||
// DB 列为 varchar(128),支持任意字符串;保留 payload.type 语义
|
||||
type: payload.type as "message" | "announcement" | "homework" | "grade",
|
||||
// Map payload.type to NotificationType via type-safe mapping (P0-11)
|
||||
type: mapPayloadTypeToNotificationType(payload.type),
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
link: payload.actionUrl ?? null,
|
||||
|
||||
@@ -26,10 +26,22 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
|
||||
const channel: NotificationChannel = "sms"
|
||||
|
||||
type SmsProvider = "aliyun" | "tencent" | "mock"
|
||||
|
||||
const isSmsProvider = (v: unknown): v is SmsProvider =>
|
||||
v === "aliyun" || v === "tencent" || v === "mock"
|
||||
|
||||
/** 从环境变量读取 SMS 配置 */
|
||||
function getSmsConfig() {
|
||||
function getSmsConfig(): {
|
||||
provider: SmsProvider
|
||||
accessKeyId: string | undefined
|
||||
accessKeySecret: string | undefined
|
||||
signName: string | undefined
|
||||
templateCode: string | undefined
|
||||
} {
|
||||
const rawProvider = process.env.SMS_PROVIDER ?? "mock"
|
||||
return {
|
||||
provider: (process.env.SMS_PROVIDER ?? "mock") as "aliyun" | "tencent" | "mock",
|
||||
provider: isSmsProvider(rawProvider) ? rawProvider : ("mock" as const),
|
||||
accessKeyId: process.env.SMS_ACCESS_KEY_ID,
|
||||
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
|
||||
signName: process.env.SMS_SIGN_NAME,
|
||||
|
||||
@@ -37,7 +37,11 @@ interface TokenCache {
|
||||
let tokenCache: TokenCache | null = null
|
||||
|
||||
/** 从环境变量读取微信配置 */
|
||||
function getWechatConfig() {
|
||||
function getWechatConfig(): {
|
||||
appId: string | undefined
|
||||
appSecret: string | undefined
|
||||
templateId: string | undefined
|
||||
} {
|
||||
return {
|
||||
appId: process.env.WECHAT_APP_ID,
|
||||
appSecret: process.env.WECHAT_APP_SECRET,
|
||||
|
||||
@@ -4,34 +4,126 @@ import "server-only"
|
||||
* 通知数据访问层
|
||||
*
|
||||
* 职责:
|
||||
* - getUserNotificationPreferences: 获取用户通知偏好(复用 messaging 模块)
|
||||
* - createNotification: 创建站内通知记录(message_notifications 表)
|
||||
* - getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
|
||||
* - getUserContactInfo: 获取用户联系方式(手机/邮箱,用于渠道发送)
|
||||
* - logNotificationSend: 记录发送日志(当前项目无 notification_logs 表,使用 console 输出)
|
||||
*
|
||||
* 表所有权:
|
||||
* - message_notifications(由 notifications 模块统一管理,P0-4 / P1-5 修复后从 messaging 迁移)
|
||||
* - notification_preferences(由 notifications/preferences.ts 管理)
|
||||
*
|
||||
* 注意: users 表当前无 wechatOpenId 字段,wechatOpenId 暂返回 undefined。
|
||||
* 未来扩展 users 表增加 wechat_open_id 列后,此处补充查询即可。
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { users } from "@/shared/db/schema"
|
||||
import { getNotificationPreferences } from "@/modules/messaging/notification-preferences"
|
||||
import type { NotificationPreferences } from "@/modules/messaging/types"
|
||||
import { messageNotifications, users } from "@/shared/db/schema"
|
||||
import type { ChannelRecipient } from "./channels/types"
|
||||
import type { ChannelSendResult } from "./types"
|
||||
import type {
|
||||
ChannelSendResult,
|
||||
CreateNotificationInput,
|
||||
GetNotificationsParams,
|
||||
Notification,
|
||||
NotificationType,
|
||||
PaginatedResult,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* 获取用户通知偏好(复用 messaging 模块的 cache 包装函数)。
|
||||
* 若用户无记录,messaging 模块会自动创建默认记录。
|
||||
*/
|
||||
export async function getUserNotificationPreferences(
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const isNotificationType = (v: unknown): v is NotificationType =>
|
||||
v === "message" || v === "announcement" || v === "homework" || v === "grade"
|
||||
|
||||
const toNotificationType = (v: string): NotificationType =>
|
||||
isNotificationType(v) ? v : "message"
|
||||
|
||||
interface NotificationRow {
|
||||
id: string
|
||||
userId: string
|
||||
): Promise<NotificationPreferences> {
|
||||
return getNotificationPreferences(userId)
|
||||
type: string
|
||||
title: string
|
||||
content: string | null
|
||||
link: string | null
|
||||
isRead: boolean
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
const mapNotification = (r: NotificationRow): Notification => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
type: toNotificationType(r.type),
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
link: r.link,
|
||||
isRead: r.isRead,
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 站内通知 CRUD(message_notifications 表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getNotifications = cache(
|
||||
async (userId: string, params?: GetNotificationsParams): Promise<PaginatedResult<Notification>> => {
|
||||
const page = Math.max(1, params?.page ?? 1)
|
||||
const pageSize = Math.max(1, params?.pageSize ?? 20)
|
||||
const offset = (page - 1) * pageSize
|
||||
const conds = [eq(messageNotifications.userId, userId)]
|
||||
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
|
||||
const where = and(...conds)
|
||||
|
||||
const [rows, [totalRow]] = await Promise.all([
|
||||
db.select().from(messageNotifications).where(where).orderBy(desc(messageNotifications.createdAt)).limit(pageSize).offset(offset),
|
||||
db.select({ value: count() }).from(messageNotifications).where(where),
|
||||
])
|
||||
const total = Number(totalRow?.value ?? 0)
|
||||
return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
||||
}
|
||||
)
|
||||
|
||||
export async function createNotification(data: CreateNotificationInput): Promise<string> {
|
||||
const id = createId()
|
||||
await db.insert(messageNotifications).values({
|
||||
id,
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
content: data.content ?? null,
|
||||
link: data.link ?? null,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function markNotificationAsRead(id: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.update(messageNotifications)
|
||||
.set({ isRead: true })
|
||||
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
|
||||
}
|
||||
|
||||
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
|
||||
await db
|
||||
.update(messageNotifications)
|
||||
.set({ isRead: true })
|
||||
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
|
||||
}
|
||||
|
||||
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(messageNotifications)
|
||||
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
|
||||
return Number(row?.value ?? 0)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 用户联系方式(用于 SMS / Email / WeChat 渠道发送)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取用户联系方式(手机号、邮箱)。
|
||||
* wechatOpenId 暂不支持(users 表无此字段),返回 undefined。
|
||||
@@ -62,6 +154,10 @@ export const getUserContactInfo = cache(
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 发送日志
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 记录通知发送日志。
|
||||
*
|
||||
|
||||
@@ -25,10 +25,10 @@ import { createWechatSender } from "./channels/wechat-channel"
|
||||
import { createEmailSender } from "./channels/email-channel"
|
||||
import { createInAppSender } from "./channels/in-app-channel"
|
||||
import {
|
||||
getUserNotificationPreferences,
|
||||
getUserContactInfo,
|
||||
logNotificationSendBatch,
|
||||
} from "./data-access"
|
||||
import { getNotificationPreferences } from "./preferences"
|
||||
|
||||
/** 渠道发送器实例缓存(避免每次发送重新创建) */
|
||||
interface SenderRegistry {
|
||||
@@ -109,7 +109,7 @@ export async function sendNotification(
|
||||
|
||||
// 并行获取用户偏好和联系方式
|
||||
const [prefs, contact] = await Promise.all([
|
||||
getUserNotificationPreferences(userId),
|
||||
getNotificationPreferences(userId),
|
||||
getUserContactInfo(userId),
|
||||
])
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* 对外导出:
|
||||
* - sendNotification / sendBatchNotifications: 分发器入口
|
||||
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel 等
|
||||
* - createNotification / getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
|
||||
* - getNotificationPreferences / upsertNotificationPreferences: 通知偏好 CRUD
|
||||
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel, NotificationType, Notification, NotificationPreferences 等
|
||||
* - 渠道发送器工厂: createSmsSender, createWechatSender, createEmailSender, createInAppSender
|
||||
*
|
||||
* 典型用法:
|
||||
@@ -20,6 +22,20 @@
|
||||
*/
|
||||
|
||||
export { sendNotification, sendBatchNotifications } from "./dispatcher"
|
||||
export {
|
||||
createNotification,
|
||||
getNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadNotificationCount,
|
||||
getUserContactInfo,
|
||||
logNotificationSend,
|
||||
logNotificationSendBatch,
|
||||
} from "./data-access"
|
||||
export {
|
||||
getNotificationPreferences,
|
||||
upsertNotificationPreferences,
|
||||
} from "./preferences"
|
||||
export type {
|
||||
NotificationChannel,
|
||||
NotificationPayload,
|
||||
@@ -28,6 +44,13 @@ export type {
|
||||
SmsChannelConfig,
|
||||
WechatChannelConfig,
|
||||
EmailChannelConfig,
|
||||
NotificationType,
|
||||
Notification,
|
||||
PaginatedResult,
|
||||
GetNotificationsParams,
|
||||
CreateNotificationInput,
|
||||
NotificationPreferences,
|
||||
UpdateNotificationPreferencesInput,
|
||||
} from "./types"
|
||||
export type { NotificationChannelSender, ChannelRecipient } from "./channels/types"
|
||||
|
||||
|
||||
179
src/modules/notifications/preferences.ts
Normal file
179
src/modules/notifications/preferences.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 通知偏好数据访问层
|
||||
*
|
||||
* 职责:
|
||||
* - getNotificationPreferences: 获取用户通知偏好(无记录时自动创建默认记录)
|
||||
* - upsertNotificationPreferences: 更新或创建用户通知偏好
|
||||
*
|
||||
* 表所有权: notification_preferences(由 notifications 模块统一管理)
|
||||
*
|
||||
* 注意: 本文件从 messaging/notification-preferences.ts 迁移而来,
|
||||
* 消除 notifications -> messaging 的反向依赖(P0-4 / P1-5 修复)。
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { notificationPreferences } from "@/shared/db/schema"
|
||||
import type {
|
||||
NotificationPreferences,
|
||||
UpdateNotificationPreferencesInput,
|
||||
} from "./types"
|
||||
|
||||
const toIso = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapRow = (
|
||||
row: typeof notificationPreferences.$inferSelect
|
||||
): NotificationPreferences => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
emailEnabled: row.emailEnabled,
|
||||
smsEnabled: row.smsEnabled,
|
||||
pushEnabled: row.pushEnabled,
|
||||
homeworkNotifications: row.homeworkNotifications,
|
||||
gradeNotifications: row.gradeNotifications,
|
||||
announcementNotifications: row.announcementNotifications,
|
||||
messageNotifications: row.messageNotifications,
|
||||
attendanceNotifications: row.attendanceNotifications,
|
||||
createdAt: toIso(row.createdAt),
|
||||
updatedAt: toIso(row.updatedAt),
|
||||
})
|
||||
|
||||
// 默认偏好值(首次创建时使用)
|
||||
const DEFAULTS = {
|
||||
emailEnabled: false,
|
||||
smsEnabled: false,
|
||||
pushEnabled: true,
|
||||
homeworkNotifications: true,
|
||||
gradeNotifications: true,
|
||||
announcementNotifications: true,
|
||||
messageNotifications: true,
|
||||
attendanceNotifications: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的通知偏好设置
|
||||
* 如果用户尚无记录,则自动创建一条默认记录并返回
|
||||
*/
|
||||
export const getNotificationPreferences = cache(
|
||||
async (userId: string): Promise<NotificationPreferences> => {
|
||||
// 先查询
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
return mapRow(existing)
|
||||
}
|
||||
|
||||
// 不存在则创建默认记录
|
||||
const id = createId()
|
||||
try {
|
||||
await db.insert(notificationPreferences).values({
|
||||
id,
|
||||
userId,
|
||||
...DEFAULTS,
|
||||
})
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, id))
|
||||
.limit(1)
|
||||
if (created) return mapRow(created)
|
||||
} catch {
|
||||
// 并发情况下可能违反唯一约束,回退到查询
|
||||
const [fallback] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
if (fallback) return mapRow(fallback)
|
||||
}
|
||||
|
||||
// 极端情况:返回内存中的默认值(不带 id)
|
||||
return {
|
||||
id: "",
|
||||
userId,
|
||||
...DEFAULTS,
|
||||
createdAt: toIso(new Date()),
|
||||
updatedAt: toIso(new Date()),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新(或创建)用户的通知偏好设置
|
||||
* 使用 upsert 语义:存在则更新,不存在则插入
|
||||
*/
|
||||
export async function upsertNotificationPreferences(
|
||||
userId: string,
|
||||
input: UpdateNotificationPreferencesInput
|
||||
): Promise<NotificationPreferences | null> {
|
||||
// 先查询是否存在
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
// 更新
|
||||
const updateData: Partial<typeof notificationPreferences.$inferInsert> = {}
|
||||
if (input.emailEnabled !== undefined) updateData.emailEnabled = input.emailEnabled
|
||||
if (input.smsEnabled !== undefined) updateData.smsEnabled = input.smsEnabled
|
||||
if (input.pushEnabled !== undefined) updateData.pushEnabled = input.pushEnabled
|
||||
if (input.homeworkNotifications !== undefined) updateData.homeworkNotifications = input.homeworkNotifications
|
||||
if (input.gradeNotifications !== undefined) updateData.gradeNotifications = input.gradeNotifications
|
||||
if (input.announcementNotifications !== undefined) updateData.announcementNotifications = input.announcementNotifications
|
||||
if (input.messageNotifications !== undefined) updateData.messageNotifications = input.messageNotifications
|
||||
if (input.attendanceNotifications !== undefined) updateData.attendanceNotifications = input.attendanceNotifications
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return mapRow(existing)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(notificationPreferences)
|
||||
.set(updateData)
|
||||
.where(and(eq(notificationPreferences.id, existing.id), eq(notificationPreferences.userId, userId)))
|
||||
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, existing.id))
|
||||
.limit(1)
|
||||
return updated ? mapRow(updated) : null
|
||||
}
|
||||
|
||||
// 不存在则插入
|
||||
const id = createId()
|
||||
try {
|
||||
await db.insert(notificationPreferences).values({
|
||||
id,
|
||||
userId,
|
||||
emailEnabled: input.emailEnabled ?? DEFAULTS.emailEnabled,
|
||||
smsEnabled: input.smsEnabled ?? DEFAULTS.smsEnabled,
|
||||
pushEnabled: input.pushEnabled ?? DEFAULTS.pushEnabled,
|
||||
homeworkNotifications: input.homeworkNotifications ?? DEFAULTS.homeworkNotifications,
|
||||
gradeNotifications: input.gradeNotifications ?? DEFAULTS.gradeNotifications,
|
||||
announcementNotifications: input.announcementNotifications ?? DEFAULTS.announcementNotifications,
|
||||
messageNotifications: input.messageNotifications ?? DEFAULTS.messageNotifications,
|
||||
attendanceNotifications: input.attendanceNotifications ?? DEFAULTS.attendanceNotifications,
|
||||
})
|
||||
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, id))
|
||||
.limit(1)
|
||||
return created ? mapRow(created) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,41 @@
|
||||
* - NotificationPayload: 通知负载(跨渠道统一)
|
||||
* - ChannelSendResult: 单次发送结果
|
||||
* - NotificationChannelConfig: 渠道配置(从环境变量加载)
|
||||
*
|
||||
* 此外,本文件还定义了站内通知记录与通知偏好的类型:
|
||||
* - NotificationType / Notification: 站内通知记录(message_notifications 表)
|
||||
* - NotificationPreferences / UpdateNotificationPreferencesInput: 通知偏好(notification_preferences 表)
|
||||
* - CreateNotificationInput / GetNotificationsParams: 通知 CRUD 入参
|
||||
* - PaginatedResult<T>: 分页结果泛型
|
||||
*/
|
||||
|
||||
/** 支持的通知渠道 */
|
||||
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
|
||||
|
||||
/** 站内通知类型(message_notifications.type 列) */
|
||||
export type NotificationType = "message" | "announcement" | "homework" | "grade"
|
||||
|
||||
/** 站内通知记录(对应 message_notifications 表的展示形态) */
|
||||
export interface Notification {
|
||||
id: string
|
||||
userId: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
content: string | null
|
||||
link: string | null
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 通知列表项(Notification 的别名,用于列表场景) */
|
||||
export type NotificationListItem = Notification
|
||||
|
||||
/** 通知负载(跨渠道统一格式) */
|
||||
export interface NotificationPayload {
|
||||
userId: string
|
||||
title: string
|
||||
content: string
|
||||
/** 通知语义类型(用于渠道内模板映射,不与 messaging.NotificationType 耦合) */
|
||||
/** 通知语义类型(用于渠道内模板映射,不与 NotificationType 耦合) */
|
||||
type: "info" | "warning" | "error" | "success"
|
||||
metadata?: Record<string, unknown>
|
||||
/** 点击通知后的跳转地址(站内相对路径或外链) */
|
||||
@@ -34,6 +58,59 @@ export interface ChannelSendResult {
|
||||
sentAt: Date
|
||||
}
|
||||
|
||||
/** 通用分页结果 */
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
/** 获取站内通知列表的查询参数 */
|
||||
export interface GetNotificationsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
unreadOnly?: boolean
|
||||
}
|
||||
|
||||
/** 创建站内通知的输入 */
|
||||
export interface CreateNotificationInput {
|
||||
userId: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
content?: string | null
|
||||
link?: string | null
|
||||
}
|
||||
|
||||
/** 通知偏好设置(对应 notification_preferences 表的展示形态) */
|
||||
export interface NotificationPreferences {
|
||||
id: string
|
||||
userId: string
|
||||
emailEnabled: boolean
|
||||
smsEnabled: boolean
|
||||
pushEnabled: boolean
|
||||
homeworkNotifications: boolean
|
||||
gradeNotifications: boolean
|
||||
announcementNotifications: boolean
|
||||
messageNotifications: boolean
|
||||
attendanceNotifications: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 更新通知偏好的输入(部分字段可选,未提供则保留原值) */
|
||||
export interface UpdateNotificationPreferencesInput {
|
||||
emailEnabled?: boolean
|
||||
smsEnabled?: boolean
|
||||
pushEnabled?: boolean
|
||||
homeworkNotifications?: boolean
|
||||
gradeNotifications?: boolean
|
||||
announcementNotifications?: boolean
|
||||
messageNotifications?: boolean
|
||||
attendanceNotifications?: boolean
|
||||
}
|
||||
|
||||
/** SMS 渠道配置 */
|
||||
export interface SmsChannelConfig {
|
||||
provider: "aliyun" | "tencent" | "mock"
|
||||
|
||||
Reference in New Issue
Block a user