Security: Add admin/layout.tsx auth guard; Add requirePermission() to 12 admin pages Dashboard: Fix StudentStatsGrid rendering; Fix teacher greeting; Add loading/error boundaries; Fix col-span; Add metadata Announcements: Fix audience filtering; Add user detail page; Trigger notifications on publish; Pass classes data; Add loading.tsx Messages: Implement soft delete; Add unread badge with polling; Add notification dropdown polling; Add keyword search; Add quiet hours DND Management: Add loading/error for 9 admin routes; Fix admin-classes-view to use Select for school/grade Profile/Settings: Add loading/error; Fix parent role routing; Create ParentSettingsView; Integrate AiProviderSettingsCard; Add Tab URL persistence; Add logout confirm; Add avatar; Fix Progress arbitrary class Schema: Add senderDeletedAt/receiverDeletedAt to messages; Add quietHours to notificationPreferences; Add uniqueIndex import Docs: Update architecture docs 004/005
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
"use server"
|
||
|
||
import { revalidatePath } from "next/cache"
|
||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||
import { Permissions } from "@/shared/types/permissions"
|
||
import type { ActionState } from "@/shared/types/action-state"
|
||
import { sendNotification } from "@/modules/notifications/dispatcher"
|
||
|
||
import {
|
||
SendMessageSchema,
|
||
MessageIdSchema,
|
||
UpdateNotificationPreferencesSchema,
|
||
} from "./schema"
|
||
import {
|
||
getMessages,
|
||
getMessageById,
|
||
createMessage,
|
||
markMessageAsRead,
|
||
deleteMessage,
|
||
getRecipients,
|
||
getUnreadMessageCount,
|
||
} from "./data-access"
|
||
import {
|
||
getNotifications,
|
||
markNotificationAsRead,
|
||
markAllNotificationsAsRead,
|
||
getUnreadNotificationCount,
|
||
} from "@/modules/notifications/data-access"
|
||
import {
|
||
getNotificationPreferences,
|
||
upsertNotificationPreferences,
|
||
} from "@/modules/notifications/preferences"
|
||
import type { Message, MessageType, RecipientOption } from "./types"
|
||
import type {
|
||
Notification,
|
||
NotificationPreferences,
|
||
UpdateNotificationPreferencesInput,
|
||
} from "@/modules/notifications/types"
|
||
|
||
export async function sendMessageAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
|
||
|
||
const parsed = SendMessageSchema.safeParse({
|
||
receiverId: formData.get("receiverId"),
|
||
subject: formData.get("subject") || undefined,
|
||
content: formData.get("content"),
|
||
parentMessageId: formData.get("parentMessageId") || undefined,
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
|
||
}
|
||
|
||
const input = parsed.data
|
||
if (input.receiverId === ctx.userId) {
|
||
return { success: false, message: "Cannot send a message to yourself" }
|
||
}
|
||
|
||
const id = await createMessage({
|
||
senderId: ctx.userId,
|
||
receiverId: input.receiverId,
|
||
subject: input.subject,
|
||
content: input.content,
|
||
parentMessageId: input.parentMessageId,
|
||
})
|
||
|
||
// Notify the receiver about the new message via the notifications dispatcher.
|
||
// This respects user notification preferences (SMS/WeChat/Email/In-App).
|
||
await sendNotification({
|
||
userId: input.receiverId,
|
||
type: "info",
|
||
title: input.subject ? `New message: ${input.subject}` : "New message",
|
||
content: input.content.slice(0, 200),
|
||
actionUrl: `/messages/${id}`,
|
||
metadata: { messageType: "message", messageId: id },
|
||
})
|
||
|
||
revalidatePath("/messages")
|
||
revalidatePath(`/messages/${id}`)
|
||
|
||
return { success: true, message: "Message sent", 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 markMessageAsReadAction(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 markMessageAsRead(parsed.data.messageId, ctx.userId)
|
||
revalidatePath("/messages")
|
||
revalidatePath(`/messages/${parsed.data.messageId}`)
|
||
return { success: true, message: "Marked as read" }
|
||
} 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 deleteMessageAction(messageId: string): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.MESSAGE_DELETE)
|
||
|
||
const parsed = MessageIdSchema.safeParse({ messageId })
|
||
if (!parsed.success) {
|
||
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
|
||
}
|
||
|
||
await deleteMessage(parsed.data.messageId, ctx.userId)
|
||
revalidatePath("/messages")
|
||
revalidatePath(`/messages/${parsed.data.messageId}`)
|
||
return { success: true, message: "Message 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" }
|
||
}
|
||
}
|
||
|
||
export async function getMessagesAction(
|
||
params: { type: MessageType; page?: number; pageSize?: number; keyword?: string }
|
||
): Promise<ActionState<{ items: Message[]; total: number; page: number; pageSize: number; totalPages: number }>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||
const result = await getMessages({ userId: ctx.userId, ...params })
|
||
return { success: true, data: result }
|
||
} 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 getMessageDetailAction(messageId: string): Promise<ActionState<Message>> {
|
||
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 }
|
||
}
|
||
|
||
const validMessageId = parsed.data.messageId
|
||
const message = await getMessageById(validMessageId, ctx.userId)
|
||
if (!message) return { success: false, message: "Message not found" }
|
||
// Auto-mark as read when viewed by receiver
|
||
if (!message.isRead && message.receiverId === ctx.userId) {
|
||
await markMessageAsRead(validMessageId, ctx.userId)
|
||
revalidatePath("/messages")
|
||
}
|
||
return { success: true, data: message }
|
||
} 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 getRecipientsAction(): Promise<ActionState<RecipientOption[]>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
|
||
const recipients = await getRecipients(ctx.userId, ctx.dataScope)
|
||
return { success: true, data: recipients }
|
||
} 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 getUnreadMessageCountAction(): Promise<ActionState<number>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||
const count = await getUnreadMessageCount(ctx.userId)
|
||
return { success: true, data: count }
|
||
} 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 getUnreadNotificationCountAction(): Promise<ActionState<number>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||
const count = await getUnreadNotificationCount(ctx.userId)
|
||
return { success: true, data: count }
|
||
} 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 getNotificationsAction(
|
||
params?: { page?: number; pageSize?: number; unreadOnly?: boolean }
|
||
): Promise<ActionState<{ items: Notification[]; total: number; page: number; pageSize: number; totalPages: number }>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||
const result = await getNotifications(ctx.userId, params)
|
||
return { success: true, data: result }
|
||
} 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 markNotificationAsReadAction(
|
||
notificationId: string
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||
await markNotificationAsRead(notificationId, ctx.userId)
|
||
revalidatePath("/messages")
|
||
return { success: true, message: "Notification marked as read" }
|
||
} 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 markAllNotificationsAsReadAction(): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||
await markAllNotificationsAsRead(ctx.userId)
|
||
revalidatePath("/messages")
|
||
return { success: true, message: "All notifications marked as read" }
|
||
} 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 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" }
|
||
}
|
||
}
|
||
|
||
export async function updateNotificationPreferencesAction(
|
||
prevState: ActionState<NotificationPreferences> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<NotificationPreferences>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||
|
||
// 从 FormData 中解析布尔值(checkbox 提交 "on" 或不提交)
|
||
const parseBool = (key: string): boolean => formData.get(key) === "on"
|
||
// 从 FormData 中解析时间字符串("HH:mm"),空字符串转为 null
|
||
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" }
|
||
}
|
||
}
|