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
This commit is contained in:
257
src/modules/leave-requests/actions.ts
Normal file
257
src/modules/leave-requests/actions.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
"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 })
|
||||
}
|
||||
Reference in New Issue
Block a user