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:
SpecialX
2026-07-03 10:24:07 +08:00
parent ac1de9e433
commit 7567f317e1
19 changed files with 3192 additions and 0 deletions

View 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 })
}