"use server" import { revalidatePath } from "next/cache" import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils" import { trackEvent } from "@/shared/lib/track-event" import { verifyTeacherOwnsClass } from "@/modules/classes/data-access" import { RecordAttendanceSchema, BatchRecordAttendanceSchema, UpdateAttendanceSchema, AttendanceRuleSchema, } from "./schema" import { createAttendanceRecord, batchCreateAttendanceRecords, updateAttendanceRecord, deleteAttendanceRecord, getAttendanceRecordClassId, upsertAttendanceRules, } from "./data-access" /** * 校验当前用户对考勤记录的归属权限。 * - admin(scope=all):直接放行 * - teacher(scope=class_taught):必须为记录所属班级的任课教师 * - 其他 scope:拒绝(学生/家长不应调用写操作) */ async function assertRecordOwnership( recordId: string, ctx: Awaited> ): Promise<{ ok: boolean; message?: string }> { const t = await getTranslations("attendance") if (ctx.dataScope.type === "all") return { ok: true } if (ctx.dataScope.type === "class_taught") { const classId = await getAttendanceRecordClassId(recordId) if (!classId) return { ok: false, message: t("errors.notFound") } const owns = await verifyTeacherOwnsClass(classId, ctx.userId) if (!owns) return { ok: false, message: t("errors.noOwnership") } return { ok: true } } return { ok: false, message: t("errors.insufficientPermissions") } } /** * 校验当前用户对班级的归属权限。 * - admin(scope=all):直接放行 * - teacher(scope=class_taught):必须为该班级的任课教师 * - 其他 scope:拒绝 */ async function assertClassOwnership( classId: string, ctx: Awaited> ): Promise<{ ok: boolean; message?: string }> { const t = await getTranslations("attendance") if (ctx.dataScope.type === "all") return { ok: true } if (ctx.dataScope.type === "class_taught") { const owns = await verifyTeacherOwnsClass(classId, ctx.userId) if (!owns) return { ok: false, message: t("errors.noClassOwnership") } return { ok: true } } return { ok: false, message: t("errors.insufficientPermissions") } } export async function recordAttendanceAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const t = await getTranslations("attendance") const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const parsed = RecordAttendanceSchema.safeParse({ studentId: formData.get("studentId"), classId: formData.get("classId"), date: formData.get("date"), status: formData.get("status"), remark: formData.get("remark") || undefined, scheduleId: formData.get("scheduleId") || undefined, }) if (!parsed.success) { return { success: false, message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } const id = await createAttendanceRecord(parsed.data, ctx.userId) revalidatePath("/teacher/attendance") await trackEvent({ event: "attendance.recorded", userId: ctx.userId, targetId: id, targetType: "attendance_record", properties: { studentId: parsed.data.studentId, classId: parsed.data.classId, status: parsed.data.status }, }) return { success: true, message: t("messages.recorded"), data: id } } catch (e) { return handleActionError(e) } } export async function batchRecordAttendanceAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const t = await getTranslations("attendance") const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const recordsJson = formData.get("recordsJson") if (typeof recordsJson !== "string" || recordsJson.length === 0) { return { success: false, message: t("errors.missingRecords") } } const parsed = BatchRecordAttendanceSchema.safeParse({ records: safeJsonParse(recordsJson, t("errors.invalidRecordsJson")), }) if (!parsed.success) { return { success: false, message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } const count = await batchCreateAttendanceRecords(parsed.data, ctx.userId) revalidatePath("/teacher/attendance") await trackEvent({ event: "attendance.batch_recorded", userId: ctx.userId, targetType: "attendance_record", properties: { count, classId: parsed.data.records[0]?.classId }, }) return { success: true, message: t("messages.batchRecorded", { count }), data: count } } catch (e) { return handleActionError(e) } } export async function updateAttendanceAction( id: string, prevState: ActionState | null, formData: FormData ): Promise> { try { const t = await getTranslations("attendance") const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const ownership = await assertRecordOwnership(id, ctx) if (!ownership.ok) { return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } const parsed = UpdateAttendanceSchema.safeParse({ status: formData.get("status") || undefined, remark: formData.get("remark") || undefined, scheduleId: formData.get("scheduleId") || undefined, }) if (!parsed.success) { return { success: false, message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } await updateAttendanceRecord(id, parsed.data) revalidatePath("/teacher/attendance") await trackEvent({ event: "attendance.updated", userId: ctx.userId, targetId: id, targetType: "attendance_record", properties: { status: parsed.data.status }, }) return { success: true, message: t("messages.updated") } } catch (e) { return handleActionError(e) } } export async function deleteAttendanceAction( id: string ): Promise> { try { const t = await getTranslations("attendance") const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const ownership = await assertRecordOwnership(id, ctx) if (!ownership.ok) { return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } await deleteAttendanceRecord(id) revalidatePath("/teacher/attendance") await trackEvent({ event: "attendance.deleted", userId: ctx.userId, targetId: id, targetType: "attendance_record", }) return { success: true, message: t("messages.deleted") } } catch (e) { return handleActionError(e) } } export async function saveAttendanceRulesAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const t = await getTranslations("attendance") const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const parsed = AttendanceRuleSchema.safeParse({ classId: formData.get("classId"), lateThresholdMinutes: formData.get("lateThresholdMinutes") || undefined, earlyLeaveThresholdMinutes: formData.get("earlyLeaveThresholdMinutes") || undefined, enableAutoMark: formData.get("enableAutoMark") === "true", }) if (!parsed.success) { return { success: false, message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } const ownership = await assertClassOwnership(parsed.data.classId, ctx) if (!ownership.ok) { return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } const id = await upsertAttendanceRules(parsed.data) revalidatePath("/teacher/attendance") await trackEvent({ event: "attendance.rules_saved", userId: ctx.userId, targetId: id, targetType: "attendance_rule", properties: { classId: parsed.data.classId }, }) return { success: true, message: t("messages.rulesSaved"), data: id } } catch (e) { return handleActionError(e) } }