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:
151
src/modules/classes/actions-admin.ts
Normal file
151
src/modules/classes/actions-admin.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
"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 {
|
||||
createAdminClass,
|
||||
deleteAdminClass,
|
||||
setClassSubjectTeachers,
|
||||
updateAdminClass,
|
||||
} from "./data-access"
|
||||
import {
|
||||
CreateAdminClassSchema,
|
||||
UpdateAdminClassSchema,
|
||||
DeleteAdminClassSchema,
|
||||
} from "./schema"
|
||||
import { parseSubjectTeachers } from "./actions-shared"
|
||||
|
||||
export async function createAdminClassAction(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_CREATE)
|
||||
|
||||
const parsed = CreateAdminClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name, grade and teacher are required" }
|
||||
}
|
||||
|
||||
const { name, grade, teacherId, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
||||
|
||||
try {
|
||||
const id = await createAdminClass({
|
||||
schoolName: schoolName ?? null,
|
||||
schoolId: schoolId ?? null,
|
||||
name,
|
||||
grade,
|
||||
gradeId: gradeId ?? null,
|
||||
teacherId,
|
||||
homeroom: homeroom ?? null,
|
||||
room: room ?? null,
|
||||
})
|
||||
revalidatePath("/admin/school/classes")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAdminClassAction(
|
||||
classId: string,
|
||||
prevState: ActionState | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_UPDATE)
|
||||
|
||||
const parsed = UpdateAdminClassSchema.safeParse({
|
||||
classId,
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
||||
const subjectTeachers = parseSubjectTeachers(formData.get("subjectTeachers") as string | null)
|
||||
|
||||
try {
|
||||
await updateAdminClass(validatedClassId, {
|
||||
schoolName: schoolName ?? undefined,
|
||||
schoolId: schoolId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
grade: grade ?? undefined,
|
||||
gradeId: gradeId ?? undefined,
|
||||
teacherId: teacherId ?? undefined,
|
||||
homeroom: homeroom ?? undefined,
|
||||
room: room ?? undefined,
|
||||
})
|
||||
|
||||
if (subjectTeachers) {
|
||||
await setClassSubjectTeachers({
|
||||
classId: validatedClassId,
|
||||
assignments: subjectTeachers,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/admin/school/classes")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAdminClassAction(classId: string): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_DELETE)
|
||||
|
||||
const parsed = DeleteAdminClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAdminClass(parsed.data.classId)
|
||||
revalidatePath("/admin/school/classes")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
182
src/modules/classes/actions-grade.ts
Normal file
182
src/modules/classes/actions-grade.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
"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 {
|
||||
createAdminClass,
|
||||
deleteAdminClass,
|
||||
getClassGradeId,
|
||||
setClassSubjectTeachers,
|
||||
updateAdminClass,
|
||||
} from "./data-access"
|
||||
import { isGradeManager } from "@/modules/school/data-access"
|
||||
import {
|
||||
CreateGradeClassSchema,
|
||||
UpdateGradeClassSchema,
|
||||
DeleteGradeClassSchema,
|
||||
} from "./schema"
|
||||
import { parseSubjectTeachers } from "./actions-shared"
|
||||
|
||||
export async function createGradeClassAction(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
||||
|
||||
const parsed = CreateGradeClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
grade: formData.get("grade"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name, grade and teacher are required" }
|
||||
}
|
||||
|
||||
const { name, gradeId, teacherId, schoolName, schoolId, grade, homeroom, room } = parsed.data
|
||||
|
||||
// Verify access
|
||||
const isManager = await isGradeManager(gradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to create classes for this grade" }
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await createAdminClass({
|
||||
schoolName: schoolName ?? null,
|
||||
schoolId: schoolId ?? null,
|
||||
name,
|
||||
grade: grade ?? "", // Should be passed from UI based on selected grade
|
||||
gradeId,
|
||||
teacherId,
|
||||
homeroom: homeroom ?? null,
|
||||
room: room ?? null,
|
||||
})
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGradeClassAction(
|
||||
classId: string,
|
||||
prevState: ActionState | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_UPDATE)
|
||||
|
||||
const parsed = UpdateGradeClassSchema.safeParse({
|
||||
classId,
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
||||
const subjectTeachers = parseSubjectTeachers(formData.get("subjectTeachers") as string | null)
|
||||
|
||||
// Verify access: Check if the class belongs to a managed grade
|
||||
const classGradeId = await getClassGradeId(validatedClassId)
|
||||
if (!classGradeId) {
|
||||
return { success: false, message: "Class not found or not linked to a grade" }
|
||||
}
|
||||
|
||||
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to update this class" }
|
||||
}
|
||||
|
||||
// If changing gradeId, verify target grade too
|
||||
if (typeof gradeId === "string" && gradeId !== classGradeId) {
|
||||
const isTargetManager = await isGradeManager(gradeId, ctx.userId)
|
||||
if (!isTargetManager) {
|
||||
return { success: false, message: "You do not have permission to move class to this grade" }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAdminClass(validatedClassId, {
|
||||
schoolName: schoolName ?? undefined,
|
||||
schoolId: schoolId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
grade: grade ?? undefined,
|
||||
gradeId: gradeId ?? undefined,
|
||||
teacherId: teacherId ?? undefined,
|
||||
homeroom: homeroom ?? undefined,
|
||||
room: room ?? undefined,
|
||||
})
|
||||
|
||||
if (subjectTeachers) {
|
||||
await setClassSubjectTeachers({
|
||||
classId: validatedClassId,
|
||||
assignments: subjectTeachers,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGradeClassAction(classId: string): Promise<ActionState> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_DELETE)
|
||||
|
||||
const parsed = DeleteGradeClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId } = parsed.data
|
||||
|
||||
// Verify access
|
||||
const classGradeId = await getClassGradeId(validatedClassId)
|
||||
if (!classGradeId) {
|
||||
return { success: false, message: "Class not found or not linked to a grade" }
|
||||
}
|
||||
|
||||
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to delete this class" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAdminClass(validatedClassId)
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
124
src/modules/classes/actions-schedule.ts
Normal file
124
src/modules/classes/actions-schedule.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
"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 {
|
||||
createClassScheduleItem,
|
||||
updateClassScheduleItem,
|
||||
deleteClassScheduleItem,
|
||||
} from "@/modules/scheduling/data-access-class-schedule"
|
||||
import {
|
||||
CreateClassScheduleItemSchema,
|
||||
UpdateClassScheduleItemSchema,
|
||||
DeleteClassScheduleItemSchema,
|
||||
} from "./schema"
|
||||
import { toWeekday } from "./actions-shared"
|
||||
|
||||
export async function createClassScheduleItemAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
|
||||
const parsed = CreateClassScheduleItemSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
weekday: formData.get("weekday"),
|
||||
course: formData.get("course"),
|
||||
startTime: formData.get("startTime"),
|
||||
endTime: formData.get("endTime"),
|
||||
location: formData.get("location"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid schedule item data" }
|
||||
}
|
||||
|
||||
const { classId, weekday, course, startTime, endTime, location } = parsed.data
|
||||
|
||||
try {
|
||||
const id = await createClassScheduleItem({
|
||||
classId,
|
||||
weekday: toWeekday(weekday),
|
||||
startTime,
|
||||
endTime,
|
||||
course,
|
||||
location: location ?? null,
|
||||
})
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item created successfully", data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create schedule item" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateClassScheduleItemAction(
|
||||
scheduleId: string,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
|
||||
const parsed = UpdateClassScheduleItemSchema.safeParse({
|
||||
scheduleId,
|
||||
classId: formData.get("classId"),
|
||||
weekday: formData.get("weekday") || undefined,
|
||||
course: formData.get("course"),
|
||||
startTime: formData.get("startTime"),
|
||||
endTime: formData.get("endTime"),
|
||||
location: formData.get("location"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing or invalid schedule id" }
|
||||
}
|
||||
|
||||
const { scheduleId: validatedScheduleId, classId, weekday, course, startTime, endTime, location } = parsed.data
|
||||
|
||||
try {
|
||||
await updateClassScheduleItem(validatedScheduleId, {
|
||||
classId: classId ?? undefined,
|
||||
weekday: typeof weekday === "number" ? toWeekday(weekday) : undefined,
|
||||
startTime: startTime ?? undefined,
|
||||
endTime: endTime ?? undefined,
|
||||
course: course ?? undefined,
|
||||
location: location ?? undefined,
|
||||
})
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update schedule item" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteClassScheduleItemAction(scheduleId: string): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
|
||||
const parsed = DeleteClassScheduleItemSchema.safeParse({ scheduleId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing schedule id" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteClassScheduleItem(parsed.data.scheduleId)
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item deleted successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete schedule item" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
59
src/modules/classes/actions-shared.ts
Normal file
59
src/modules/classes/actions-shared.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { AuthContext } from "@/shared/types/permissions"
|
||||
import type { ClassSubject } from "./types"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
|
||||
const CLASS_SUBJECT_STRINGS: readonly string[] = DEFAULT_CLASS_SUBJECTS
|
||||
|
||||
export const isClassSubject = (v: string): v is ClassSubject => CLASS_SUBJECT_STRINGS.includes(v)
|
||||
|
||||
export const isWeekday = (n: number): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
||||
n >= 1 && n <= 7 && Number.isInteger(n)
|
||||
|
||||
export const toWeekday = (n: number): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
if (!isWeekday(n)) throw new Error("Invalid weekday")
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* P1-1: 替代 `ctx.roles.includes("admin")` 硬编码。
|
||||
* 通过 dataScope.type === "all" 判断是否拥有 admin 级别的数据访问范围。
|
||||
*/
|
||||
export const hasAdminScope = (ctx: AuthContext): boolean => ctx.dataScope.type === "all"
|
||||
|
||||
/**
|
||||
* P1-1: 替代 `ctx.roles.includes("teacher")` 硬编码。
|
||||
* 通过 dataScope.type === "class_taught" 判断是否为教师(有授课班级)。
|
||||
*/
|
||||
export const hasTeacherScope = (ctx: AuthContext): boolean => ctx.dataScope.type === "class_taught"
|
||||
|
||||
/**
|
||||
* P1-1: 替代 `ctx.roles.includes("student")` 硬编码。
|
||||
* 通过 dataScope.type === "class_members" 判断是否为学生。
|
||||
*/
|
||||
export const hasStudentScope = (ctx: AuthContext): boolean => ctx.dataScope.type === "class_members"
|
||||
|
||||
/**
|
||||
* 解析表单中的 subjectTeachers JSON 字符串为标准赋值数组。
|
||||
* 提取自原 actions.ts,供 admin/grade class 更新逻辑复用。
|
||||
*/
|
||||
export const parseSubjectTeachers = (raw: string | null) => {
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) return null
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
|
||||
|
||||
return parsed.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
const subject = (item as { subject?: unknown }).subject
|
||||
const teacherId = (item as { teacherId?: unknown }).teacherId
|
||||
|
||||
if (typeof subject !== "string" || !isClassSubject(subject)) return []
|
||||
|
||||
if (teacherId === null || typeof teacherId === "undefined") {
|
||||
return [{ subject, teacherId: null }]
|
||||
}
|
||||
|
||||
if (typeof teacherId !== "string") return []
|
||||
const trimmed = teacherId.trim()
|
||||
return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }]
|
||||
})
|
||||
}
|
||||
148
src/modules/classes/actions-teacher.ts
Normal file
148
src/modules/classes/actions-teacher.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
"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 {
|
||||
createTeacherClass,
|
||||
deleteTeacherClass,
|
||||
updateTeacherClass,
|
||||
} from "./data-access"
|
||||
import { findGradeIdByHeadAndName, isGradeHead } from "@/modules/school/data-access"
|
||||
import {
|
||||
CreateTeacherClassSchema,
|
||||
UpdateTeacherClassSchema,
|
||||
DeleteTeacherClassSchema,
|
||||
} from "./schema"
|
||||
import { hasAdminScope } from "./actions-shared"
|
||||
|
||||
export async function createTeacherClassAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
||||
|
||||
const parsed = CreateTeacherClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name and grade are required" }
|
||||
}
|
||||
|
||||
const { name, grade, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
||||
|
||||
// P1-1: 使用 dataScope 替代 ctx.roles.includes("admin") 硬编码
|
||||
if (!hasAdminScope(ctx)) {
|
||||
const userId = ctx.userId
|
||||
|
||||
const normalizedGradeId = typeof gradeId === "string" ? gradeId.trim() : ""
|
||||
const isOwner = normalizedGradeId
|
||||
? await isGradeHead(normalizedGradeId, userId)
|
||||
: Boolean(await findGradeIdByHeadAndName(userId, grade))
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "Only admins and grade heads can create classes" }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await createTeacherClass({
|
||||
schoolName: schoolName ?? null,
|
||||
schoolId: schoolId ?? null,
|
||||
name,
|
||||
grade,
|
||||
gradeId: gradeId ?? null,
|
||||
homeroom: homeroom ?? null,
|
||||
room: room ?? null,
|
||||
})
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTeacherClassAction(
|
||||
classId: string,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_UPDATE)
|
||||
|
||||
const parsed = UpdateTeacherClassSchema.safeParse({
|
||||
classId,
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, homeroom, room } = parsed.data
|
||||
|
||||
try {
|
||||
await updateTeacherClass(validatedClassId, {
|
||||
schoolName: schoolName ?? undefined,
|
||||
schoolId: schoolId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
grade: grade ?? undefined,
|
||||
gradeId: gradeId ?? undefined,
|
||||
homeroom: homeroom ?? undefined,
|
||||
room: room ?? undefined,
|
||||
})
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTeacherClassAction(classId: string): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_DELETE)
|
||||
|
||||
const parsed = DeleteTeacherClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTeacherClass(parsed.data.classId)
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* 班级模块类型定义。
|
||||
*
|
||||
* 说明(P1-4 审计决策):
|
||||
* 下列 `ClassHomeworkInsights` / `GradeHomeworkInsights` / `ClassHomeworkAssignmentStats`
|
||||
* / `ScoreStats` / `AssignmentSummary` 等类型虽涉及 homework 概念,但它们是
|
||||
* **classes 模块对 homework 数据的视图**(按班级/年级聚合的作业统计),由
|
||||
* `data-access-stats.ts` 产出并被 classes 组件消费。homework 模块自身的
|
||||
* `types.ts` 定义的是作业实体类型(HomeworkAssignmentStatus 等),不包含这些聚合视图类型。
|
||||
* 因此将这些类型保留在 classes 模块,避免让 homework 模块承担 classes 视角的类型定义职责。
|
||||
*/
|
||||
|
||||
export type TeacherClass = {
|
||||
id: string
|
||||
schoolName?: string | null
|
||||
|
||||
Reference in New Issue
Block a user