refactor(school,classes): 完成 school/grade/class 审计全量改进项
P0-1/P0-2: 删除 grade-management 死模块,年级 CRUD 统一由 school 模块负责 P0-3: classes/actions.ts 从 974 行拆分为 6 个职责文件 + barrel re-export P0-5: 13 个页面 i18n 全量接入(grades/departments/academic-year/classes/insights) P1-1: 角色硬编码改为 hasAdminScope/hasTeacherScope/hasStudentScope 基于 dataScope.type P1-3: 新增 SchoolErrorBoundary + SchoolListSkeleton/SchoolCardSkeleton,4 个页面包裹 Error Boundary P1-4: classes/types.ts 跨领域类型添加归属决策注释 P1-5: schools-view.tsx 拆分为组合模式(SchoolFormDialog + SchoolDeleteDialog + SchoolListToolbar) P1-6: 新增 getSchoolsForUser/getGradesForUser 权限感知查询函数 P2-1: 抽取 useSchoolData hook,对话框状态管理与 UI 分离 同步更新架构图文档 004/005
This commit is contained in:
377
src/modules/classes/actions-invitations.ts
Normal file
377
src/modules/classes/actions-invitations.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import {
|
||||
enrollStudentByEmail,
|
||||
enrollStudentByInvitationCode,
|
||||
enrollTeacherByInvitationCode,
|
||||
ensureClassInvitationCode,
|
||||
regenerateClassInvitationCode,
|
||||
setStudentEnrollmentStatus,
|
||||
} from "./data-access"
|
||||
import {
|
||||
EnrollStudentByEmailSchema,
|
||||
} from "./schema"
|
||||
import { hasTeacherScope, hasStudentScope } from "./actions-shared"
|
||||
|
||||
export async function enrollStudentByEmailAction(
|
||||
classId: string,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const parsed = EnrollStudentByEmailSchema.safeParse({
|
||||
classId,
|
||||
email: formData.get("email"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Please select a class and provide student email" }
|
||||
}
|
||||
|
||||
try {
|
||||
await enrollStudentByEmail(parsed.data.classId, parsed.data.email)
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
return { success: true, message: "Student added successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to add student" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinClassByInvitationCodeAction(
|
||||
prevState: ActionState<{ classId: string }> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ classId: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const code = formData.get("code")
|
||||
if (typeof code !== "string" || code.trim().length === 0) {
|
||||
return { success: false, message: "Invitation code is required" }
|
||||
}
|
||||
|
||||
// v3:rate limit 防爆破(10 次/5 分钟,按 userId)
|
||||
const { rateLimit, rateLimitKey } = await import("@/shared/lib/rate-limit")
|
||||
const rlKey = rateLimitKey("class-join", ctx.userId)
|
||||
const rlResult = rateLimit({
|
||||
key: rlKey,
|
||||
limit: 10,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
})
|
||||
if (!rlResult.success) {
|
||||
return { success: false, message: "Too many attempts, please try again later" }
|
||||
}
|
||||
|
||||
// P1-1: 使用 dataScope 替代 ctx.roles.includes("teacher") 硬编码
|
||||
const isTeacher = hasTeacherScope(ctx)
|
||||
const subjectValue = formData.get("subject")
|
||||
const subject = isTeacher && typeof subjectValue === "string" ? subjectValue.trim() : null
|
||||
|
||||
if (isTeacher && (!subject || subject.length === 0)) {
|
||||
return { success: false, message: "Subject is required" }
|
||||
}
|
||||
|
||||
try {
|
||||
const classId = isTeacher
|
||||
? await enrollTeacherByInvitationCode(ctx.userId, code, subject)
|
||||
: await enrollStudentByInvitationCode(ctx.userId, code)
|
||||
|
||||
// 成功后重置 rate limit
|
||||
const { resetRateLimit } = await import("@/shared/lib/rate-limit")
|
||||
resetRateLimit(rlKey)
|
||||
|
||||
// 审计日志
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.consume",
|
||||
module: "classes",
|
||||
targetId: classId,
|
||||
targetType: "class",
|
||||
detail: {
|
||||
code: String(code).trim().toUpperCase(),
|
||||
userId: ctx.userId,
|
||||
// P1-1: 使用 dataScope 推断角色名,避免硬编码
|
||||
role: hasStudentScope(ctx) ? "student" : "teacher",
|
||||
subject,
|
||||
},
|
||||
})
|
||||
|
||||
if (hasStudentScope(ctx)) {
|
||||
revalidatePath("/student/learning/courses")
|
||||
revalidatePath("/student/schedule")
|
||||
} else {
|
||||
revalidatePath("/teacher/classes/my")
|
||||
}
|
||||
revalidatePath("/profile")
|
||||
return { success: true, message: "Joined class successfully", data: { classId } }
|
||||
} catch (error) {
|
||||
// 审计日志:加入失败
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.consume_failed",
|
||||
module: "classes",
|
||||
targetId: String(code).trim().toUpperCase(),
|
||||
targetType: "invitation_code",
|
||||
detail: {
|
||||
userId: ctx.userId,
|
||||
reason: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
status: "failure",
|
||||
})
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to join class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
const code = await ensureClassInvitationCode(classId)
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
||||
return { success: true, message: "Invitation code ready", data: { code } }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
const code = await regenerateClassInvitationCode(classId)
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
||||
return { success: true, message: "Invitation code updated", data: { code } }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 新增:生成自定义邀请码(支持有效期/次数/备注)。
|
||||
* 对标 Google Classroom / 钉钉教育:管理员/教师可为班级生成带有效期与次数限制的邀请码。
|
||||
*
|
||||
* 权限:CLASS_ENROLL(沿用现有权限点,避免过度拆分)
|
||||
* 审计:调用 logAudit 记录生成操作
|
||||
*/
|
||||
export async function createClassInvitationCodeAction(
|
||||
prevState: ActionState<{ code: string; id: string }> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ code: string; id: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const classId = String(formData.get("classId") ?? "").trim()
|
||||
if (!classId) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const expiresInHoursRaw = formData.get("expiresInHours")
|
||||
const maxUsesRaw = formData.get("maxUses")
|
||||
const note = String(formData.get("note") ?? "").trim() || null
|
||||
|
||||
const expiresInHours =
|
||||
expiresInHoursRaw && String(expiresInHoursRaw).trim() !== ""
|
||||
? Number(expiresInHoursRaw)
|
||||
: null
|
||||
const maxUses =
|
||||
maxUsesRaw && String(maxUsesRaw).trim() !== ""
|
||||
? Number(maxUsesRaw)
|
||||
: null
|
||||
|
||||
if (expiresInHours !== null && (!Number.isFinite(expiresInHours) || expiresInHours <= 0)) {
|
||||
return { success: false, message: "Invalid expiresInHours" }
|
||||
}
|
||||
if (maxUses !== null && (!Number.isFinite(maxUses) || maxUses <= 0)) {
|
||||
return { success: false, message: "Invalid maxUses" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { createInvitationCode } = await import("./data-access-invitations")
|
||||
const record = await createInvitationCode(classId, ctx.userId, {
|
||||
expiresInHours,
|
||||
maxUses,
|
||||
note,
|
||||
})
|
||||
|
||||
// 审计日志
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.create",
|
||||
module: "classes",
|
||||
targetId: classId,
|
||||
targetType: "class",
|
||||
detail: {
|
||||
codeId: record.id,
|
||||
code: record.code,
|
||||
expiresInHours,
|
||||
maxUses,
|
||||
note,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
||||
revalidatePath(`/admin/school/classes`)
|
||||
return {
|
||||
success: true,
|
||||
message: "Invitation code generated",
|
||||
data: { code: record.code, id: record.id },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to generate code",
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 新增:撤销邀请码(软删除)。
|
||||
*/
|
||||
export async function revokeClassInvitationCodeAction(
|
||||
prevState: ActionState<null> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const codeId = String(formData.get("codeId") ?? "").trim()
|
||||
if (!codeId) {
|
||||
return { success: false, message: "Missing code id" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { revokeInvitationCode } = await import("./data-access-invitations")
|
||||
await revokeInvitationCode(codeId, ctx.userId)
|
||||
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.revoke",
|
||||
module: "classes",
|
||||
targetId: codeId,
|
||||
targetType: "invitation_code",
|
||||
detail: { revokedBy: ctx.userId },
|
||||
})
|
||||
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/admin/school/classes`)
|
||||
return { success: true, message: "Invitation code revoked" }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to revoke code",
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 新增:列出班级所有邀请码(管理端列表用)。
|
||||
*/
|
||||
export async function listClassInvitationCodesAction(
|
||||
classId: string
|
||||
): Promise<ActionState<{ codes: Array<Record<string, unknown>> }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { listClassInvitationCodes } = await import("./data-access-invitations")
|
||||
const codes = await listClassInvitationCodes(classId)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
codes: codes.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
status: c.status,
|
||||
maxUses: c.maxUses,
|
||||
usedCount: c.usedCount,
|
||||
expiresAt: c.expiresAt?.toISOString() ?? null,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
revokedAt: c.revokedAt?.toISOString() ?? null,
|
||||
note: c.note,
|
||||
})),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to list codes",
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStudentEnrollmentStatusAction(
|
||||
classId: string,
|
||||
studentId: string,
|
||||
status: "active" | "inactive"
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
if (!classId?.trim() || !studentId?.trim()) {
|
||||
return { success: false, message: "Missing enrollment info" }
|
||||
}
|
||||
|
||||
try {
|
||||
await setStudentEnrollmentStatus(classId, studentId, status)
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
return { success: true, message: "Student updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update student" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user