- 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
258 lines
8.6 KiB
TypeScript
258 lines
8.6 KiB
TypeScript
"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 })
|
||
}
|