refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
Some checks failed
CI / build-deploy (push) Has been cancelled
Some checks failed
CI / build-deploy (push) Has been cancelled
- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验 - UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内 - 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过) - 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007) - 项目规则: 架构图优先规则,改码必同步图 - 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫 - 无障碍: skip-link、aria-label、prefers-reduced-motion - 性能: next/font优化、next/image、代码分割
This commit is contained in:
@@ -2,9 +2,10 @@
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, eq, inArray } from "drizzle-orm"
|
||||
import { and, count, eq } from "drizzle-orm"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
@@ -16,51 +17,11 @@ import {
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
roles,
|
||||
users,
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
|
||||
|
||||
type TeacherRole = "admin" | "teacher"
|
||||
type StudentRole = "student"
|
||||
|
||||
const getSessionUserId = async (): Promise<string | null> => {
|
||||
const session = await auth()
|
||||
const userId = String(session?.user?.id ?? "").trim()
|
||||
return userId.length > 0 ? userId : null
|
||||
}
|
||||
|
||||
async function ensureTeacher(): Promise<{ id: string; role: TeacherRole }> {
|
||||
const userId = await getSessionUserId()
|
||||
if (!userId) throw new Error("Unauthorized")
|
||||
const [row] = await db
|
||||
.select({ id: users.id, role: roles.name })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"])))
|
||||
.limit(1)
|
||||
if (!row) throw new Error("Unauthorized")
|
||||
return { id: row.id, role: row.role as TeacherRole }
|
||||
}
|
||||
|
||||
async function ensureStudent(): Promise<{ id: string; role: StudentRole }> {
|
||||
const userId = await getSessionUserId()
|
||||
if (!userId) throw new Error("Unauthorized")
|
||||
const [row] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, userId), eq(roles.name, "student")))
|
||||
.limit(1)
|
||||
if (!row) throw new Error("Unauthorized")
|
||||
return { id: row.id, role: "student" }
|
||||
}
|
||||
|
||||
const parseStudentIds = (raw: string): string[] => {
|
||||
return raw
|
||||
.split(/[,\n\r\t ]+/g)
|
||||
@@ -73,7 +34,7 @@ export async function createHomeworkAssignmentAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureTeacher()
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_CREATE)
|
||||
|
||||
const targetStudentIdsJson = formData.get("targetStudentIdsJson")
|
||||
const targetStudentIdsText = formData.get("targetStudentIdsText")
|
||||
@@ -126,11 +87,11 @@ export async function createHomeworkAssignmentAction(
|
||||
|
||||
if (!exam) return { success: false, message: "Exam not found" }
|
||||
|
||||
if (user.role !== "admin" && classRow.teacherId !== user.id) {
|
||||
if (ctx.dataScope.type !== "all" && classRow.teacherId !== ctx.userId) {
|
||||
const assignedSubjectRows = await db
|
||||
.select({ subjectId: classSubjectTeachers.subjectId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, user.id)))
|
||||
.where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, ctx.userId)))
|
||||
if (assignedSubjectRows.length === 0) {
|
||||
return { success: false, message: "Not assigned to this class" }
|
||||
}
|
||||
@@ -150,10 +111,10 @@ export async function createHomeworkAssignmentAction(
|
||||
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
|
||||
|
||||
const classScope =
|
||||
user.role === "admin"
|
||||
ctx.dataScope.type === "all"
|
||||
? eq(classes.id, input.classId)
|
||||
: classRow.teacherId === user.id
|
||||
? eq(classes.teacherId, user.id)
|
||||
: classRow.teacherId === ctx.userId
|
||||
? eq(classes.teacherId, ctx.userId)
|
||||
: eq(classes.id, input.classId)
|
||||
|
||||
const classStudentIds = (
|
||||
@@ -185,7 +146,7 @@ export async function createHomeworkAssignmentAction(
|
||||
description: input.description ?? null,
|
||||
structure: publish ? (exam.structure as unknown) : null,
|
||||
status: publish ? "published" : "draft",
|
||||
creatorId: user.id,
|
||||
creatorId: ctx.userId,
|
||||
availableAt,
|
||||
dueAt,
|
||||
allowLate: input.allowLate ?? false,
|
||||
@@ -218,8 +179,11 @@ export async function createHomeworkAssignmentAction(
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
|
||||
return { success: true, message: "Assignment created", data: assignmentId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
@@ -229,7 +193,7 @@ export async function startHomeworkSubmissionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureStudent()
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
const assignmentId = formData.get("assignmentId")
|
||||
if (typeof assignmentId !== "string" || assignmentId.length === 0) return { success: false, message: "Missing assignmentId" }
|
||||
|
||||
@@ -240,7 +204,7 @@ export async function startHomeworkSubmissionAction(
|
||||
if (assignment.status !== "published") return { success: false, message: "Assignment not available" }
|
||||
|
||||
const target = await db.query.homeworkAssignmentTargets.findFirst({
|
||||
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, user.id)),
|
||||
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, ctx.userId)),
|
||||
})
|
||||
if (!target) return { success: false, message: "Not assigned" }
|
||||
|
||||
@@ -249,7 +213,7 @@ export async function startHomeworkSubmissionAction(
|
||||
const [attemptRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkSubmissions)
|
||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, user.id)))
|
||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, ctx.userId)))
|
||||
|
||||
const attemptNo = (attemptRow?.c ?? 0) + 1
|
||||
if (attemptNo > assignment.maxAttempts) return { success: false, message: "No attempts left" }
|
||||
@@ -258,7 +222,7 @@ export async function startHomeworkSubmissionAction(
|
||||
await db.insert(homeworkSubmissions).values({
|
||||
id: submissionId,
|
||||
assignmentId,
|
||||
studentId: user.id,
|
||||
studentId: ctx.userId,
|
||||
attemptNo,
|
||||
status: "started",
|
||||
startedAt: new Date(),
|
||||
@@ -267,8 +231,11 @@ export async function startHomeworkSubmissionAction(
|
||||
revalidatePath("/student/learning/assignments")
|
||||
|
||||
return { success: true, message: "Started", data: submissionId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
@@ -278,7 +245,7 @@ export async function saveHomeworkAnswerAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureStudent()
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
const submissionId = formData.get("submissionId")
|
||||
const questionId = formData.get("questionId")
|
||||
const answerJson = formData.get("answerJson")
|
||||
@@ -290,7 +257,7 @@ export async function saveHomeworkAnswerAction(
|
||||
with: { assignment: true },
|
||||
})
|
||||
if (!submission) return { success: false, message: "Submission not found" }
|
||||
if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" }
|
||||
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
||||
if (submission.status !== "started") return { success: false, message: "Submission is locked" }
|
||||
|
||||
const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null
|
||||
@@ -316,8 +283,11 @@ export async function saveHomeworkAnswerAction(
|
||||
})
|
||||
|
||||
return { success: true, message: "Saved", data: submissionId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
@@ -327,7 +297,7 @@ export async function submitHomeworkAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureStudent()
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
const submissionId = formData.get("submissionId")
|
||||
if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" }
|
||||
|
||||
@@ -336,7 +306,7 @@ export async function submitHomeworkAction(
|
||||
with: { assignment: true },
|
||||
})
|
||||
if (!submission) return { success: false, message: "Submission not found" }
|
||||
if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" }
|
||||
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
||||
if (submission.status !== "started") return { success: false, message: "Already submitted" }
|
||||
|
||||
const now = new Date()
|
||||
@@ -358,8 +328,11 @@ export async function submitHomeworkAction(
|
||||
revalidatePath("/student/learning/assignments")
|
||||
|
||||
return { success: true, message: "Submitted", data: submissionId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
@@ -369,7 +342,7 @@ export async function gradeHomeworkSubmissionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await ensureTeacher()
|
||||
await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
|
||||
const rawAnswers = formData.get("answersJson") as string | null
|
||||
const parsed = GradeHomeworkSchema.safeParse({
|
||||
@@ -404,8 +377,11 @@ export async function gradeHomeworkSubmissionAction(
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
|
||||
return { success: true, message: "Grading saved" }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
StudentRanking,
|
||||
TeacherGradeTrendItem,
|
||||
} from "./types"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
|
||||
const recentAssignments = await db.query.homeworkAssignments.findMany({
|
||||
@@ -122,7 +123,7 @@ const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<s
|
||||
return map
|
||||
}
|
||||
|
||||
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[]; classId?: string }) => {
|
||||
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[]; classId?: string; scope?: DataScope }) => {
|
||||
const conditions = []
|
||||
|
||||
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
|
||||
@@ -141,6 +142,37 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
|
||||
}
|
||||
|
||||
// Data scope filtering
|
||||
if (params?.scope) {
|
||||
if (params.scope.type === "owned") {
|
||||
conditions.push(eq(homeworkAssignments.creatorId, params.scope.userId))
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
// Filter homework by assignments targeting students in teacher's classes
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, params.scope.classIds))
|
||||
|
||||
const targetAssignmentIds = db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
// Homework links to exam via sourceExamId, exam has gradeId
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
}
|
||||
// "all" type: no filtering
|
||||
}
|
||||
|
||||
const data = await db.query.homeworkAssignments.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
@@ -168,12 +200,42 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
})
|
||||
})
|
||||
|
||||
export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: string }) => {
|
||||
export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: string; scope?: DataScope }) => {
|
||||
const creatorId = params.creatorId.trim()
|
||||
if (!creatorId) return []
|
||||
|
||||
const conditions = [eq(homeworkAssignments.creatorId, creatorId)]
|
||||
|
||||
// Data scope filtering
|
||||
if (params.scope) {
|
||||
if (params.scope.type === "owned") {
|
||||
// Already filtered by creatorId above
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, params.scope.classIds))
|
||||
|
||||
const targetAssignmentIds = db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
}
|
||||
}
|
||||
|
||||
const assignments = await db.query.homeworkAssignments.findMany({
|
||||
where: eq(homeworkAssignments.creatorId, creatorId),
|
||||
where: and(...conditions),
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
with: { sourceExam: true },
|
||||
})
|
||||
@@ -239,7 +301,7 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
|
||||
})
|
||||
})
|
||||
|
||||
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string; classId?: string; creatorId?: string }) => {
|
||||
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string; classId?: string; creatorId?: string; scope?: DataScope }) => {
|
||||
const conditions = []
|
||||
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
|
||||
if (params?.classId) {
|
||||
@@ -265,6 +327,39 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
|
||||
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
|
||||
}
|
||||
|
||||
// Data scope filtering
|
||||
if (params?.scope) {
|
||||
if (params.scope.type === "owned") {
|
||||
const creatorAssignmentIds = db
|
||||
.select({ assignmentId: homeworkAssignments.id })
|
||||
.from(homeworkAssignments)
|
||||
.where(eq(homeworkAssignments.creatorId, params.scope.userId))
|
||||
|
||||
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, params.scope.classIds))
|
||||
|
||||
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
|
||||
const gradeAssignmentIds = db
|
||||
.select({ assignmentId: homeworkAssignments.id })
|
||||
.from(homeworkAssignments)
|
||||
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
|
||||
conditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query.homeworkSubmissions.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||
@@ -289,7 +384,7 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
|
||||
})
|
||||
})
|
||||
|
||||
export const getHomeworkAssignmentById = cache(async (id: string) => {
|
||||
export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataScope) => {
|
||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
||||
where: eq(homeworkAssignments.id, id),
|
||||
with: {
|
||||
@@ -299,6 +394,41 @@ export const getHomeworkAssignmentById = cache(async (id: string) => {
|
||||
|
||||
if (!assignment) return null
|
||||
|
||||
// Data scope verification for single-item fetch
|
||||
if (scope && scope.type !== "all") {
|
||||
if (scope.type === "owned" && assignment.creatorId !== scope.userId) {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = await db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, scope.gradeIds))
|
||||
const examIds = gradeExamIds.map(e => e.id)
|
||||
if (!examIds.includes(assignment.sourceExamId)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const classStudentIds = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, scope.classIds))
|
||||
const studentIds = classStudentIds.map(s => s.studentId)
|
||||
if (studentIds.length > 0) {
|
||||
const target = await db.query.homeworkAssignmentTargets.findFirst({
|
||||
where: and(
|
||||
eq(homeworkAssignmentTargets.assignmentId, id),
|
||||
inArray(homeworkAssignmentTargets.studentId, studentIds)
|
||||
),
|
||||
})
|
||||
if (!target) return null
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [targetsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkAssignmentTargets)
|
||||
|
||||
Reference in New Issue
Block a user