"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 } from "@/shared/lib/action-utils" import { trackEvent } from "@/shared/lib/track-event" import { verifyParentChildRelation } from "@/modules/parent/data-access" import { getStudentActiveClass } from "@/modules/classes/data-access" import { syncExcusedFromLeaveRequest } from "@/modules/attendance/data-access" import { CreateLeaveRequestSchema, ReviewLeaveRequestSchema } from "./schema" import { createLeaveRequest, getLeaveRequest, getLeaveRequests, hasOverlappingLeave, reviewLeaveRequest, cancelLeaveRequest, markAttendanceSynced, } from "./data-access" import type { LeaveType } from "./types" /** * 校验提交人对学生的归属: * - 家长视角:必须通过 verifyParentChildRelation 校验 * - 学生本人:requesterId === studentId(由调用方保证,本函数不处理) * 返回值:true 表示是家长代提交且关系已校验;false 表示非家长路径(学生本人)。 */ async function assertRequesterRelation( studentId: string, requesterId: string, ): Promise<{ isParent: boolean; ok: boolean }> { if (studentId === requesterId) { return { isParent: false, ok: true } } const relation = await verifyParentChildRelation(studentId, requesterId) return { isParent: true, ok: relation !== null } } /** 创建请假申请(家长代子女 / 学生本人)。 */ export async function createLeaveRequestAction( prevState: ActionState | null, formData: FormData, ): Promise> { try { const t = await getTranslations("leave") const ctx = await requirePermission(Permissions.LEAVE_REQUEST_CREATE) const parsed = CreateLeaveRequestSchema.safeParse({ studentId: formData.get("studentId"), classId: formData.get("classId"), leaveType: formData.get("leaveType"), startDate: formData.get("startDate"), endDate: formData.get("endDate"), reason: formData.get("reason"), }) if (!parsed.success) { return { success: false, message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } // 校验提交人与学生的归属关系 const relation = await assertRequesterRelation(parsed.data.studentId, ctx.userId) if (!relation.ok) { return { success: false, message: t("errors.noRelation") } } // 校验 classId 确为学生当前所在班级(防止伪造班级 ID) const activeClass = await getStudentActiveClass(parsed.data.studentId) if (!activeClass || activeClass.classId !== parsed.data.classId) { return { success: false, message: t("errors.classMismatch") } } // 校验日期范围内无未结束的请假 const hasOverlap = await hasOverlappingLeave( parsed.data.studentId, parsed.data.startDate, parsed.data.endDate, ) if (hasOverlap) { return { success: false, message: t("errors.overlappingLeave") } } const id = await createLeaveRequest(parsed.data, ctx.userId) revalidatePath("/parent/leave") revalidatePath("/student/leave") revalidatePath("/teacher/leave") await trackEvent({ event: "leave_request.created", userId: ctx.userId, targetId: id, targetType: "leave_request", properties: { studentId: parsed.data.studentId, classId: parsed.data.classId, leaveType: parsed.data.leaveType, startDate: parsed.data.startDate, endDate: parsed.data.endDate, }, }) return { success: true, message: t("messages.created"), data: id } } catch (e) { return handleActionError(e) } } /** 审批请假申请(班主任 / 管理员)。 */ export async function reviewLeaveRequestAction( id: string, prevState: ActionState | null, formData: FormData, ): Promise> { try { const t = await getTranslations("leave") const ctx = await requirePermission(Permissions.LEAVE_REQUEST_REVIEW) const parsed = ReviewLeaveRequestSchema.safeParse({ status: formData.get("status"), reviewComment: formData.get("reviewComment") || undefined, }) if (!parsed.success) { return { success: false, message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } // 拒绝时审批意见必填 if (parsed.data.status === "rejected" && !parsed.data.reviewComment?.trim()) { return { success: false, message: t("errors.reviewCommentRequired") } } // 校验审批人对该请假所在班级的归属 const target = await getLeaveRequest(id, ctx.dataScope, ctx.userId) if (!target) { return { success: false, message: t("errors.notFound") } } // class_taught scope 下,data-access 已通过 buildScopeFilter 过滤;admin 直接放行 // 但仍需再次确认 target.status === 'pending'(reviewLeaveRequest 内部也会校验) if (target.status !== "pending") { return { success: false, message: t("errors.alreadyReviewed") } } const updated = await reviewLeaveRequest(id, parsed.data, ctx.userId) if (!updated) { return { success: false, message: t("errors.alreadyReviewed") } } // 审批通过后异步同步考勤(写入 excused 记录) if (updated.status === "approved" && !updated.attendanceSynced) { try { const leaveTypeLabel = leaveTypeReasonLabel(updated.leaveType) await syncExcusedFromLeaveRequest({ studentId: updated.studentId, classId: updated.classId, startDate: updated.startDate, endDate: updated.endDate, reason: `请假:${leaveTypeLabel}(${updated.reason.slice(0, 80)})`, reviewerId: ctx.userId, }) await markAttendanceSynced(id) } catch { // 考勤同步失败不阻断审批结果(审批已成功,考勤可由管理员后续手动补录) // 不抛出错误,但也不标记 attendanceSynced,便于后续重试 } } revalidatePath("/teacher/leave") revalidatePath("/parent/leave") revalidatePath(`/teacher/leave/${id}`) await trackEvent({ event: "leave_request.reviewed", userId: ctx.userId, targetId: id, targetType: "leave_request", properties: { status: parsed.data.status }, }) return { success: true, message: parsed.data.status === "approved" ? t("messages.approved") : t("messages.rejected"), } } catch (e) { return handleActionError(e) } } /** 撤销请假申请(仅 pending 状态、提交人本人可撤销)。 */ export async function cancelLeaveRequestAction( id: string, ): Promise> { try { const t = await getTranslations("leave") const ctx = await requirePermission(Permissions.LEAVE_REQUEST_CREATE) const target = await getLeaveRequest(id, ctx.dataScope, ctx.userId) if (!target) { return { success: false, message: t("errors.notFound") } } if (target.requesterId !== ctx.userId) { return { success: false, message: t("errors.notOwner") } } if (target.status !== "pending") { return { success: false, message: t("errors.cannotCancel") } } const updated = await cancelLeaveRequest(id, ctx.userId) if (!updated) { return { success: false, message: t("errors.cannotCancel") } } revalidatePath("/parent/leave") revalidatePath("/student/leave") revalidatePath("/teacher/leave") await trackEvent({ event: "leave_request.cancelled", userId: ctx.userId, targetId: id, targetType: "leave_request", }) return { success: true, message: t("messages.cancelled") } } catch (e) { return handleActionError(e) } } /** 请假类型中文标签(用于考勤同步 reason 文本,服务端无 i18n 上下文)。 */ function leaveTypeReasonLabel(type: LeaveType): string { const labels: Record = { sick: "病假", personal: "事假", family: "家庭事务", other: "其他", } return labels[type] ?? type } /** 内部查询辅助:供页面 Server Component 使用(包装 dataScope)。 */ export async function listMyLeaveRequests( scope: Parameters[0]["scope"], currentUserId: string, status?: Parameters[0]["status"], ): ReturnType { return getLeaveRequests({ scope, currentUserId, status, page: 1, pageSize: 50 }) }