Files
NextEdu/src/modules/leave-requests/actions.ts
SpecialX 7567f317e1 feat(modules): add leave-requests, invitation-codes, and standards modules
- Add leave-requests module for staff and student leave request management

- Add invitation-codes module for class invitation code generation and redemption

- Add standards module for curriculum standards management
2026-07-03 10:24:07 +08:00

258 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string> | null,
formData: FormData,
): Promise<ActionState<string>> {
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<string> | null,
formData: FormData,
): Promise<ActionState<string>> {
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<ActionState<string>> {
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<LeaveType, string> = {
sick: "病假",
personal: "事假",
family: "家庭事务",
other: "其他",
}
return labels[type] ?? type
}
/** 内部查询辅助:供页面 Server Component 使用(包装 dataScope。 */
export async function listMyLeaveRequests(
scope: Parameters<typeof getLeaveRequests>[0]["scope"],
currentUserId: string,
status?: Parameters<typeof getLeaveRequests>[0]["status"],
): ReturnType<typeof getLeaveRequests> {
return getLeaveRequests({ scope, currentUserId, status, page: 1, pageSize: 50 })
}