diff --git a/app/course/[course_id]/dynamicCourseNav.tsx b/app/course/[course_id]/dynamicCourseNav.tsx index 9ff6321a9..b620c5e3f 100644 --- a/app/course/[course_id]/dynamicCourseNav.tsx +++ b/app/course/[course_id]/dynamicCourseNav.tsx @@ -169,6 +169,12 @@ const LinkItems = (courseID: number) => [ feature_flag: COURSE_FEATURES.DISCUSSION }, { name: "Grading Conflicts", icon: FiAlertCircle, target: `/course/${courseID}/manage/course/grading-conflicts` }, + { + name: "Grading Assignment Defaults", + instructors_only: true, + icon: FiClock, + target: `/course/${courseID}/manage/course/grading-assignment-defaults` + }, { name: "Due Date Extensions", instructors_only: true, diff --git a/app/course/[course_id]/manage/assignments/[assignment_id]/edit/page.tsx b/app/course/[course_id]/manage/assignments/[assignment_id]/edit/page.tsx index ced61e114..cc15f6d3e 100644 --- a/app/course/[course_id]/manage/assignments/[assignment_id]/edit/page.tsx +++ b/app/course/[course_id]/manage/assignments/[assignment_id]/edit/page.tsx @@ -11,11 +11,11 @@ import { useForm } from "@refinedev/react-hook-form"; import { useParams } from "next/navigation"; import { useCallback, useEffect } from "react"; import { FieldValues } from "react-hook-form"; -import AssignmentForm from "../../new/form"; +import AssignmentForm, { AssignmentFormValues, normalizeProfileIdSubset } from "../../new/form"; export default function EditAssignment() { const { course_id, assignment_id } = useParams(); - const form = useForm({ + const form = useForm({ refineCoreProps: { resource: "assignments", action: "edit", id: Number.parseInt(assignment_id as string) } }); const { data } = useOne({ resource: "assignments", id: assignment_id as string }); @@ -26,7 +26,21 @@ export default function EditAssignment() { useEffect(() => { if (queryData) { - reset(queryData); + const values = queryData as AssignmentFormValues; + reset({ + ...values, + grading_default_profile_id: values.grading_default_profile_id ?? null, + auto_assign_at_deadline: values.auto_assign_at_deadline ?? false, + auto_assign_assignee_pool: values.auto_assign_assignee_pool ?? "graders", + auto_assign_review_due_hours: values.auto_assign_review_due_hours ?? 72, + auto_assign_grader_subset_private_profile_ids: normalizeProfileIdSubset( + values.auto_assign_grader_subset_private_profile_ids + ), + late_grading_reminders_enabled: values.late_grading_reminders_enabled ?? false, + late_grading_reminder_interval_hours: values.late_grading_reminder_interval_hours ?? 12, + late_grading_reply_to: values.late_grading_reply_to ?? null, + late_grading_cc_emails: values.late_grading_cc_emails ?? { emails: [] } + }); } }, [queryData, reset]); @@ -88,6 +102,22 @@ export default function EditAssignment() { values.eval_config = undefined; values.allow_early = undefined; values.deadline_offset = undefined; + const reminderInterval = values.late_grading_reminder_interval_hours; + values.late_grading_reminder_interval_hours = values.late_grading_reminders_enabled + ? typeof reminderInterval === "number" && Number.isFinite(reminderInterval) + ? reminderInterval + : 12 + : null; + const replyTrimmed = + typeof values.late_grading_reply_to === "string" ? values.late_grading_reply_to.trim() : ""; + values.late_grading_reply_to = replyTrimmed.length > 0 ? replyTrimmed : null; + values.late_grading_cc_emails = values.late_grading_cc_emails ?? { emails: [] }; + + values.auto_assign_grader_subset_private_profile_ids = + values.auto_assign_assignee_pool === "graders" + ? normalizeProfileIdSubset(values.auto_assign_grader_subset_private_profile_ids) + : []; + await form.refineCore.onFinish(values); await revalidateCourseDerivedCachesClient(Number.parseInt(course_id as string, 10)); if (values.template_repo) { diff --git a/app/course/[course_id]/manage/assignments/new/form.tsx b/app/course/[course_id]/manage/assignments/new/form.tsx index 8064b5677..3d8b1dd4a 100644 --- a/app/course/[course_id]/manage/assignments/new/form.tsx +++ b/app/course/[course_id]/manage/assignments/new/form.tsx @@ -12,7 +12,8 @@ import { NativeSelectField, NativeSelectRoot, Table, - Text + Text, + VStack } from "@chakra-ui/react"; import { Controller, FieldValues } from "react-hook-form"; @@ -20,19 +21,85 @@ import { Button } from "@/components/ui/button"; import { Alert } from "@/components/ui/alert"; import { toaster, Toaster } from "@/components/ui/toaster"; import { appendTimezoneOffset } from "@/lib/utils"; -import { Assignment } from "@/utils/supabase/DatabaseTypes"; +import { + Assignment, + GradingAssignmentDefaultProfile, + UserRoleWithPrivateProfileAndUser +} from "@/utils/supabase/DatabaseTypes"; import { TZDate } from "@date-fns/tz"; import { addMinutes } from "date-fns"; import { useList } from "@refinedev/core"; import { UseFormReturnType } from "@refinedev/react-hook-form"; import { useParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { LuCheck } from "react-icons/lu"; import { TimeZoneAwareDate } from "@/components/TimeZoneAwareDate"; import { useClassProfiles } from "@/hooks/useClassProfiles"; import { useCourseController } from "@/hooks/useCourseController"; import { LabSection, LabSectionMeeting } from "@/utils/supabase/DatabaseTypes"; -import { useTableControllerTableValues } from "@/lib/TableController"; +import { useListTableControllerValues, useTableControllerTableValues } from "@/lib/TableController"; + +type GradingAssigneePool = "graders" | "instructors" | "instructors_and_graders" | "lab_leaders" | "group_mentors"; + +type GradingCcEmails = { + emails: string[]; +}; + +export type AssignmentFormValues = Omit< + Assignment, + | "grading_default_profile_id" + | "auto_assign_assignee_pool" + | "auto_assign_grader_subset_private_profile_ids" + | "late_grading_cc_emails" +> & { + grading_default_profile_id: number | null; + auto_assign_assignee_pool: GradingAssigneePool; + auto_assign_grader_subset_private_profile_ids: string[]; + late_grading_cc_emails: GradingCcEmails; + copy_groups_from_assignment?: string; + eval_config?: "base_only" | "use_eval"; + deadline_offset?: number | null; + allow_early?: boolean | null; +}; + +/** Coerce number inputs so toggling visibility never leaves `NaN` in form state (NaN breaks `??` fallbacks). */ +const numberInputValueAs = (emptyFallback: number | null) => (value: unknown) => { + if (value === "" || value === null || value === undefined) { + return emptyFallback; + } + const n = Number(value); + return Number.isFinite(n) ? n : emptyFallback; +}; + +const normalizeCcEmails = (value: unknown): GradingCcEmails => { + if (value && typeof value === "object" && "emails" in value) { + const emails = (value as { emails?: unknown }).emails; + if (Array.isArray(emails)) { + return { + emails: emails + .filter((email): email is string => typeof email === "string") + .map((email) => email.trim()) + .filter((email) => email.length > 0) + }; + } + } + return { emails: [] }; +}; + +/** Parses JSON/array values from API into unique private profile ids for the grader rotator whitelist. */ +export function normalizeProfileIdSubset(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return [ + ...new Set( + value + .filter((id): id is string => typeof id === "string") + .map((id) => id.trim()) + .filter((id) => id.length > 0) + ) + ]; +} // Helper function to calculate effective due date for a lab section function calculateLabSectionDueDate( @@ -73,7 +140,7 @@ function calculateLabSectionDueDate( return addMinutes(labMeetingEndTime, minutesDueAfterLab); } -function LabDueDatePreview({ form, timezone }: { form: UseFormReturnType; timezone: string }) { +function LabDueDatePreview({ form, timezone }: { form: UseFormReturnType; timezone: string }) { const dueDate = form.watch("due_date"); const minutesDueAfterLab = form.watch("minutes_due_after_lab"); const controller = useCourseController(); @@ -165,7 +232,13 @@ function LabDueDatePreview({ form, timezone }: { form: UseFormReturnType; timezone: string }) { +function GroupConfigurationSubform({ + form, + timezone +}: { + form: UseFormReturnType; + timezone: string; +}) { const { course_id } = useParams(); const { data: otherAssignments } = useList({ resource: "assignments", @@ -358,7 +431,7 @@ function GroupConfigurationSubform({ form, timezone }: { form: UseFormReturnType ); } -function LabDueDateSubform({ form }: { form: UseFormReturnType }) { +function LabDueDateSubform({ form }: { form: UseFormReturnType }) { const [withLabDueDate, setWithLabDueDate] = useState(() => { const minutesDueAfterLab = form.getValues("minutes_due_after_lab"); return minutesDueAfterLab !== null && minutesDueAfterLab !== undefined; @@ -444,7 +517,7 @@ function LabDueDateSubform({ form }: { form: UseFormReturnType }) { ); } -function SelfEvaluationSubform({ form }: { form: UseFormReturnType }) { +function SelfEvaluationSubform({ form }: { form: UseFormReturnType }) { const [withEval, setWithEval] = useState(false); const [allowEarly, setAllowEarly] = useState(form.getValues("allow_early") == true); @@ -542,13 +615,359 @@ function SelfEvaluationSubform({ form }: { form: UseFormReturnType } ); } +function GradingAutomationSubform({ + form, + courseId +}: { + form: UseFormReturnType; + courseId: number; +}) { + const { + data: profileData, + isLoading: isProfileLoading, + error: profileError + } = useList({ + resource: "grading_assignment_default_profiles", + filters: [{ field: "class_id", operator: "eq", value: courseId }], + pagination: { pageSize: 200 }, + queryOptions: { enabled: Number.isFinite(courseId) } + }); + const profiles = useMemo(() => profileData?.data ?? [], [profileData?.data]); + const profileMap = useMemo( + () => + profiles.reduce( + (acc, profile) => { + acc[profile.id] = profile; + return acc; + }, + {} as Record + ), + [profiles] + ); + const [applyProfileOnSelect, setApplyProfileOnSelect] = useState(true); + + const { userRolesWithProfiles } = useCourseController(); + const gradersOnlyRoles = useCallback( + (r: UserRoleWithPrivateProfileAndUser) => r.role === "grader" && !r.disabled, + [] + ); + const graderCourseRoles = useListTableControllerValues(userRolesWithProfiles, gradersOnlyRoles); + const gradersSortedForSubsetUi = useMemo( + () => + [...graderCourseRoles].sort( + (a, b) => + (a.profiles?.name || "").localeCompare(b.profiles?.name || "") || + a.private_profile_id.localeCompare(b.private_profile_id) + ), + [graderCourseRoles] + ); + + const { + register, + watch, + setValue, + control, + formState: { errors } + } = form; + + const selectedProfileId = watch("grading_default_profile_id"); + const remindersEnabled = watch("late_grading_reminders_enabled"); + const autoAssignAtDeadline = watch("auto_assign_at_deadline"); + const assigneePool = watch("auto_assign_assignee_pool"); + + useEffect(() => { + if (assigneePool !== "graders") { + setValue("auto_assign_grader_subset_private_profile_ids", []); + } + }, [assigneePool, setValue]); + + const applyProfileValuesToForm = useCallback( + (profile: GradingAssignmentDefaultProfile) => { + setValue("auto_assign_at_deadline", profile.auto_assign_at_deadline); + setValue("auto_assign_assignee_pool", profile.auto_assign_assignee_pool); + setValue("auto_assign_review_due_hours", profile.auto_assign_review_due_hours); + setValue( + "auto_assign_grader_subset_private_profile_ids", + normalizeProfileIdSubset(profile.auto_assign_grader_subset_private_profile_ids) + ); + setValue("late_grading_reminders_enabled", profile.late_grading_reminders_enabled); + setValue("late_grading_reminder_interval_hours", profile.late_grading_reminder_interval_hours); + setValue("late_grading_reply_to", profile.late_grading_reply_to); + setValue("late_grading_cc_emails", normalizeCcEmails(profile.late_grading_cc_emails)); + }, + [setValue] + ); + + return ( + + + Grading Automation Defaults + + + + + + { + const rawValue = event.target.value; + const newId = rawValue ? Number(rawValue) : null; + setValue("grading_default_profile_id", newId, { shouldDirty: true }); + // Apply only on user-initiated changes (not on the initial form reset from + // saved DB state, which would clobber per-assignment overrides on edit). + if (newId !== null && applyProfileOnSelect) { + const newProfile = profileMap[newId]; + if (newProfile) applyProfileValuesToForm(newProfile); + } + }} + > + + {profiles.map((profile) => ( + + ))} + + + + + + + setApplyProfileOnSelect(!!checked.checked)} + > + + + + + Apply profile settings on selection + + + + {profileError && ( + + {`${profileError.message}`} + + )} + {isProfileLoading && ( + + + Loading grading profiles... + + + )} + + + ( + field.onChange(!!checked.checked)} + > + + + + + Auto assign grading at deadline + + )} + /> + + + {autoAssignAtDeadline && ( + <> + + + + + + + + + + + + + + {assigneePool === "graders" && ( + + + { + const selected = normalizeProfileIdSubset(field.value); + return ( + + {gradersSortedForSubsetUi.length === 0 ? ( + + No graders in this course yet. + + ) : ( + gradersSortedForSubsetUi.map((role) => { + const pid = role.private_profile_id; + const isChecked = selected.includes(pid); + return ( + { + const checked = !!change.checked; + if (checked) { + field.onChange(normalizeProfileIdSubset([...selected, pid])); + } else { + field.onChange(selected.filter((existing) => existing !== pid)); + } + }} + > + + + + + {role.profiles?.name || "Unknown"} + + ); + }) + )} + + ); + }} + /> + + + )} + + + + + + + )} + + + ( + field.onChange(!!checked.checked)} + > + + + + + Enable late grading reminders + + )} + /> + + + {remindersEnabled && ( + <> + + + + + + + + + + + + + ( + { + const emails = event.target.value + .split(",") + .map((email) => email.trim()) + .filter((email) => email.length > 0); + field.onChange({ emails }); + }} + placeholder="staff1@example.edu, staff2@example.edu" + /> + )} + /> + + + + )} + + + ); +} + export default function AssignmentForm({ form, onSubmit }: { - form: UseFormReturnType; + form: UseFormReturnType; onSubmit: (values: FieldValues) => void; }) { + const { course_id } = useParams(); const { handleSubmit, register, @@ -841,6 +1260,7 @@ export default function AssignmentForm({ + ({ + const form = useForm({ refineCoreProps: { resource: "assignments", action: "create" }, defaultValues: { allow_not_graded_submissions: true, permit_empty_submissions: true, - require_tokens_before_due_date: true + require_tokens_before_due_date: true, + grading_default_profile_id: null, + auto_assign_at_deadline: false, + auto_assign_assignee_pool: "graders", + auto_assign_review_due_hours: 72, + auto_assign_grader_subset_private_profile_ids: [], + late_grading_reminders_enabled: false, + late_grading_reminder_interval_hours: 12, + late_grading_reply_to: null, + late_grading_cc_emails: { emails: [] } } }); const router = useRouter(); - const { getValues } = form; const { time_zone } = useCourse(); const timezone = time_zone || "America/New_York"; @@ -56,14 +63,14 @@ export default function NewAssignmentPage() { try { const supabase = createClient(); // create the self eval configuration first - const isEnabled = getValues("eval_config") === "use_eval"; + const isEnabled = form.getValues("eval_config") === "use_eval"; const settings = await mutateAsync( { resource: "assignment_self_review_settings", values: { enabled: isEnabled, - deadline_offset: isEnabled ? getValues("deadline_offset") : null, - allow_early: isEnabled ? getValues("allow_early") : null, + deadline_offset: isEnabled ? form.getValues("deadline_offset") : null, + allow_early: isEnabled ? form.getValues("allow_early") : null, class_id: course_id } }, @@ -78,36 +85,54 @@ export default function NewAssignmentPage() { return; } + const remindersEnabled = form.getValues("late_grading_reminders_enabled") ?? false; + const assigneePool = form.getValues("auto_assign_assignee_pool") ?? "graders"; + const graderSubset = + assigneePool === "graders" + ? normalizeProfileIdSubset(form.getValues("auto_assign_grader_subset_private_profile_ids")) + : []; + const { data, error } = await supabase .from("assignments") .insert({ - title: getValues("title"), - slug: getValues("slug"), - release_date: getValues("release_date") - ? new TZDate(getValues("release_date"), timezone).toISOString() + title: form.getValues("title"), + slug: form.getValues("slug"), + release_date: form.getValues("release_date") + ? new TZDate(form.getValues("release_date"), timezone).toISOString() : "", - due_date: getValues("due_date") ? new TZDate(getValues("due_date"), timezone).toISOString() : "", - allow_late: getValues("allow_late"), - description: getValues("description"), - max_late_tokens: getValues("max_late_tokens") || null, - require_tokens_before_due_date: getValues("require_tokens_before_due_date") !== false, - allow_not_graded_submissions: getValues("allow_not_graded_submissions"), - permit_empty_submissions: getValues("permit_empty_submissions") !== false, - total_points: getValues("total_points"), - template_repo: getValues("template_repo"), - submission_files: getValues("submission_files"), + due_date: form.getValues("due_date") ? new TZDate(form.getValues("due_date"), timezone).toISOString() : "", + allow_late: form.getValues("allow_late"), + description: form.getValues("description"), + max_late_tokens: form.getValues("max_late_tokens") || null, + require_tokens_before_due_date: form.getValues("require_tokens_before_due_date") !== false, + allow_not_graded_submissions: form.getValues("allow_not_graded_submissions"), + permit_empty_submissions: form.getValues("permit_empty_submissions") !== false, + total_points: form.getValues("total_points"), + template_repo: form.getValues("template_repo"), + submission_files: form.getValues("submission_files"), has_autograder: true, has_handgrader: true, class_id: Number.parseInt(course_id as string), - group_config: getValues("group_config"), - min_group_size: getValues("min_group_size") || null, - max_group_size: getValues("max_group_size") || null, - allow_student_formed_groups: getValues("allow_student_formed_groups"), - enable_repo_analytics: getValues("enable_repo_analytics") || false, + group_config: form.getValues("group_config"), + min_group_size: form.getValues("min_group_size") || null, + max_group_size: form.getValues("max_group_size") || null, + allow_student_formed_groups: form.getValues("allow_student_formed_groups"), + enable_repo_analytics: form.getValues("enable_repo_analytics") || false, self_review_setting_id: settings.data.id as number, - group_formation_deadline: getValues("group_formation_deadline") - ? new TZDate(getValues("group_formation_deadline"), timezone).toISOString() - : null + group_formation_deadline: form.getValues("group_formation_deadline") + ? new TZDate(form.getValues("group_formation_deadline"), timezone).toISOString() + : null, + grading_default_profile_id: form.getValues("grading_default_profile_id") ?? null, + auto_assign_at_deadline: form.getValues("auto_assign_at_deadline") ?? false, + auto_assign_assignee_pool: assigneePool, + auto_assign_review_due_hours: form.getValues("auto_assign_review_due_hours") ?? 72, + auto_assign_grader_subset_private_profile_ids: graderSubset, + late_grading_reminders_enabled: remindersEnabled, + late_grading_reminder_interval_hours: remindersEnabled + ? (form.getValues("late_grading_reminder_interval_hours") ?? 12) + : null, + late_grading_reply_to: form.getValues("late_grading_reply_to") ?? null, + late_grading_cc_emails: form.getValues("late_grading_cc_emails") ?? { emails: [] } }) .select("id") .single(); @@ -126,10 +151,10 @@ export default function NewAssignmentPage() { supabase ); //Potentially copy groups from another assignment - if (getValues("copy_groups_from_assignment")) { + if (form.getValues("copy_groups_from_assignment")) { await assignmentGroupCopyGroupsFromAssignment( { - source_assignment_id: getValues("copy_groups_from_assignment"), + source_assignment_id: form.getValues("copy_groups_from_assignment"), target_assignment_id: data.id, class_id: Number.parseInt(course_id as string) }, @@ -160,7 +185,7 @@ export default function NewAssignmentPage() { } } await create(); - }, [course_id, getValues, router, mutateAsync, timezone]); + }, [course_id, form, router, mutateAsync, timezone]); return ( Create New Assignment diff --git a/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx b/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx new file mode 100644 index 000000000..127839dbf --- /dev/null +++ b/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx @@ -0,0 +1,536 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Field } from "@/components/ui/field"; +import { toaster } from "@/components/ui/toaster"; +import { useCourseController } from "@/hooks/useCourseController"; +import { useListTableControllerValues } from "@/lib/TableController"; +import { createClient } from "@/utils/supabase/client"; +import type { + GradingAssignmentDefaultProfile, + UserRoleWithPrivateProfileAndUser +} from "@/utils/supabase/DatabaseTypes"; +import { normalizeProfileIdSubset } from "../../assignments/new/form"; +import { + Box, + CardBody, + CardHeader, + CardRoot, + CardTitle, + Checkbox, + Fieldset, + Heading, + HStack, + Input, + NativeSelectField, + NativeSelectRoot, + Text, + VStack +} from "@chakra-ui/react"; +import { useCreate, useDelete, useList, useUpdate } from "@refinedev/core"; +import { useParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { LuCheck } from "react-icons/lu"; + +type GradingCcEmails = { emails: string[] }; + +type FormValues = { + name: GradingAssignmentDefaultProfile["name"]; + description: string; + auto_assign_at_deadline: GradingAssignmentDefaultProfile["auto_assign_at_deadline"]; + auto_assign_assignee_pool: GradingAssignmentDefaultProfile["auto_assign_assignee_pool"]; + auto_assign_review_due_hours: GradingAssignmentDefaultProfile["auto_assign_review_due_hours"]; + auto_assign_grader_subset_private_profile_ids: string[]; + late_grading_reminders_enabled: GradingAssignmentDefaultProfile["late_grading_reminders_enabled"]; + late_grading_reminder_interval_hours: GradingAssignmentDefaultProfile["late_grading_reminder_interval_hours"]; + late_grading_reply_to: string; + late_grading_cc_emails: GradingCcEmails; +}; + +const defaultValues: FormValues = { + name: "", + description: "", + auto_assign_at_deadline: false, + auto_assign_assignee_pool: "graders", + auto_assign_review_due_hours: 72, + auto_assign_grader_subset_private_profile_ids: [], + late_grading_reminders_enabled: false, + late_grading_reminder_interval_hours: 12, + late_grading_reply_to: "", + late_grading_cc_emails: { emails: [] } +}; + +const parseCcEmails = (value: string): GradingCcEmails => ({ + emails: value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) +}); + +const normalizeCcEmails = (value: unknown): GradingCcEmails => { + if (value && typeof value === "object" && "emails" in value) { + const emails = (value as { emails?: unknown }).emails; + if (Array.isArray(emails)) { + return { + emails: emails + .filter((email): email is string => typeof email === "string") + .map((email) => email.trim()) + .filter((email) => email.length > 0) + }; + } + } + + return { emails: [] }; +}; + +const toCcText = (value: unknown): string => normalizeCcEmails(value).emails.join(", "); + +const numberInputValueAs = (emptyFallback: number | null) => (value: unknown) => { + if (value === "" || value === null || value === undefined) { + return emptyFallback; + } + const n = Number(value); + return Number.isFinite(n) ? n : emptyFallback; +}; + +export default function GradingAssignmentDefaultsPage() { + const { course_id } = useParams(); + const classId = Number(course_id); + const isValidClassId = Number.isFinite(classId); + const [editingId, setEditingId] = useState(null); + + const form = useForm({ defaultValues }); + const { + register, + control, + watch, + setValue, + reset, + formState: { errors, isSubmitting } + } = form; + + const remindersEnabled = watch("late_grading_reminders_enabled"); + const autoAssignEnabled = watch("auto_assign_at_deadline"); + const assigneePool = watch("auto_assign_assignee_pool"); + const ccValue = watch("late_grading_cc_emails"); + + const { userRolesWithProfiles } = useCourseController(); + const gradersOnly = useCallback((r: UserRoleWithPrivateProfileAndUser) => r.role === "grader" && !r.disabled, []); + const graderCourseRoles = useListTableControllerValues(userRolesWithProfiles, gradersOnly); + const gradersSortedForSubsetUi = useMemo( + () => + [...graderCourseRoles].sort( + (a, b) => + (a.profiles?.name || "").localeCompare(b.profiles?.name || "") || + a.private_profile_id.localeCompare(b.private_profile_id) + ), + [graderCourseRoles] + ); + + useEffect(() => { + if (assigneePool !== "graders") { + setValue("auto_assign_grader_subset_private_profile_ids", []); + } + }, [assigneePool, setValue]); + const ccText = toCcText(ccValue); + + const { data: profileData, refetch } = useList({ + resource: "grading_assignment_default_profiles", + filters: [{ field: "class_id", operator: "eq", value: classId }], + sorters: [{ field: "name", order: "asc" }], + pagination: { pageSize: 200 }, + queryOptions: { enabled: isValidClassId } + }); + + const profiles = useMemo(() => profileData?.data ?? [], [profileData?.data]); + + const { mutateAsync: createProfile } = useCreate(); + const { mutateAsync: updateProfile } = useUpdate(); + const { mutateAsync: deleteProfile } = useDelete(); + + const startEdit = (profile: GradingAssignmentDefaultProfile) => { + setEditingId(profile.id); + reset({ + name: profile.name, + description: profile.description ?? "", + auto_assign_at_deadline: profile.auto_assign_at_deadline, + auto_assign_assignee_pool: profile.auto_assign_assignee_pool, + auto_assign_review_due_hours: profile.auto_assign_review_due_hours, + auto_assign_grader_subset_private_profile_ids: normalizeProfileIdSubset( + profile.auto_assign_grader_subset_private_profile_ids + ), + late_grading_reminders_enabled: profile.late_grading_reminders_enabled, + late_grading_reminder_interval_hours: profile.late_grading_reminder_interval_hours ?? 12, + late_grading_reply_to: profile.late_grading_reply_to ?? "", + late_grading_cc_emails: normalizeCcEmails(profile.late_grading_cc_emails) + }); + }; + + const clearForm = () => { + setEditingId(null); + reset(defaultValues); + }; + + const onSubmit = form.handleSubmit(async (values) => { + if (!isValidClassId) { + toaster.error({ + title: "Invalid course", + description: "Cannot save grading defaults without a valid course id." + }); + return; + } + + const payload = { + class_id: classId, + name: values.name.trim(), + description: values.description?.trim() || null, + auto_assign_at_deadline: values.auto_assign_at_deadline, + auto_assign_assignee_pool: values.auto_assign_assignee_pool, + auto_assign_review_due_hours: values.auto_assign_review_due_hours ?? 72, + auto_assign_grader_subset_private_profile_ids: + values.auto_assign_assignee_pool === "graders" + ? normalizeProfileIdSubset(values.auto_assign_grader_subset_private_profile_ids) + : [], + late_grading_reminders_enabled: values.late_grading_reminders_enabled, + late_grading_reminder_interval_hours: values.late_grading_reminders_enabled + ? (values.late_grading_reminder_interval_hours ?? 12) + : null, + late_grading_reply_to: values.late_grading_reply_to?.trim() || null, + late_grading_cc_emails: normalizeCcEmails(values.late_grading_cc_emails) + }; + + try { + if (editingId) { + await updateProfile({ + resource: "grading_assignment_default_profiles", + id: editingId, + values: payload + }); + toaster.success({ title: "Profile updated" }); + } else { + await createProfile({ + resource: "grading_assignment_default_profiles", + values: payload + }); + toaster.success({ title: "Profile created" }); + } + clearForm(); + await refetch(); + } catch (error) { + toaster.error({ + title: editingId ? "Failed to update profile" : "Failed to create profile", + description: error instanceof Error ? error.message : "Unknown error" + }); + } + }); + + const handleDelete = async (id: number) => { + const supabase = createClient(); + const { count, error: countError } = await supabase + .from("assignments") + .select("*", { count: "exact", head: true }) + .eq("grading_default_profile_id", id); + + if (countError) { + toaster.error({ + title: "Could not check assignment references", + description: countError.message + }); + return; + } + + const refCount = count ?? 0; + const refNote = + refCount > 0 + ? ` ${refCount} assignment${refCount === 1 ? "" : "s"} reference this profile; those assignments will show no saved profile after deletion.` + : ""; + const confirmed = window.confirm(`Delete this grading default profile?${refNote}`); + if (!confirmed) { + return; + } + + try { + await deleteProfile({ resource: "grading_assignment_default_profiles", id }); + toaster.success({ title: "Profile deleted" }); + if (editingId === id) { + clearForm(); + } + await refetch(); + } catch (error) { + toaster.error({ + title: "Failed to delete profile", + description: error instanceof Error ? error.message : "Unknown error" + }); + } + }; + + if (!isValidClassId) { + return ( + + Grading Assignment Defaults + Invalid course id. + + ); + } + + return ( + + + + Grading Assignment Defaults + + Create reusable profiles for grading auto-assignment at deadline and late grading reminders. Instructors can + apply these profiles when creating or editing assignments. + + + + + + {editingId ? "Edit grading profile" : "New grading profile"} + + +
+ + + + + + + + + + + + + + ( + field.onChange(!!checked.checked)} + > + + + + + Auto assign at deadline + + )} + /> + + + {autoAssignEnabled && ( + <> + + + + + + + + + + + + + + {assigneePool === "graders" && ( + + + { + const selected = normalizeProfileIdSubset(field.value); + return ( + + {gradersSortedForSubsetUi.length === 0 ? ( + + No graders in this course yet. + + ) : ( + gradersSortedForSubsetUi.map((role) => { + const pid = role.private_profile_id; + const isChecked = selected.includes(pid); + return ( + { + const checked = !!change.checked; + if (checked) { + field.onChange(normalizeProfileIdSubset([...selected, pid])); + } else { + field.onChange(selected.filter((existing) => existing !== pid)); + } + }} + > + + + + + {role.profiles?.name || "Unknown"} + + ); + }) + )} + + ); + }} + /> + + + )} + + + + + + + )} + + + ( + field.onChange(!!checked.checked)} + > + + + + + Enable late grading reminders + + )} + /> + + + {remindersEnabled && ( + <> + + + + + + + + + + + + + setValue("late_grading_cc_emails", parseCcEmails(event.target.value))} + placeholder="staff@example.edu, lead-ta@example.edu" + /> + + + + )} + + + + {editingId && ( + + )} + + + +
+
+
+ + + + Saved profiles + + + {profiles.length === 0 ? ( + No grading default profiles yet. + ) : ( + + {profiles.map((profile) => ( + + + + {profile.name} + {profile.description && {profile.description}} + + Auto assign: {profile.auto_assign_at_deadline ? "on" : "off"} | Reminder:{" "} + {profile.late_grading_reminders_enabled + ? `every ${profile.late_grading_reminder_interval_hours}h` + : "off"} + + + + + + + + + ))} + + )} + + +
+
+ ); +} diff --git a/supabase/functions/_shared/SupabaseTypes.d.ts b/supabase/functions/_shared/SupabaseTypes.d.ts index 83bf9b5f7..d94bb4dfd 100644 --- a/supabase/functions/_shared/SupabaseTypes.d.ts +++ b/supabase/functions/_shared/SupabaseTypes.d.ts @@ -296,6 +296,79 @@ export type Database = { } ]; }; + assignment_grading_automation_state: { + Row: { + assignment_id: number; + auto_assigned_at: string | null; + class_id: number; + created_at: string; + last_reminder_recipient_count: number; + last_reminder_sent_at: string | null; + updated_at: string; + }; + Insert: { + assignment_id: number; + auto_assigned_at?: string | null; + class_id: number; + created_at?: string; + last_reminder_recipient_count?: number; + last_reminder_sent_at?: string | null; + updated_at?: string; + }; + Update: { + assignment_id?: number; + auto_assigned_at?: string | null; + class_id?: number; + created_at?: string; + last_reminder_recipient_count?: number; + last_reminder_sent_at?: string | null; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "assignment_grading_automation_state_assignment_id_fkey"; + columns: ["assignment_id"]; + isOneToOne: true; + referencedRelation: "assignment_overview"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "assignment_grading_automation_state_assignment_id_fkey"; + columns: ["assignment_id"]; + isOneToOne: true; + referencedRelation: "assignments"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "assignment_grading_automation_state_assignment_id_fkey"; + columns: ["assignment_id"]; + isOneToOne: true; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "assignment_grading_automation_state_assignment_id_fkey"; + columns: ["assignment_id"]; + isOneToOne: true; + referencedRelation: "assignments_with_effective_due_dates"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "assignment_grading_automation_state_assignment_id_fkey"; + columns: ["assignment_id"]; + isOneToOne: true; + referencedRelation: "submissions_with_grades_for_assignment_and_regression_test"; + referencedColumns: ["assignment_id"]; + }, + { + foreignKeyName: "assignment_grading_automation_state_class_id_fkey"; + columns: ["class_id"]; + isOneToOne: false; + referencedRelation: "classes"; + referencedColumns: ["id"]; + } + ]; + }; assignment_group_invitations: { Row: { assignment_group_id: number; @@ -1018,6 +1091,10 @@ export type Database = { allow_not_graded_submissions: boolean; allow_student_formed_groups: boolean | null; archived_at: string | null; + auto_assign_assignee_pool: string; + auto_assign_at_deadline: boolean; + auto_assign_review_due_hours: number; + auto_assign_grader_subset_private_profile_ids: Json; autograder_points: number | null; class_id: number; created_at: string; @@ -1026,6 +1103,7 @@ export type Database = { enable_repo_analytics: boolean; gradebook_column_id: number | null; grader_pseudonymous_mode: boolean; + grading_default_profile_id: number | null; grading_rubric_id: number | null; group_config: Database["public"]["Enums"]["assignment_group_mode"]; group_formation_deadline: string | null; @@ -1033,6 +1111,10 @@ export type Database = { has_autograder: boolean; has_handgrader: boolean; id: number; + late_grading_cc_emails: Json; + late_grading_reminder_interval_hours: number | null; + late_grading_reminders_enabled: boolean; + late_grading_reply_to: string | null; latest_template_sha: string | null; max_group_size: number | null; max_late_tokens: number; @@ -1057,6 +1139,10 @@ export type Database = { allow_not_graded_submissions?: boolean; allow_student_formed_groups?: boolean | null; archived_at?: string | null; + auto_assign_assignee_pool?: string; + auto_assign_at_deadline?: boolean; + auto_assign_review_due_hours?: number; + auto_assign_grader_subset_private_profile_ids?: Json; autograder_points?: number | null; class_id: number; created_at?: string; @@ -1065,6 +1151,7 @@ export type Database = { enable_repo_analytics?: boolean; gradebook_column_id?: number | null; grader_pseudonymous_mode?: boolean; + grading_default_profile_id?: number | null; grading_rubric_id?: number | null; group_config: Database["public"]["Enums"]["assignment_group_mode"]; group_formation_deadline?: string | null; @@ -1072,6 +1159,10 @@ export type Database = { has_autograder?: boolean; has_handgrader?: boolean; id?: number; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; latest_template_sha?: string | null; max_group_size?: number | null; max_late_tokens?: number; @@ -1096,6 +1187,10 @@ export type Database = { allow_not_graded_submissions?: boolean; allow_student_formed_groups?: boolean | null; archived_at?: string | null; + auto_assign_assignee_pool?: string; + auto_assign_at_deadline?: boolean; + auto_assign_review_due_hours?: number; + auto_assign_grader_subset_private_profile_ids?: Json; autograder_points?: number | null; class_id?: number; created_at?: string; @@ -1104,6 +1199,7 @@ export type Database = { enable_repo_analytics?: boolean; gradebook_column_id?: number | null; grader_pseudonymous_mode?: boolean; + grading_default_profile_id?: number | null; grading_rubric_id?: number | null; group_config?: Database["public"]["Enums"]["assignment_group_mode"]; group_formation_deadline?: string | null; @@ -1111,6 +1207,10 @@ export type Database = { has_autograder?: boolean; has_handgrader?: boolean; id?: number; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; latest_template_sha?: string | null; max_group_size?: number | null; max_late_tokens?: number; @@ -1139,6 +1239,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "assignments_grading_default_profile_id_fkey"; + columns: ["grading_default_profile_id", "class_id"]; + isOneToOne: false; + referencedRelation: "grading_assignment_default_profiles"; + referencedColumns: ["id", "class_id"]; + }, { foreignKeyName: "assignments_meta_grading_rubric_id_fkey"; columns: ["meta_grading_rubric_id"]; @@ -4093,6 +4200,65 @@ export type Database = { } ]; }; + grading_assignment_default_profiles: { + Row: { + auto_assign_assignee_pool: string; + auto_assign_at_deadline: boolean; + auto_assign_review_due_hours: number; + auto_assign_grader_subset_private_profile_ids: Json; + class_id: number; + created_at: string; + description: string | null; + id: number; + late_grading_cc_emails: Json; + late_grading_reminder_interval_hours: number | null; + late_grading_reminders_enabled: boolean; + late_grading_reply_to: string | null; + name: string; + updated_at: string; + }; + Insert: { + auto_assign_assignee_pool?: string; + auto_assign_at_deadline?: boolean; + auto_assign_review_due_hours?: number; + auto_assign_grader_subset_private_profile_ids?: Json; + class_id: number; + created_at?: string; + description?: string | null; + id?: number; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; + name: string; + updated_at?: string; + }; + Update: { + auto_assign_assignee_pool?: string; + auto_assign_at_deadline?: boolean; + auto_assign_review_due_hours?: number; + auto_assign_grader_subset_private_profile_ids?: Json; + class_id?: number; + created_at?: string; + description?: string | null; + id?: number; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; + name?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "grading_assignment_default_profiles_class_id_fkey"; + columns: ["class_id"]; + isOneToOne: false; + referencedRelation: "classes"; + referencedColumns: ["id"]; + } + ]; + }; grading_conflicts: { Row: { class_id: number; @@ -11352,6 +11518,10 @@ export type Database = { | { Args: { poll__id: number }; Returns: boolean } | { Args: { class__id: number; poll__id: number }; Returns: boolean }; authorizeforprofile: { Args: { profile_id: string }; Returns: boolean }; + auto_assign_grading_reviews_for_assignment: { + Args: { p_assignment_id: number }; + Returns: number; + }; bulk_assign_reviews: { Args: { p_assignment_id: number; @@ -12483,6 +12653,10 @@ export type Database = { }; Returns: Json; }; + queue_late_grading_reminders_for_assignment: { + Args: { p_assignment_id: number }; + Returns: number; + }; queue_repository_syncs: { Args: { p_repository_ids: number[] }; Returns: Json; @@ -12545,6 +12719,7 @@ export type Database = { Args: { p_class_id?: number }; Returns: undefined; }; + run_assignment_grading_automation: { Args: never; Returns: undefined }; safe_broadcast: { Args: { p_channel: string; diff --git a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql new file mode 100644 index 000000000..eccd9b298 --- /dev/null +++ b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql @@ -0,0 +1,1052 @@ +-- Class-level grading automation profiles + per-assignment defaults/reminders +-- Implements: +-- 1) "Auto assign at deadline" defaults via reusable class profiles +-- (staff rotation / lab leaders / group mentors / optional subset of graders) +-- 2) Late grading reminders at deadline + recurring interval +-- 3) Instructor-configurable CC + reply-to for reminder emails + +-- Postgres does not allow subqueries directly in CHECK constraints, but it does +-- allow CHECKs to call functions whose bodies contain subqueries. Wrap the +-- "array of strings" predicate so both the profile and assignment tables can +-- reuse it. +CREATE OR REPLACE FUNCTION public.jsonb_is_string_array(p jsonb) +RETURNS boolean +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT + jsonb_typeof(p) = 'array' + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements(p) AS elt + WHERE jsonb_typeof(elt) IS DISTINCT FROM 'string' + ); +$$; + +CREATE TABLE IF NOT EXISTS public.grading_assignment_default_profiles ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + class_id bigint NOT NULL REFERENCES public.classes(id) ON DELETE CASCADE, + name text NOT NULL, + description text, + auto_assign_at_deadline boolean NOT NULL DEFAULT false, + auto_assign_assignee_pool text NOT NULL DEFAULT 'graders', + auto_assign_review_due_hours integer NOT NULL DEFAULT 72, + auto_assign_grader_subset_private_profile_ids jsonb NOT NULL DEFAULT '[]'::jsonb, + late_grading_reminders_enabled boolean NOT NULL DEFAULT false, + late_grading_reminder_interval_hours integer, + late_grading_reply_to text, + late_grading_cc_emails jsonb NOT NULL DEFAULT jsonb_build_object('emails', jsonb_build_array()), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT grading_assignment_default_profiles_assignee_pool_check CHECK ( + auto_assign_assignee_pool IN ( + 'graders', + 'instructors', + 'instructors_and_graders', + 'lab_leaders', + 'group_mentors' + ) + ), + CONSTRAINT grading_assignment_default_profiles_grader_subset_ids_check CHECK ( + public.jsonb_is_string_array(auto_assign_grader_subset_private_profile_ids) + ), + CONSTRAINT grading_assignment_default_profiles_due_hours_check CHECK (auto_assign_review_due_hours >= 0), + CONSTRAINT grading_assignment_default_profiles_reminder_interval_check CHECK ( + (NOT late_grading_reminders_enabled) + OR (late_grading_reminder_interval_hours IS NOT NULL AND late_grading_reminder_interval_hours > 0) + ), + CONSTRAINT grading_assignment_default_profiles_cc_emails_check CHECK ( + jsonb_typeof(late_grading_cc_emails) = 'object' + AND late_grading_cc_emails ? 'emails' + AND jsonb_typeof(late_grading_cc_emails->'emails') = 'array' + ), + CONSTRAINT grading_assignment_default_profiles_class_name_unique UNIQUE (class_id, name), + CONSTRAINT grading_assignment_default_profiles_id_class_unique UNIQUE (id, class_id) +); + +ALTER TABLE public.grading_assignment_default_profiles ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Class staff can view grading default profiles" ON public.grading_assignment_default_profiles; +CREATE POLICY "Class staff can view grading default profiles" +ON public.grading_assignment_default_profiles +AS PERMISSIVE +FOR SELECT +TO authenticated +USING (authorizeforclassinstructor(class_id) OR authorizeforclassgrader(class_id)); + +DROP POLICY IF EXISTS "Instructors can insert grading default profiles" ON public.grading_assignment_default_profiles; +CREATE POLICY "Instructors can insert grading default profiles" +ON public.grading_assignment_default_profiles +AS PERMISSIVE +FOR INSERT +TO authenticated +WITH CHECK (authorizeforclassinstructor(class_id)); + +DROP POLICY IF EXISTS "Instructors can update grading default profiles" ON public.grading_assignment_default_profiles; +CREATE POLICY "Instructors can update grading default profiles" +ON public.grading_assignment_default_profiles +AS PERMISSIVE +FOR UPDATE +TO authenticated +USING (authorizeforclassinstructor(class_id)) +WITH CHECK (authorizeforclassinstructor(class_id)); + +DROP POLICY IF EXISTS "Instructors can delete grading default profiles" ON public.grading_assignment_default_profiles; +CREATE POLICY "Instructors can delete grading default profiles" +ON public.grading_assignment_default_profiles +AS PERMISSIVE +FOR DELETE +TO authenticated +USING (authorizeforclassinstructor(class_id)); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' AND p.proname = 'set_updated_at' + ) THEN + DROP TRIGGER IF EXISTS set_updated_at_on_grading_assignment_default_profiles ON public.grading_assignment_default_profiles; + CREATE TRIGGER set_updated_at_on_grading_assignment_default_profiles + BEFORE UPDATE ON public.grading_assignment_default_profiles + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + END IF; +END $$; + +ALTER TABLE public.assignments + ADD COLUMN IF NOT EXISTS grading_default_profile_id bigint, + ADD COLUMN IF NOT EXISTS auto_assign_at_deadline boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS auto_assign_assignee_pool text NOT NULL DEFAULT 'graders', + ADD COLUMN IF NOT EXISTS auto_assign_review_due_hours integer NOT NULL DEFAULT 72, + ADD COLUMN IF NOT EXISTS auto_assign_grader_subset_private_profile_ids jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS late_grading_reminders_enabled boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS late_grading_reminder_interval_hours integer, + ADD COLUMN IF NOT EXISTS late_grading_reply_to text, + ADD COLUMN IF NOT EXISTS late_grading_cc_emails jsonb NOT NULL DEFAULT jsonb_build_object('emails', jsonb_build_array()); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'assignments_grading_default_profile_id_fkey' + ) THEN + ALTER TABLE public.assignments + ADD CONSTRAINT assignments_grading_default_profile_id_fkey + FOREIGN KEY (grading_default_profile_id, class_id) + REFERENCES public.grading_assignment_default_profiles(id, class_id) + ON DELETE SET NULL (grading_default_profile_id); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'assignments_auto_assign_assignee_pool_check' + ) THEN + ALTER TABLE public.assignments + ADD CONSTRAINT assignments_auto_assign_assignee_pool_check + CHECK ( + auto_assign_assignee_pool IN ( + 'graders', + 'instructors', + 'instructors_and_graders', + 'lab_leaders', + 'group_mentors' + ) + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'assignments_auto_assign_grader_subset_ids_check' + ) THEN + ALTER TABLE public.assignments + ADD CONSTRAINT assignments_auto_assign_grader_subset_ids_check CHECK ( + public.jsonb_is_string_array(auto_assign_grader_subset_private_profile_ids) + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'assignments_auto_assign_review_due_hours_check' + ) THEN + ALTER TABLE public.assignments + ADD CONSTRAINT assignments_auto_assign_review_due_hours_check + CHECK (auto_assign_review_due_hours >= 0); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'assignments_late_grading_reminder_interval_check' + ) THEN + ALTER TABLE public.assignments + ADD CONSTRAINT assignments_late_grading_reminder_interval_check + CHECK ( + (NOT late_grading_reminders_enabled) + OR (late_grading_reminder_interval_hours IS NOT NULL AND late_grading_reminder_interval_hours > 0) + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'assignments_late_grading_cc_emails_check' + ) THEN + ALTER TABLE public.assignments + ADD CONSTRAINT assignments_late_grading_cc_emails_check + CHECK ( + jsonb_typeof(late_grading_cc_emails) = 'object' + AND late_grading_cc_emails ? 'emails' + AND jsonb_typeof(late_grading_cc_emails->'emails') = 'array' + ); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS public.assignment_grading_automation_state ( + assignment_id bigint PRIMARY KEY REFERENCES public.assignments(id) ON DELETE CASCADE, + class_id bigint NOT NULL REFERENCES public.classes(id) ON DELETE CASCADE, + auto_assigned_at timestamptz, + last_reminder_sent_at timestamptz, + last_reminder_recipient_count integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.assignment_grading_automation_state ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Service role manages assignment grading automation state" ON public.assignment_grading_automation_state; +CREATE POLICY "Service role manages assignment grading automation state" +ON public.assignment_grading_automation_state +AS PERMISSIVE +FOR ALL +TO service_role +USING (true) +WITH CHECK (true); + +CREATE INDEX IF NOT EXISTS idx_assignment_grading_automation_state_class_id + ON public.assignment_grading_automation_state (class_id); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' AND p.proname = 'set_updated_at' + ) THEN + DROP TRIGGER IF EXISTS set_updated_at_on_assignment_grading_automation_state ON public.assignment_grading_automation_state; + CREATE TRIGGER set_updated_at_on_assignment_grading_automation_state + BEFORE UPDATE ON public.assignment_grading_automation_state + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + END IF; +END $$; + +CREATE OR REPLACE FUNCTION public.auto_assign_grading_reviews_for_assignment( + p_assignment_id bigint +) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO public +AS $$ +DECLARE + v_assignment public.assignments%ROWTYPE; + v_staff_ids uuid[]; + v_staff_count integer := 0; + v_idx integer := 1; + v_assignee uuid; + v_actor_user_id uuid; + v_review_due_date timestamptz; + v_submission record; + v_draft_assignments jsonb := '[]'::jsonb; + v_result jsonb; + v_member_profiles uuid[]; + v_student_profiles uuid[]; + v_lab_section_id bigint; + v_leader_id uuid; + v_mentor_id uuid; +BEGIN + SELECT * + INTO v_assignment + FROM public.assignments + WHERE id = p_assignment_id + AND archived_at IS NULL; + + IF NOT FOUND THEN + RETURN 0; + END IF; + + IF NOT v_assignment.auto_assign_at_deadline THEN + RETURN 0; + END IF; + + IF v_assignment.grading_rubric_id IS NULL THEN + RAISE WARNING 'auto_assign skipped for assignment %: missing grading_rubric_id', v_assignment.id; + RETURN -1; + END IF; + + -- bulk_assign_reviews requires an instructor auth context. + SELECT ur.user_id + INTO v_actor_user_id + FROM public.user_roles ur + WHERE ur.class_id = v_assignment.class_id + AND ur.role = 'instructor' + AND ur.disabled = false + ORDER BY ur.id + LIMIT 1; + + IF v_actor_user_id IS NULL THEN + RAISE WARNING 'auto_assign skipped for assignment %: no active instructor', v_assignment.id; + RETURN -1; + END IF; + + PERFORM set_config('request.jwt.claim.sub', v_actor_user_id::text, true); + + v_review_due_date := + v_assignment.due_date + make_interval(hours => GREATEST(v_assignment.auto_assign_review_due_hours, 0)); + + IF v_assignment.auto_assign_assignee_pool IN ('graders', 'instructors', 'instructors_and_graders') THEN + SELECT array_agg(ur.private_profile_id ORDER BY ur.private_profile_id) + INTO v_staff_ids + FROM public.user_roles ur + WHERE ur.class_id = v_assignment.class_id + AND ur.disabled = false + AND ur.private_profile_id IS NOT NULL + AND ( + ( + v_assignment.auto_assign_assignee_pool = 'graders' + AND ur.role = 'grader' + ) + OR ( + v_assignment.auto_assign_assignee_pool = 'instructors' + AND ur.role = 'instructor' + ) + OR ( + v_assignment.auto_assign_assignee_pool = 'instructors_and_graders' + AND ur.role IN ('grader', 'instructor') + ) + ) + AND ( + v_assignment.auto_assign_assignee_pool <> 'graders' + OR COALESCE(jsonb_array_length(v_assignment.auto_assign_grader_subset_private_profile_ids), 0) = 0 + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(v_assignment.auto_assign_grader_subset_private_profile_ids) AS subset_id(txt) + WHERE subset_id.txt IS NOT NULL + AND btrim(subset_id.txt) <> '' + AND subset_id.txt ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + AND subset_id.txt::uuid = ur.private_profile_id + ) + ); + + v_staff_count := COALESCE(array_length(v_staff_ids, 1), 0); + IF v_staff_count = 0 THEN + RAISE WARNING 'auto_assign skipped for assignment %: empty assignee pool (%).', v_assignment.id, v_assignment.auto_assign_assignee_pool; + RETURN -1; + END IF; + + FOR v_submission IN + SELECT s.id AS sid, s.profile_id AS pid, s.assignment_group_id AS agid + FROM public.submissions s + WHERE s.assignment_id = v_assignment.id + AND s.class_id = v_assignment.class_id + AND s.is_active = true + AND NOT EXISTS ( + SELECT 1 + FROM public.review_assignments ra + WHERE ra.assignment_id = v_assignment.id + AND ra.rubric_id = v_assignment.grading_rubric_id + AND ra.submission_id = s.id + ) + ORDER BY s.id + LOOP + v_assignee := v_staff_ids[((v_idx - 1) % v_staff_count) + 1]; + v_draft_assignments := v_draft_assignments || jsonb_build_array( + jsonb_build_object( + 'assignee_profile_id', v_assignee, + 'submission_id', v_submission.sid, + 'rubric_part_id', NULL + ) + ); + v_idx := v_idx + 1; + END LOOP; + ELSIF v_assignment.auto_assign_assignee_pool = 'lab_leaders' THEN + FOR v_submission IN + SELECT s.id AS sid, s.profile_id AS pid, s.assignment_group_id AS agid + FROM public.submissions s + WHERE s.assignment_id = v_assignment.id + AND s.class_id = v_assignment.class_id + AND s.is_active = true + AND NOT EXISTS ( + SELECT 1 + FROM public.review_assignments ra + WHERE ra.assignment_id = v_assignment.id + AND ra.rubric_id = v_assignment.grading_rubric_id + AND ra.submission_id = s.id + ) + ORDER BY s.id + LOOP + IF v_submission.agid IS NOT NULL THEN + SELECT COALESCE(array_agg(DISTINCT agm.profile_id), '{}'::uuid[]) + INTO v_member_profiles + FROM public.assignment_groups_members agm + WHERE agm.assignment_group_id = v_submission.agid + AND agm.class_id = v_assignment.class_id + AND agm.assignment_id = v_assignment.id; + ELSE + v_member_profiles := '{}'::uuid[]; + END IF; + + v_student_profiles := ARRAY( + SELECT DISTINCT mp + FROM unnest(v_member_profiles || ARRAY[v_submission.pid]) AS t(mp) + WHERE mp IS NOT NULL + ); + + SELECT ur.lab_section_id INTO v_lab_section_id + FROM public.user_roles ur + WHERE ur.class_id = v_assignment.class_id + AND ur.lab_section_id IS NOT NULL + AND ur.private_profile_id = ANY(v_student_profiles) + LIMIT 1; + + IF v_lab_section_id IS NULL THEN + CONTINUE; + END IF; + + FOR v_leader_id IN + SELECT lsl.profile_id + FROM public.lab_section_leaders lsl + JOIN public.user_roles ur + ON ur.private_profile_id = lsl.profile_id + AND ur.class_id = v_assignment.class_id + AND ur.disabled = false + AND ur.role IN ('grader', 'instructor') + WHERE lsl.class_id = v_assignment.class_id + AND lsl.lab_section_id = v_lab_section_id + LOOP + IF EXISTS ( + SELECT 1 + FROM public.grading_conflicts gc + WHERE gc.class_id = v_assignment.class_id + AND gc.grader_profile_id = v_leader_id + AND gc.student_profile_id = ANY(v_student_profiles) + ) THEN + CONTINUE; + END IF; + + v_draft_assignments := v_draft_assignments || jsonb_build_array( + jsonb_build_object( + 'assignee_profile_id', v_leader_id, + 'submission_id', v_submission.sid, + 'rubric_part_id', NULL + ) + ); + END LOOP; + END LOOP; + ELSIF v_assignment.auto_assign_assignee_pool = 'group_mentors' THEN + FOR v_submission IN + SELECT s.id AS sid, s.profile_id AS pid, s.assignment_group_id AS agid + FROM public.submissions s + WHERE s.assignment_id = v_assignment.id + AND s.class_id = v_assignment.class_id + AND s.is_active = true + AND NOT EXISTS ( + SELECT 1 + FROM public.review_assignments ra + WHERE ra.assignment_id = v_assignment.id + AND ra.rubric_id = v_assignment.grading_rubric_id + AND ra.submission_id = s.id + ) + ORDER BY s.id + LOOP + IF v_submission.agid IS NULL THEN + CONTINUE; + END IF; + + SELECT ag.mentor_profile_id + INTO v_mentor_id + FROM public.assignment_groups ag + WHERE ag.id = v_submission.agid + AND ag.class_id = v_assignment.class_id + AND ag.assignment_id = v_assignment.id; + + IF v_mentor_id IS NULL THEN + CONTINUE; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.user_roles ur + WHERE ur.private_profile_id = v_mentor_id + AND ur.class_id = v_assignment.class_id + AND ur.disabled = false + AND ur.role IN ('grader', 'instructor') + ) THEN + CONTINUE; + END IF; + + SELECT COALESCE(array_agg(DISTINCT agm.profile_id), '{}'::uuid[]) + INTO v_member_profiles + FROM public.assignment_groups_members agm + WHERE agm.assignment_group_id = v_submission.agid + AND agm.class_id = v_assignment.class_id + AND agm.assignment_id = v_assignment.id; + + v_student_profiles := ARRAY( + SELECT DISTINCT mp + FROM unnest(v_member_profiles || ARRAY[v_submission.pid]) AS t(mp) + WHERE mp IS NOT NULL + ); + + IF EXISTS ( + SELECT 1 + FROM public.grading_conflicts gc + WHERE gc.class_id = v_assignment.class_id + AND gc.grader_profile_id = v_mentor_id + AND gc.student_profile_id = ANY(v_student_profiles) + ) THEN + CONTINUE; + END IF; + + v_draft_assignments := v_draft_assignments || jsonb_build_array( + jsonb_build_object( + 'assignee_profile_id', v_mentor_id, + 'submission_id', v_submission.sid, + 'rubric_part_id', NULL + ) + ); + END LOOP; + END IF; + + IF jsonb_array_length(v_draft_assignments) = 0 THEN + RETURN 0; + END IF; + + SELECT public.bulk_assign_reviews( + v_assignment.class_id, + v_assignment.id, + v_assignment.grading_rubric_id, + v_draft_assignments, + v_review_due_date + ) + INTO v_result; + + IF COALESCE((v_result->>'success')::boolean, false) IS DISTINCT FROM true THEN + RAISE WARNING 'bulk_assign_reviews failed for assignment %: %', v_assignment.id, v_result; + RETURN -1; + END IF; + + RETURN COALESCE((v_result->>'assignments_created')::integer, 0) + + COALESCE((v_result->>'assignments_updated')::integer, 0); +END; +$$; + +CREATE OR REPLACE FUNCTION public.queue_late_grading_reminders_for_assignment( + p_assignment_id bigint +) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO public +AS $$ +DECLARE + v_assignment public.assignments%ROWTYPE; + v_batch_id bigint; + v_subject text; + v_email_count integer := 0; +BEGIN + SELECT * + INTO v_assignment + FROM public.assignments + WHERE id = p_assignment_id + AND archived_at IS NULL; + + IF NOT FOUND THEN + RETURN 0; + END IF; + + IF NOT v_assignment.late_grading_reminders_enabled THEN + RETURN 0; + END IF; + + IF v_assignment.late_grading_reminder_interval_hours IS NULL + OR v_assignment.late_grading_reminder_interval_hours <= 0 THEN + RETURN 0; + END IF; + + v_subject := format('Late grading reminder: %s', v_assignment.title); + + INSERT INTO public.email_batches (subject, body, class_id, cc_emails, reply_to) + VALUES ( + v_subject, + '', + v_assignment.class_id, + v_assignment.late_grading_cc_emails, + v_assignment.late_grading_reply_to + ) + RETURNING id INTO v_batch_id; + + WITH reminder_recipients AS ( + SELECT + ur.user_id, + COUNT(*)::integer AS incomplete_count + FROM public.review_assignments ra + JOIN public.user_roles ur + ON ur.private_profile_id = ra.assignee_profile_id + AND ur.class_id = ra.class_id + WHERE ra.assignment_id = v_assignment.id + AND ra.completed_at IS NULL + AND COALESCE(ra.due_date, v_assignment.due_date) <= now() + AND (v_assignment.grading_rubric_id IS NULL OR ra.rubric_id = v_assignment.grading_rubric_id) + AND ur.disabled = false + AND ur.role IN ('grader', 'instructor') + GROUP BY ur.user_id + ), + inserted AS ( + INSERT INTO public.emails ( + user_id, + batch_id, + class_id, + subject, + body, + cc_emails, + reply_to + ) + SELECT + rr.user_id, + v_batch_id, + v_assignment.class_id, + v_subject, + format( + 'You currently have %s incomplete grading assignment%s for "%s". Please visit the assignment reviews page to finish grading.', + rr.incomplete_count, + CASE WHEN rr.incomplete_count = 1 THEN '' ELSE 's' END, + v_assignment.title + ), + v_assignment.late_grading_cc_emails, + v_assignment.late_grading_reply_to + FROM reminder_recipients rr + RETURNING 1 + ) + SELECT COUNT(*) INTO v_email_count FROM inserted; + + IF v_email_count = 0 THEN + DELETE FROM public.email_batches WHERE id = v_batch_id; + END IF; + + RETURN v_email_count; +END; +$$; + +CREATE OR REPLACE FUNCTION public.run_assignment_grading_automation() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO public +AS $$ +DECLARE + v_assignment record; + v_state record; + v_auto_assigned_count integer; + v_reminder_count integer; + -- Bound the candidate window so cron cost stays proportional to recent work, + -- not to the lifetime size of the assignments table. Auto-assign is a one- + -- shot action that only makes sense near the deadline; reminders nag for a + -- bounded period after deadline. If a deadline-bound action wasn't taken + -- within these windows it will not retroactively fire. + c_auto_assign_window_days constant integer := 7; + c_reminder_window_days constant integer := 30; +BEGIN + FOR v_assignment IN + SELECT + a.id, + a.class_id, + a.auto_assign_at_deadline, + a.late_grading_reminders_enabled, + a.late_grading_reminder_interval_hours, + a.due_date, + s.auto_assigned_at, + s.last_reminder_sent_at + FROM public.assignments a + LEFT JOIN public.assignment_grading_automation_state s ON s.assignment_id = a.id + WHERE a.archived_at IS NULL + AND a.due_date <= now() + AND a.due_date >= now() - make_interval(days => c_reminder_window_days) + AND ( + -- Auto-assign: only consider rows that have not yet been auto-assigned + -- and whose deadline is still within the auto-assign window. + ( + a.auto_assign_at_deadline + AND s.auto_assigned_at IS NULL + AND a.due_date >= now() - make_interval(days => c_auto_assign_window_days) + ) + OR + -- Reminders: only consider rows whose interval has actually elapsed. + ( + a.late_grading_reminders_enabled + AND COALESCE(a.late_grading_reminder_interval_hours, 0) > 0 + AND ( + s.last_reminder_sent_at IS NULL + OR s.last_reminder_sent_at + + make_interval(hours => a.late_grading_reminder_interval_hours) <= now() + ) + ) + ) + LOOP + INSERT INTO public.assignment_grading_automation_state (assignment_id, class_id) + VALUES (v_assignment.id, v_assignment.class_id) + ON CONFLICT (assignment_id) DO NOTHING; + + SELECT s.auto_assigned_at, s.last_reminder_sent_at + INTO v_state + FROM public.assignment_grading_automation_state s + WHERE s.assignment_id = v_assignment.id + FOR UPDATE; + + IF v_assignment.auto_assign_at_deadline AND v_state.auto_assigned_at IS NULL THEN + v_auto_assigned_count := public.auto_assign_grading_reviews_for_assignment(v_assignment.id); + IF v_auto_assigned_count >= 0 THEN + UPDATE public.assignment_grading_automation_state + SET auto_assigned_at = now(), + updated_at = now() + WHERE assignment_id = v_assignment.id; + IF v_auto_assigned_count > 0 THEN + RAISE LOG 'Auto-assigned grading for assignment %: created/updated=%', v_assignment.id, v_auto_assigned_count; + ELSE + RAISE LOG 'Auto-assign grading for assignment %: marked complete (no new review assignments required)', v_assignment.id; + END IF; + ELSE + RAISE LOG 'Auto-assign grading for assignment %: deferred or failed (code %)', v_assignment.id, v_auto_assigned_count; + END IF; + END IF; + + IF v_assignment.late_grading_reminders_enabled + AND COALESCE(v_assignment.late_grading_reminder_interval_hours, 0) > 0 + AND ( + v_state.last_reminder_sent_at IS NULL + OR v_state.last_reminder_sent_at + + make_interval(hours => v_assignment.late_grading_reminder_interval_hours) <= now() + ) THEN + v_reminder_count := public.queue_late_grading_reminders_for_assignment(v_assignment.id); + UPDATE public.assignment_grading_automation_state + SET last_reminder_sent_at = now(), + last_reminder_recipient_count = v_reminder_count, + updated_at = now() + WHERE assignment_id = v_assignment.id; + + RAISE LOG 'Queued late grading reminders for assignment %, recipients=%', v_assignment.id, v_reminder_count; + END IF; + END LOOP; +END; +$$; + +-- Submission-time auto-assignment for late submissions. +-- +-- The cron above batch-assigns at the deadline. Students with due-date +-- extensions submit after that batch has already run, so we also fire on +-- submission insert: when the assignment is past its deadline AND +-- auto_assign_at_deadline is enabled, create a review_assignment for the new +-- submission right away. +-- +-- Picks the pool member with the FEWEST existing review_assignments for this +-- assignment so trigger-time picks stay balanced against the cron's prior +-- batch (we can't see in-flight cron drafts, only the DB state, so this is +-- a best-effort load balance). +CREATE OR REPLACE FUNCTION public.auto_assign_grading_review_for_submission(p_submission_id bigint) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO public +AS $$ +DECLARE + v_submission record; + v_assignment public.assignments%ROWTYPE; + v_actor_user_id uuid; + v_review_due_date timestamptz; + v_assignee uuid; + v_draft_assignments jsonb := '[]'::jsonb; + v_result jsonb; + v_member_profiles uuid[]; + v_student_profiles uuid[]; + v_lab_section_id bigint; + v_leader_id uuid; + v_mentor_id uuid; +BEGIN + SELECT s.id, s.profile_id, s.assignment_group_id, s.assignment_id, s.class_id, s.is_active + INTO v_submission + FROM public.submissions s + WHERE s.id = p_submission_id; + + IF NOT FOUND OR NOT v_submission.is_active THEN + RETURN 0; + END IF; + + SELECT * + INTO v_assignment + FROM public.assignments + WHERE id = v_submission.assignment_id + AND archived_at IS NULL; + + IF NOT FOUND + OR NOT v_assignment.auto_assign_at_deadline + OR v_assignment.grading_rubric_id IS NULL + OR v_assignment.due_date > now() THEN + -- Pre-deadline submissions and non-auto-assign assignments are left for + -- the cron's deadline batch. + RETURN 0; + END IF; + + -- Skip if this student/group already has a review_assignment for this + -- assignment+rubric (on any submission). Catches: + -- (a) the row was just created by the cron's batch in the same window; + -- (b) re-submissions where existing repoint triggers move the prior RA + -- to the new active submission. + IF EXISTS ( + SELECT 1 + FROM public.review_assignments ra + JOIN public.submissions s ON s.id = ra.submission_id + WHERE ra.assignment_id = v_assignment.id + AND ra.rubric_id = v_assignment.grading_rubric_id + AND ( + (v_submission.assignment_group_id IS NOT NULL + AND s.assignment_group_id = v_submission.assignment_group_id) + OR (v_submission.assignment_group_id IS NULL + AND s.assignment_group_id IS NULL + AND s.profile_id = v_submission.profile_id) + ) + ) THEN + RETURN 0; + END IF; + + SELECT ur.user_id + INTO v_actor_user_id + FROM public.user_roles ur + WHERE ur.class_id = v_assignment.class_id + AND ur.role = 'instructor' + AND ur.disabled = false + ORDER BY ur.id + LIMIT 1; + + IF v_actor_user_id IS NULL THEN + RAISE WARNING 'auto_assign_grading_review_for_submission skipped for submission %: no active instructor', v_submission.id; + RETURN 0; + END IF; + + PERFORM set_config('request.jwt.claim.sub', v_actor_user_id::text, true); + + v_review_due_date := + v_assignment.due_date + make_interval(hours => GREATEST(v_assignment.auto_assign_review_due_hours, 0)); + + IF v_submission.assignment_group_id IS NOT NULL THEN + SELECT COALESCE(array_agg(DISTINCT agm.profile_id), '{}'::uuid[]) + INTO v_member_profiles + FROM public.assignment_groups_members agm + WHERE agm.assignment_group_id = v_submission.assignment_group_id + AND agm.class_id = v_assignment.class_id + AND agm.assignment_id = v_assignment.id; + ELSE + v_member_profiles := '{}'::uuid[]; + END IF; + + v_student_profiles := ARRAY( + SELECT DISTINCT mp + FROM unnest(v_member_profiles || ARRAY[v_submission.profile_id]) AS t(mp) + WHERE mp IS NOT NULL + ); + + IF v_assignment.auto_assign_assignee_pool IN ('graders', 'instructors', 'instructors_and_graders') THEN + SELECT picked.private_profile_id + INTO v_assignee + FROM ( + SELECT ur.private_profile_id, + COALESCE(ra_count.c, 0)::integer AS c + FROM public.user_roles ur + LEFT JOIN ( + SELECT assignee_profile_id, COUNT(*) AS c + FROM public.review_assignments + WHERE assignment_id = v_assignment.id + AND rubric_id = v_assignment.grading_rubric_id + GROUP BY assignee_profile_id + ) ra_count ON ra_count.assignee_profile_id = ur.private_profile_id + WHERE ur.class_id = v_assignment.class_id + AND ur.disabled = false + AND ur.private_profile_id IS NOT NULL + AND ( + (v_assignment.auto_assign_assignee_pool = 'graders' AND ur.role = 'grader') + OR (v_assignment.auto_assign_assignee_pool = 'instructors' AND ur.role = 'instructor') + OR (v_assignment.auto_assign_assignee_pool = 'instructors_and_graders' AND ur.role IN ('grader', 'instructor')) + ) + AND ( + v_assignment.auto_assign_assignee_pool <> 'graders' + OR COALESCE(jsonb_array_length(v_assignment.auto_assign_grader_subset_private_profile_ids), 0) = 0 + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(v_assignment.auto_assign_grader_subset_private_profile_ids) AS subset_id(txt) + WHERE subset_id.txt IS NOT NULL + AND btrim(subset_id.txt) <> '' + AND subset_id.txt ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + AND subset_id.txt::uuid = ur.private_profile_id + ) + ) + ) picked + ORDER BY picked.c ASC, picked.private_profile_id ASC + LIMIT 1; + + IF v_assignee IS NOT NULL THEN + v_draft_assignments := jsonb_build_array(jsonb_build_object( + 'assignee_profile_id', v_assignee, + 'submission_id', v_submission.id, + 'rubric_part_id', NULL + )); + END IF; + ELSIF v_assignment.auto_assign_assignee_pool = 'lab_leaders' THEN + SELECT ur.lab_section_id + INTO v_lab_section_id + FROM public.user_roles ur + WHERE ur.class_id = v_assignment.class_id + AND ur.lab_section_id IS NOT NULL + AND ur.private_profile_id = ANY(v_student_profiles) + LIMIT 1; + + IF v_lab_section_id IS NOT NULL THEN + FOR v_leader_id IN + SELECT lsl.profile_id + FROM public.lab_section_leaders lsl + JOIN public.user_roles ur + ON ur.private_profile_id = lsl.profile_id + AND ur.class_id = v_assignment.class_id + AND ur.disabled = false + AND ur.role IN ('grader', 'instructor') + WHERE lsl.class_id = v_assignment.class_id + AND lsl.lab_section_id = v_lab_section_id + LOOP + IF NOT EXISTS ( + SELECT 1 + FROM public.grading_conflicts gc + WHERE gc.class_id = v_assignment.class_id + AND gc.grader_profile_id = v_leader_id + AND gc.student_profile_id = ANY(v_student_profiles) + ) THEN + v_draft_assignments := v_draft_assignments || jsonb_build_array( + jsonb_build_object( + 'assignee_profile_id', v_leader_id, + 'submission_id', v_submission.id, + 'rubric_part_id', NULL + ) + ); + END IF; + END LOOP; + END IF; + ELSIF v_assignment.auto_assign_assignee_pool = 'group_mentors' THEN + IF v_submission.assignment_group_id IS NOT NULL THEN + SELECT ag.mentor_profile_id + INTO v_mentor_id + FROM public.assignment_groups ag + WHERE ag.id = v_submission.assignment_group_id + AND ag.class_id = v_assignment.class_id + AND ag.assignment_id = v_assignment.id; + + IF v_mentor_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM public.user_roles ur + WHERE ur.private_profile_id = v_mentor_id + AND ur.class_id = v_assignment.class_id + AND ur.disabled = false + AND ur.role IN ('grader', 'instructor') + ) + AND NOT EXISTS ( + SELECT 1 + FROM public.grading_conflicts gc + WHERE gc.class_id = v_assignment.class_id + AND gc.grader_profile_id = v_mentor_id + AND gc.student_profile_id = ANY(v_student_profiles) + ) THEN + v_draft_assignments := jsonb_build_array(jsonb_build_object( + 'assignee_profile_id', v_mentor_id, + 'submission_id', v_submission.id, + 'rubric_part_id', NULL + )); + END IF; + END IF; + END IF; + + IF jsonb_array_length(v_draft_assignments) = 0 THEN + RETURN 0; + END IF; + + SELECT public.bulk_assign_reviews( + v_assignment.class_id, + v_assignment.id, + v_assignment.grading_rubric_id, + v_draft_assignments, + v_review_due_date + ) + INTO v_result; + + IF COALESCE((v_result->>'success')::boolean, false) IS DISTINCT FROM true THEN + RAISE WARNING 'bulk_assign_reviews failed for late submission %: %', v_submission.id, v_result; + RETURN 0; + END IF; + + RETURN COALESCE((v_result->>'assignments_created')::integer, 0) + + COALESCE((v_result->>'assignments_updated')::integer, 0); +END; +$$; + +-- Wrap the trigger so a failure in auto-assignment never blocks the underlying +-- submission INSERT. The student must always be able to submit; auto-assign is +-- best-effort and an operator can re-run the cron RPC to recover. +CREATE OR REPLACE FUNCTION public.trigger_auto_assign_review_on_submission() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO public, pg_temp +AS $$ +BEGIN + BEGIN + PERFORM public.auto_assign_grading_review_for_submission(NEW.id); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'auto_assign_grading_review_for_submission failed for submission %: %', NEW.id, SQLERRM; + END; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_auto_assign_review_on_submission ON public.submissions; +CREATE TRIGGER trg_auto_assign_review_on_submission + AFTER INSERT ON public.submissions + FOR EACH ROW + WHEN (NEW.is_active = true) + EXECUTE FUNCTION public.trigger_auto_assign_review_on_submission(); + +REVOKE ALL ON FUNCTION public.auto_assign_grading_reviews_for_assignment(bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.auto_assign_grading_reviews_for_assignment(bigint) TO service_role; + +REVOKE ALL ON FUNCTION public.auto_assign_grading_review_for_submission(bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.auto_assign_grading_review_for_submission(bigint) TO service_role; + +REVOKE ALL ON FUNCTION public.queue_late_grading_reminders_for_assignment(bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.queue_late_grading_reminders_for_assignment(bigint) TO service_role; + +REVOKE ALL ON FUNCTION public.run_assignment_grading_automation() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.run_assignment_grading_automation() TO service_role; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN + BEGIN + IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'run-assignment-grading-automation-every-5-minutes') THEN + PERFORM cron.unschedule('run-assignment-grading-automation-every-5-minutes'); + END IF; + + PERFORM cron.schedule( + 'run-assignment-grading-automation-every-5-minutes', + '*/5 * * * *', + $cron$SELECT public.run_assignment_grading_automation();$cron$ + ); + EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'Unable to schedule grading automation cron job: %', SQLERRM; + END; + ELSE + RAISE NOTICE 'pg_cron extension not available - grading automation schedule not created'; + END IF; +END $$; diff --git a/tests/e2e/grading-assignment-defaults.test.tsx b/tests/e2e/grading-assignment-defaults.test.tsx new file mode 100644 index 000000000..fb6289bdd --- /dev/null +++ b/tests/e2e/grading-assignment-defaults.test.tsx @@ -0,0 +1,1372 @@ +import { Assignment, Course, GradingAssignmentDefaultProfile } from "@/utils/supabase/DatabaseTypes"; +import { addDays } from "date-fns"; +import dotenv from "dotenv"; +import { test, expect } from "../global-setup"; +import { + createClass, + createLabSectionWithStudents, + createUsersInClass, + insertAssignment, + insertPreBakedSubmission, + loginAsUser, + supabase, + TestingUser +} from "./TestingUtils"; + +dotenv.config({ path: ".env.local" }); + +test.describe.configure({ mode: "serial" }); + +let course: Course; +let instructor: TestingUser | undefined; +let assignment: Assignment | undefined; + +const profileNameSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +const gradingProfileName = `E2E Grading Defaults ${profileNameSuffix}`; + +test.beforeAll(async () => { + course = await createClass({ name: `E2E Grading Defaults Course ${profileNameSuffix}` }); + [instructor] = await createUsersInClass([ + { + name: `E2E Grading Instructor ${profileNameSuffix}`, + email: `e2e-grading-instructor-${profileNameSuffix}@pawtograder.net`, + role: "instructor", + class_id: course.id, + useMagicLink: true + } + ]); + + assignment = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 7).toISOString(), + name: `E2E Grading Assignment ${profileNameSuffix}`, + assignment_slug: `e2e-grading-defaults-${profileNameSuffix}` + }); +}); + +test.afterEach(async ({ logMagicLinksOnFailure }) => { + await logMagicLinksOnFailure([instructor]); +}); + +test("instructors can manage grading default profiles and apply them on assignment create/edit workflows", async ({ + page +}) => { + test.setTimeout(180_000); + await loginAsUser(page, instructor!, course); + + await page.goto(`/course/${course.id}/manage/course/grading-assignment-defaults`); + await expect(page.getByRole("heading", { name: "Grading Assignment Defaults" })).toBeVisible(); + + await page.getByLabel("Profile name").fill(gradingProfileName); + await page.getByLabel("Description").fill("Profile used by E2E test coverage."); + // Chakra renders the styled Checkbox.Control above the hidden input, so it intercepts + // pointer events from Playwright's .check(). Click the label text instead — that's the + // pattern used elsewhere in the suite (see gradebook.test.tsx). + const autoAssignProfileCheckbox = page.getByRole("checkbox", { name: "Auto assign at deadline" }); + if (!(await autoAssignProfileCheckbox.isChecked())) { + await page.getByText("Auto assign at deadline", { exact: true }).click(); + } + await expect(autoAssignProfileCheckbox).toBeChecked(); + await page.getByLabel("Assignee pool").selectOption("instructors_and_graders"); + await page.getByLabel("Review due hours after deadline").fill("36"); + const remindersProfileCheckbox = page.getByRole("checkbox", { name: "Enable late grading reminders" }); + if (!(await remindersProfileCheckbox.isChecked())) { + await page.getByText("Enable late grading reminders", { exact: true }).click(); + } + await expect(remindersProfileCheckbox).toBeChecked(); + await page.getByLabel("Reminder interval (hours)").fill("12"); + await page.getByLabel("Reply-to email").fill("grading-reply@example.edu"); + await page.getByLabel("CC emails").fill("staff1@example.edu, staff2@example.edu"); + + await page.getByRole("button", { name: "Create profile" }).click(); + await expect(page.getByText("Profile created")).toBeVisible(); + await expect(page.getByText(gradingProfileName)).toBeVisible(); + await expect(page.getByText("Auto assign: on | Reminder: every 12h")).toBeVisible(); + + const throwawayProfileName = `E2E Throwaway Profile ${profileNameSuffix}`; + await page.getByLabel("Profile name").fill(throwawayProfileName); + await page.getByRole("button", { name: "Create profile" }).click(); + await expect(page.getByText(throwawayProfileName)).toBeVisible(); + + const { data: createdProfile, error: createdProfileError } = await supabase + .from("grading_assignment_default_profiles") + .select("*") + .eq("class_id", course.id) + .eq("name", gradingProfileName) + .single(); + + expect(createdProfileError).toBeNull(); + expect(createdProfile).not.toBeNull(); + + const profile = createdProfile as GradingAssignmentDefaultProfile; + + // Toast notifications from the prior `Create profile` clicks linger at the bottom-right + // and overlay the Delete buttons. Use dispatchEvent("click") to bypass DOM-level pointer + // interception (force:true only skips Playwright's actionability check, not DOM stacking). + // The handler does an async DB lookup before showing window.confirm, so the dialog lags; + // a single page-level dialog listener driven by a counter handles both confirms. + let dialogIndex = 0; + const onDialog = async (dialog: import("@playwright/test").Dialog) => { + expect(dialog.message()).toContain("Delete this grading default profile?"); + dialogIndex += 1; + if (dialogIndex === 1) { + await dialog.dismiss(); + } else { + await dialog.accept(); + } + }; + page.on("dialog", onDialog); + try { + await page.getByRole("button", { name: "Delete" }).first().dispatchEvent("click"); + await expect.poll(() => dialogIndex, { timeout: 10_000 }).toBeGreaterThanOrEqual(1); + await expect(page.getByText(gradingProfileName)).toBeVisible(); + + await page.getByRole("button", { name: "Delete" }).nth(1).dispatchEvent("click"); + await expect.poll(() => dialogIndex, { timeout: 10_000 }).toBeGreaterThanOrEqual(2); + await expect(page.getByText(throwawayProfileName)).not.toBeVisible(); + await expect(page.getByText(gradingProfileName)).toBeVisible(); + } finally { + page.off("dialog", onDialog); + } + + await page.goto(`/course/${course.id}/manage/assignments/new`); + await expect(page.getByRole("heading", { name: "Create New Assignment" })).toBeVisible(); + + const savedProfileSelect = page.getByLabel("Saved profile"); + await savedProfileSelect.selectOption(String(profile.id)); + + await expect(page.getByRole("checkbox", { name: "Auto assign grading at deadline" })).toBeChecked(); + await expect(page.getByLabel("Assignee pool")).toHaveValue("instructors_and_graders"); + const createDueHoursInput = page.getByLabel("Review due hours after assignment deadline"); + await expect(createDueHoursInput).toHaveValue("36"); + await expect(page.getByRole("checkbox", { name: "Enable late grading reminders" })).toBeChecked(); + await expect(page.getByLabel("Reminder interval (hours)")).toHaveValue("12"); + await expect(page.getByLabel("Reply-to email")).toHaveValue("grading-reply@example.edu"); + await expect(page.getByLabel("CC emails")).toHaveValue("staff1@example.edu, staff2@example.edu"); + + const applyProfileCheckbox = page.getByRole("checkbox", { name: "Apply profile settings on selection" }); + const applyProfileLabel = page.getByText("Apply profile settings on selection", { exact: true }); + if (await applyProfileCheckbox.isChecked()) { + await applyProfileLabel.click(); + } + await expect(applyProfileCheckbox).not.toBeChecked(); + await createDueHoursInput.fill("99"); + await savedProfileSelect.selectOption(""); + await savedProfileSelect.selectOption(String(profile.id)); + await expect(createDueHoursInput).toHaveValue("99"); + + if (!(await applyProfileCheckbox.isChecked())) { + await applyProfileLabel.click(); + } + await expect(applyProfileCheckbox).toBeChecked(); + await savedProfileSelect.selectOption(""); + await savedProfileSelect.selectOption(String(profile.id)); + await expect(createDueHoursInput).toHaveValue("36"); + + const seededCcEmails = { emails: ["seeded-cc@example.edu"] }; + const { error: seedAssignmentError } = await supabase + .from("assignments") + .update({ + grading_default_profile_id: profile.id, + auto_assign_at_deadline: true, + auto_assign_assignee_pool: "instructors_and_graders", + auto_assign_review_due_hours: 48, + late_grading_reminders_enabled: true, + late_grading_reminder_interval_hours: 24, + late_grading_reply_to: "seeded-reply@example.edu", + late_grading_cc_emails: seededCcEmails + }) + .eq("id", assignment!.id); + expect(seedAssignmentError).toBeNull(); + + await page.goto(`/course/${course.id}/manage/assignments/${assignment!.id}/edit`); + await expect(page.getByRole("heading", { name: "Edit Assignment" })).toBeVisible(); + + // ManageAssignmentNav (the layout for /manage/assignments/[assignment_id]/*) renders its + // children twice — once in a desktop Flex (display: { base: 'none', lg: 'flex' }) and once + // in a mobile Flex (display: { base: 'flex', lg: 'none' }). Both are in the DOM regardless + // of viewport, so every field label resolves to two elements. Scope to a single visible + // form to keep strict-mode happy. + const editForm = page + .locator("form") + .filter({ has: page.getByRole("button", { name: "Save" }) }) + .first(); + const editProfileSelect = editForm.getByLabel("Saved profile"); + await expect(editProfileSelect).toHaveValue(String(profile.id)); + const editDueHoursInput = editForm.getByLabel("Review due hours after assignment deadline"); + await expect(editDueHoursInput).toHaveValue("48"); + await expect(editForm.getByLabel("Reminder interval (hours)")).toHaveValue("24"); + await expect(editForm.getByLabel("Reply-to email")).toHaveValue("seeded-reply@example.edu"); + await expect(editForm.getByLabel("CC emails")).toHaveValue("seeded-cc@example.edu"); + + await editDueHoursInput.fill("77"); + await page.waitForTimeout(1500); + await expect(editDueHoursInput).toHaveValue("77"); + + await editForm.getByLabel("Reminder interval (hours)").fill("6"); + await editForm.getByLabel("Reply-to email").fill("updated-reply@example.edu"); + await editForm.getByLabel("CC emails").fill("updated-cc@example.edu"); + + const saveButton = editForm.getByRole("button", { name: "Save" }); + const assignmentUpdateResponse = page.waitForResponse( + (response) => + response.url().includes("/rest/v1/assignments") && + response.request().method() === "PATCH" && + response.url().includes(`id=eq.${assignment!.id}`), + { timeout: 20_000 } + ); + await saveButton.click(); + // Save sets loading/disabled immediately; also wait for the assignments PATCH so we + // do not race the success/error toaster from edit/page.tsx. + await expect(saveButton).toBeDisabled({ timeout: 5_000 }); + const updateResponse = await assignmentUpdateResponse; + expect(updateResponse.ok(), `assignments PATCH failed with status ${updateResponse.status()}`).toBe(true); + await expect(saveButton).toBeEnabled({ timeout: 20_000 }); + + const successToast = page.getByText("Assignment Updated"); + const errorToast = page.getByText("Update Error"); + await expect(successToast.or(errorToast)).toBeVisible({ timeout: 20_000 }); + if (await errorToast.isVisible()) { + throw new Error("Assignment update failed with 'Update Error' toast instead of 'Assignment Updated'"); + } + await expect(successToast).toBeVisible(); + + await expect(async () => { + const { data: persistedAssignment, error: persistedAssignmentError } = await supabase + .from("assignments") + .select( + "grading_default_profile_id, auto_assign_review_due_hours, late_grading_reminder_interval_hours, late_grading_reply_to, late_grading_cc_emails" + ) + .eq("id", assignment!.id) + .single(); + + expect(persistedAssignmentError).toBeNull(); + expect(persistedAssignment).not.toBeNull(); + expect(persistedAssignment!.grading_default_profile_id).toBe(profile.id); + expect(persistedAssignment!.auto_assign_review_due_hours).toBe(77); + expect(persistedAssignment!.late_grading_reminder_interval_hours).toBe(6); + expect(persistedAssignment!.late_grading_reply_to).toBe("updated-reply@example.edu"); + expect((persistedAssignment!.late_grading_cc_emails as { emails: string[] }).emails).toEqual([ + "updated-cc@example.edu" + ]); + }).toPass({ timeout: 20_000 }); +}); + +test("deadline automation assigns grading to lab leaders and queues reminder emails", async () => { + test.setTimeout(120_000); + + const workflowSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const [labLeaderA, labLeaderB, studentA, studentB] = await createUsersInClass([ + { + name: `E2E Lab Leader A ${workflowSuffix}`, + email: `e2e-lab-leader-a-${workflowSuffix}@pawtograder.net`, + role: "grader", + class_id: course.id, + useMagicLink: true + }, + { + name: `E2E Lab Leader B ${workflowSuffix}`, + email: `e2e-lab-leader-b-${workflowSuffix}@pawtograder.net`, + role: "grader", + class_id: course.id, + useMagicLink: true + }, + { + name: `E2E Lab Student A ${workflowSuffix}`, + email: `e2e-lab-student-a-${workflowSuffix}@pawtograder.net`, + role: "student", + class_id: course.id, + useMagicLink: true + }, + { + name: `E2E Lab Student B ${workflowSuffix}`, + email: `e2e-lab-student-b-${workflowSuffix}@pawtograder.net`, + role: "student", + class_id: course.id, + useMagicLink: true + } + ]); + + await createLabSectionWithStudents({ + class_id: course.id, + day_of_week: "monday", + lab_leaders: [labLeaderA], + students: [studentA], + name: `E2E Auto-Assign Lab A ${workflowSuffix}` + }); + await createLabSectionWithStudents({ + class_id: course.id, + day_of_week: "tuesday", + lab_leaders: [labLeaderB], + students: [studentB], + name: `E2E Auto-Assign Lab B ${workflowSuffix}` + }); + + const autoAssignment = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 1).toISOString(), + name: `E2E Deadline Automation ${workflowSuffix}`, + assignment_slug: `e2e-deadline-automation-${workflowSuffix}` + }); + + const reminderCcEmails = { emails: ["lab-leaders-reminders@example.edu"] }; + const { error: assignmentConfigError } = await supabase + .from("assignments") + .update({ + due_date: addDays(new Date(), -1).toISOString(), + auto_assign_at_deadline: true, + auto_assign_assignee_pool: "graders", + auto_assign_review_due_hours: 0, + late_grading_reminders_enabled: true, + late_grading_reminder_interval_hours: 12, + late_grading_reply_to: "lab-leaders-reply@example.edu", + late_grading_cc_emails: reminderCcEmails + }) + .eq("id", autoAssignment.id); + expect(assignmentConfigError).toBeNull(); + + const firstSubmission = await insertPreBakedSubmission({ + student_profile_id: studentA.private_profile_id, + assignment_id: autoAssignment.id, + class_id: course.id + }); + const secondSubmission = await insertPreBakedSubmission({ + student_profile_id: studentB.private_profile_id, + assignment_id: autoAssignment.id, + class_id: course.id + }); + + await supabase.from("emails").delete().eq("class_id", course.id); + await supabase.from("email_batches").delete().eq("class_id", course.id); + + const { error: runAutomationError } = await supabase.rpc("run_assignment_grading_automation"); + expect(runAutomationError).toBeNull(); + + const expectedSubmissionIds = [firstSubmission.submission_id, secondSubmission.submission_id].sort((a, b) => a - b); + const labLeaderProfileIds = [labLeaderA.private_profile_id, labLeaderB.private_profile_id]; + + await expect(async () => { + const { data: reviewAssignments, error: reviewAssignmentsError } = await supabase + .from("review_assignments") + .select("submission_id, assignee_profile_id, completed_at, due_date") + .eq("assignment_id", autoAssignment.id) + .eq("class_id", course.id) + .eq("rubric_id", autoAssignment.grading_rubric_id!); + + expect(reviewAssignmentsError).toBeNull(); + expect(reviewAssignments).not.toBeNull(); + expect(reviewAssignments!.length).toBe(2); + expect(reviewAssignments!.map((row) => row.submission_id).sort((a, b) => a - b)).toEqual(expectedSubmissionIds); + for (const row of reviewAssignments!) { + expect(row.completed_at).toBeNull(); + expect(labLeaderProfileIds).toContain(row.assignee_profile_id); + expect(new Date(row.due_date).getTime()).toBeLessThanOrEqual(Date.now()); + } + }).toPass({ timeout: 20_000 }); + + await expect(async () => { + const { data: stateRow, error: stateError } = await supabase + .from("assignment_grading_automation_state") + .select("auto_assigned_at, last_reminder_sent_at, last_reminder_recipient_count") + .eq("assignment_id", autoAssignment.id) + .single(); + + expect(stateError).toBeNull(); + expect(stateRow).not.toBeNull(); + expect(stateRow!.auto_assigned_at).not.toBeNull(); + expect(stateRow!.last_reminder_sent_at).not.toBeNull(); + expect(stateRow!.last_reminder_recipient_count).toBeGreaterThan(0); + }).toPass({ timeout: 20_000 }); + + await expect(async () => { + const { data: queuedEmails, error: queuedEmailsError } = await supabase + .from("emails") + .select("batch_id, user_id, subject, reply_to, cc_emails") + .eq("class_id", course.id) + .like("subject", `Late grading reminder: ${autoAssignment.title}%`); + expect(queuedEmailsError).toBeNull(); + expect(queuedEmails).not.toBeNull(); + expect(queuedEmails!.length).toBeGreaterThan(0); + + const queuedUserIds = new Set(queuedEmails!.map((row) => row.user_id)); + const expectedUserIds = new Set([labLeaderA.user_id, labLeaderB.user_id]); + for (const userId of queuedUserIds) { + expect(expectedUserIds.has(userId)).toBe(true); + } + + for (const row of queuedEmails!) { + expect(row.reply_to).toBe("lab-leaders-reply@example.edu"); + expect((row.cc_emails as { emails: string[] }).emails).toEqual(["lab-leaders-reminders@example.edu"]); + } + }).toPass({ timeout: 20_000 }); +}); + +// --------------------------------------------------------------------------- +// Shared helpers for the assignee-pool, idempotency, cadence, and filter +// tests below. These exercise auto_assign_grading_reviews_for_assignment, +// queue_late_grading_reminders_for_assignment, and run_assignment_grading_automation +// across branches and corner cases that the higher-level happy-path tests do +// not reach. +// --------------------------------------------------------------------------- + +type AutoAssignConfig = { + due_date?: string; + auto_assign_at_deadline?: boolean; + auto_assign_assignee_pool: "graders" | "instructors" | "instructors_and_graders" | "lab_leaders" | "group_mentors"; + auto_assign_review_due_hours?: number; + auto_assign_grader_subset_private_profile_ids?: string[]; + late_grading_reminders_enabled?: boolean; + late_grading_reminder_interval_hours?: number | null; + late_grading_reply_to?: string | null; + late_grading_cc_emails?: { emails: string[] }; +}; + +async function configureAutoAssign(assignmentId: number, config: AutoAssignConfig) { + const { error } = await supabase + .from("assignments") + .update({ + due_date: config.due_date ?? addDays(new Date(), -1).toISOString(), + auto_assign_at_deadline: config.auto_assign_at_deadline ?? true, + auto_assign_assignee_pool: config.auto_assign_assignee_pool, + auto_assign_review_due_hours: config.auto_assign_review_due_hours ?? 0, + auto_assign_grader_subset_private_profile_ids: config.auto_assign_grader_subset_private_profile_ids ?? [], + late_grading_reminders_enabled: config.late_grading_reminders_enabled ?? false, + late_grading_reminder_interval_hours: + config.late_grading_reminders_enabled === false ? null : (config.late_grading_reminder_interval_hours ?? null), + late_grading_reply_to: config.late_grading_reply_to ?? null, + late_grading_cc_emails: config.late_grading_cc_emails ?? { emails: [] } + }) + .eq("id", assignmentId); + expect(error).toBeNull(); +} + +async function insertGradingConflict({ + classId, + graderProfileId, + studentProfileId, + createdByProfileId +}: { + classId: number; + graderProfileId: string; + studentProfileId: string; + createdByProfileId: string; +}) { + const { error } = await supabase.from("grading_conflicts").insert({ + class_id: classId, + grader_profile_id: graderProfileId, + student_profile_id: studentProfileId, + created_by_profile_id: createdByProfileId + }); + expect(error).toBeNull(); +} + +async function insertAssignmentGroup({ + classId, + assignmentId, + name, + mentorProfileId, + memberProfileIds, + addedByProfileId +}: { + classId: number; + assignmentId: number; + name: string; + mentorProfileId: string | null; + memberProfileIds: string[]; + addedByProfileId: string; +}): Promise { + const { data: groupRow, error: groupError } = await supabase + .from("assignment_groups") + .insert({ + class_id: classId, + assignment_id: assignmentId, + name, + mentor_profile_id: mentorProfileId + }) + .select("id") + .single(); + expect(groupError).toBeNull(); + const groupId = groupRow!.id; + + if (memberProfileIds.length > 0) { + const { error: memberError } = await supabase.from("assignment_groups_members").insert( + memberProfileIds.map((pid) => ({ + assignment_group_id: groupId, + class_id: classId, + assignment_id: assignmentId, + profile_id: pid, + added_by: addedByProfileId + })) + ); + expect(memberError).toBeNull(); + } + return groupId; +} + +async function fetchReviewAssignments(assignmentId: number, rubricId: number) { + const { data, error } = await supabase + .from("review_assignments") + .select("submission_id, assignee_profile_id, completed_at, due_date, rubric_id") + .eq("assignment_id", assignmentId) + .eq("rubric_id", rubricId); + expect(error).toBeNull(); + return data ?? []; +} + +function makeSuffix(label: string) { + return `${label}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +test("auto-assign with 'instructors' pool rotates submissions across active instructors", async () => { + test.setTimeout(120_000); + const suffix = makeSuffix("instructors-pool"); + + const [extraInstructorA, extraInstructorB, student1, student2, student3] = await createUsersInClass([ + { + name: `E2E Inst Pool A ${suffix}`, + email: `e2e-inst-pool-a-${suffix}@pawtograder.net`, + role: "instructor", + class_id: course.id + }, + { + name: `E2E Inst Pool B ${suffix}`, + email: `e2e-inst-pool-b-${suffix}@pawtograder.net`, + role: "instructor", + class_id: course.id + }, + { + name: `E2E IP Student 1 ${suffix}`, + email: `e2e-ip-s1-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + }, + { + name: `E2E IP Student 2 ${suffix}`, + email: `e2e-ip-s2-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + }, + { + name: `E2E IP Student 3 ${suffix}`, + email: `e2e-ip-s3-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + } + ]); + + const a = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 1).toISOString(), + name: `E2E Instructors Pool ${suffix}`, + assignment_slug: `e2e-instructors-pool-${suffix}` + }); + await configureAutoAssign(a.id, { auto_assign_assignee_pool: "instructors" }); + + await insertPreBakedSubmission({ + student_profile_id: student1.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + await insertPreBakedSubmission({ + student_profile_id: student2.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + await insertPreBakedSubmission({ + student_profile_id: student3.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + + const { error: rpcError } = await supabase.rpc("run_assignment_grading_automation"); + expect(rpcError).toBeNull(); + + // Original beforeAll instructor + extraInstructorA + extraInstructorB are eligible. + const instructorPool = new Set([ + instructor!.private_profile_id, + extraInstructorA.private_profile_id, + extraInstructorB.private_profile_id + ]); + + const rows = await fetchReviewAssignments(a.id, a.grading_rubric_id!); + expect(rows.length).toBe(3); + for (const row of rows) { + expect(instructorPool.has(row.assignee_profile_id)).toBe(true); + } + // Round-robin: with 3 submissions and 3 instructors each instructor should be hit exactly once. + const distinctAssignees = new Set(rows.map((r) => r.assignee_profile_id)); + expect(distinctAssignees.size).toBe(3); +}); + +test("auto-assign with 'instructors_and_graders' pool rotates across both roles", async () => { + test.setTimeout(120_000); + const suffix = makeSuffix("inst-graders-pool"); + + const [graderA, graderB, student1, student2] = await createUsersInClass([ + { + name: `E2E IG Grader A ${suffix}`, + email: `e2e-ig-grader-a-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E IG Grader B ${suffix}`, + email: `e2e-ig-grader-b-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E IG Student 1 ${suffix}`, + email: `e2e-ig-s1-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + }, + { + name: `E2E IG Student 2 ${suffix}`, + email: `e2e-ig-s2-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + } + ]); + + const a = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 1).toISOString(), + name: `E2E Instructors+Graders ${suffix}`, + assignment_slug: `e2e-instructors-graders-${suffix}` + }); + await configureAutoAssign(a.id, { auto_assign_assignee_pool: "instructors_and_graders" }); + + await insertPreBakedSubmission({ + student_profile_id: student1.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + await insertPreBakedSubmission({ + student_profile_id: student2.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + + const { error: rpcError } = await supabase.rpc("run_assignment_grading_automation"); + expect(rpcError).toBeNull(); + + // Pool includes every non-disabled instructor or grader in the course. Capture the live snapshot, + // since prior tests may have added graders. + const { data: pool, error: poolError } = await supabase + .from("user_roles") + .select("private_profile_id") + .eq("class_id", course.id) + .eq("disabled", false) + .in("role", ["grader", "instructor"]); + expect(poolError).toBeNull(); + const poolIds = new Set(pool!.map((r) => r.private_profile_id)); + // Sanity: the freshly created graders must be in the pool. + expect(poolIds.has(graderA.private_profile_id)).toBe(true); + expect(poolIds.has(graderB.private_profile_id)).toBe(true); + + const rows = await fetchReviewAssignments(a.id, a.grading_rubric_id!); + expect(rows.length).toBe(2); + for (const row of rows) { + expect(poolIds.has(row.assignee_profile_id)).toBe(true); + } +}); + +test("auto-assign with 'graders' subset restricts rotation to the selected graders", async () => { + test.setTimeout(120_000); + const suffix = makeSuffix("graders-subset"); + + const [allowedGrader, excludedGrader, student1, student2, student3] = await createUsersInClass([ + { + name: `E2E Allowed Grader ${suffix}`, + email: `e2e-allowed-grader-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E Excluded Grader ${suffix}`, + email: `e2e-excluded-grader-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E GS Student 1 ${suffix}`, + email: `e2e-gs-s1-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + }, + { + name: `E2E GS Student 2 ${suffix}`, + email: `e2e-gs-s2-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + }, + { + name: `E2E GS Student 3 ${suffix}`, + email: `e2e-gs-s3-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + } + ]); + + const a = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 1).toISOString(), + name: `E2E Graders Subset ${suffix}`, + assignment_slug: `e2e-graders-subset-${suffix}` + }); + await configureAutoAssign(a.id, { + auto_assign_assignee_pool: "graders", + auto_assign_grader_subset_private_profile_ids: [allowedGrader.private_profile_id] + }); + + await insertPreBakedSubmission({ + student_profile_id: student1.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + await insertPreBakedSubmission({ + student_profile_id: student2.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + await insertPreBakedSubmission({ + student_profile_id: student3.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + + const { error: rpcError } = await supabase.rpc("run_assignment_grading_automation"); + expect(rpcError).toBeNull(); + + const rows = await fetchReviewAssignments(a.id, a.grading_rubric_id!); + expect(rows.length).toBe(3); + for (const row of rows) { + // Subset only contains allowedGrader; excludedGrader must never be picked even though they + // would otherwise qualify under the full graders pool. + expect(row.assignee_profile_id).toBe(allowedGrader.private_profile_id); + expect(row.assignee_profile_id).not.toBe(excludedGrader.private_profile_id); + } +}); + +test("auto-assign with 'lab_leaders' pool assigns submissions to section leaders and skips conflicted leaders", async () => { + test.setTimeout(120_000); + const suffix = makeSuffix("lab-leaders-conflict"); + + const [leaderA1, leaderA2, leaderBSolo, studentA, studentB] = await createUsersInClass([ + { name: `E2E LL A1 ${suffix}`, email: `e2e-ll-a1-${suffix}@pawtograder.net`, role: "grader", class_id: course.id }, + { name: `E2E LL A2 ${suffix}`, email: `e2e-ll-a2-${suffix}@pawtograder.net`, role: "grader", class_id: course.id }, + { name: `E2E LL B ${suffix}`, email: `e2e-ll-b-${suffix}@pawtograder.net`, role: "grader", class_id: course.id }, + { + name: `E2E LL Student A ${suffix}`, + email: `e2e-ll-sa-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + }, + { + name: `E2E LL Student B ${suffix}`, + email: `e2e-ll-sb-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + } + ]); + + // Section A has two leaders. Leader A1 has a conflict with student A, so only Leader A2 should + // be assigned student A's submission. Section B has a single leader (solo). + await createLabSectionWithStudents({ + class_id: course.id, + day_of_week: "monday", + lab_leaders: [leaderA1, leaderA2], + students: [studentA], + name: `E2E Lab A (conflict) ${suffix}` + }); + await createLabSectionWithStudents({ + class_id: course.id, + day_of_week: "tuesday", + lab_leaders: [leaderBSolo], + students: [studentB], + name: `E2E Lab B (solo) ${suffix}` + }); + + await insertGradingConflict({ + classId: course.id, + graderProfileId: leaderA1.private_profile_id, + studentProfileId: studentA.private_profile_id, + createdByProfileId: instructor!.private_profile_id + }); + + const a = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 1).toISOString(), + name: `E2E Lab Leaders Conflict ${suffix}`, + assignment_slug: `e2e-lab-leaders-conflict-${suffix}` + }); + await configureAutoAssign(a.id, { auto_assign_assignee_pool: "lab_leaders" }); + + const subA = await insertPreBakedSubmission({ + student_profile_id: studentA.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + const subB = await insertPreBakedSubmission({ + student_profile_id: studentB.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + + const { error: rpcError } = await supabase.rpc("run_assignment_grading_automation"); + expect(rpcError).toBeNull(); + + const rows = await fetchReviewAssignments(a.id, a.grading_rubric_id!); + const byAssignee = (sid: number) => + rows + .filter((r) => r.submission_id === sid) + .map((r) => r.assignee_profile_id) + .sort(); + + // Submission A should have exactly one review assignment: leaderA2 (leaderA1 was conflicted out). + expect(byAssignee(subA.submission_id)).toEqual([leaderA2.private_profile_id].sort()); + // Submission B should have exactly one review assignment: leaderBSolo. + expect(byAssignee(subB.submission_id)).toEqual([leaderBSolo.private_profile_id].sort()); +}); + +test("auto-assign with 'group_mentors' pool assigns submissions to mentor and skips conflicted mentors", async () => { + test.setTimeout(120_000); + const suffix = makeSuffix("group-mentors"); + + const [mentorClean, mentorConflict, memberClean, memberConflict] = await createUsersInClass([ + { + name: `E2E Mentor Clean ${suffix}`, + email: `e2e-mentor-clean-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E Mentor Conflict ${suffix}`, + email: `e2e-mentor-conflict-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E GM Member Clean ${suffix}`, + email: `e2e-gm-mc-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + }, + { + name: `E2E GM Member Conflict ${suffix}`, + email: `e2e-gm-mx-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + } + ]); + + const a = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 1).toISOString(), + name: `E2E Group Mentors ${suffix}`, + assignment_slug: `e2e-group-mentors-${suffix}`, + group_config: "groups", + min_group_size: 1, + max_group_size: 4, + group_formation_deadline: addDays(new Date(), 7).toISOString() + }); + await configureAutoAssign(a.id, { auto_assign_assignee_pool: "group_mentors" }); + + const cleanGroupId = await insertAssignmentGroup({ + classId: course.id, + assignmentId: a.id, + name: `Group Clean ${suffix}`, + mentorProfileId: mentorClean.private_profile_id, + memberProfileIds: [memberClean.private_profile_id], + addedByProfileId: instructor!.private_profile_id + }); + const conflictGroupId = await insertAssignmentGroup({ + classId: course.id, + assignmentId: a.id, + name: `Group Conflict ${suffix}`, + mentorProfileId: mentorConflict.private_profile_id, + memberProfileIds: [memberConflict.private_profile_id], + addedByProfileId: instructor!.private_profile_id + }); + + await insertGradingConflict({ + classId: course.id, + graderProfileId: mentorConflict.private_profile_id, + studentProfileId: memberConflict.private_profile_id, + createdByProfileId: instructor!.private_profile_id + }); + + const subClean = await insertPreBakedSubmission({ + assignment_group_id: cleanGroupId, + assignment_id: a.id, + class_id: course.id + }); + const subConflict = await insertPreBakedSubmission({ + assignment_group_id: conflictGroupId, + assignment_id: a.id, + class_id: course.id + }); + + const { error: rpcError } = await supabase.rpc("run_assignment_grading_automation"); + expect(rpcError).toBeNull(); + + const rows = await fetchReviewAssignments(a.id, a.grading_rubric_id!); + // Clean group's submission gets a review assignment for its mentor. + const cleanRows = rows.filter((r) => r.submission_id === subClean.submission_id); + expect(cleanRows.length).toBe(1); + expect(cleanRows[0].assignee_profile_id).toBe(mentorClean.private_profile_id); + // Conflicted group's submission gets none — the mentor is skipped, and there's no fallback. + const conflictRows = rows.filter((r) => r.submission_id === subConflict.submission_id); + expect(conflictRows.length).toBe(0); +}); + +test("run_assignment_grading_automation is idempotent across repeated calls", async () => { + test.setTimeout(120_000); + const suffix = makeSuffix("idempotency"); + + const [grader1, student1, student2] = await createUsersInClass([ + { + name: `E2E Idem Grader ${suffix}`, + email: `e2e-idem-grader-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E Idem S1 ${suffix}`, + email: `e2e-idem-s1-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + }, + { + name: `E2E Idem S2 ${suffix}`, + email: `e2e-idem-s2-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + } + ]); + + const a = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 1).toISOString(), + name: `E2E Idempotency ${suffix}`, + assignment_slug: `e2e-idempotency-${suffix}` + }); + await configureAutoAssign(a.id, { + auto_assign_assignee_pool: "graders", + auto_assign_grader_subset_private_profile_ids: [grader1.private_profile_id], + late_grading_reminders_enabled: true, + late_grading_reminder_interval_hours: 12, + late_grading_reply_to: "idem-reply@example.edu", + late_grading_cc_emails: { emails: ["idem-cc@example.edu"] } + }); + + await insertPreBakedSubmission({ + student_profile_id: student1.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + await insertPreBakedSubmission({ + student_profile_id: student2.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + + // First run. + const { error: firstError } = await supabase.rpc("run_assignment_grading_automation"); + expect(firstError).toBeNull(); + + const firstRows = await fetchReviewAssignments(a.id, a.grading_rubric_id!); + expect(firstRows.length).toBe(2); + + const { data: firstState, error: firstStateError } = await supabase + .from("assignment_grading_automation_state") + .select("auto_assigned_at, last_reminder_sent_at, last_reminder_recipient_count") + .eq("assignment_id", a.id) + .single(); + expect(firstStateError).toBeNull(); + expect(firstState!.auto_assigned_at).not.toBeNull(); + expect(firstState!.last_reminder_sent_at).not.toBeNull(); + const firstAutoAssignedAt = firstState!.auto_assigned_at; + const firstReminderSentAt = firstState!.last_reminder_sent_at; + + const subjectLike = `Late grading reminder: ${a.title}%`; + const { data: batchesAfterFirst, error: batchesAfterFirstError } = await supabase + .from("email_batches") + .select("id") + .eq("class_id", course.id) + .like("subject", subjectLike); + expect(batchesAfterFirstError).toBeNull(); + const firstBatchCount = batchesAfterFirst!.length; + expect(firstBatchCount).toBeGreaterThan(0); + + // Second run, immediately. Inner gates (auto_assigned_at IS NULL; reminder interval not elapsed) + // should make this a no-op for both branches; new-outer-WHERE should make the row not even + // selected. Either way, no new review_assignments and no new reminder batches. + const { error: secondError } = await supabase.rpc("run_assignment_grading_automation"); + expect(secondError).toBeNull(); + + const secondRows = await fetchReviewAssignments(a.id, a.grading_rubric_id!); + expect(secondRows.length).toBe(firstRows.length); + + const { data: secondState, error: secondStateError } = await supabase + .from("assignment_grading_automation_state") + .select("auto_assigned_at, last_reminder_sent_at") + .eq("assignment_id", a.id) + .single(); + expect(secondStateError).toBeNull(); + // Neither marker should have advanced. + expect(secondState!.auto_assigned_at).toBe(firstAutoAssignedAt); + expect(secondState!.last_reminder_sent_at).toBe(firstReminderSentAt); + + const { data: batchesAfterSecond, error: batchesAfterSecondError } = await supabase + .from("email_batches") + .select("id") + .eq("class_id", course.id) + .like("subject", subjectLike); + expect(batchesAfterSecondError).toBeNull(); + expect(batchesAfterSecond!.length).toBe(firstBatchCount); +}); + +test("reminders only refire after the configured interval elapses", async () => { + test.setTimeout(120_000); + const suffix = makeSuffix("cadence"); + const intervalHours = 6; + + const [graderC, studentC] = await createUsersInClass([ + { + name: `E2E Cadence Grader ${suffix}`, + email: `e2e-cad-grader-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E Cadence Student ${suffix}`, + email: `e2e-cad-s-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + } + ]); + + const a = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 1).toISOString(), + name: `E2E Reminder Cadence ${suffix}`, + assignment_slug: `e2e-cadence-${suffix}` + }); + await configureAutoAssign(a.id, { + auto_assign_assignee_pool: "graders", + auto_assign_grader_subset_private_profile_ids: [graderC.private_profile_id], + late_grading_reminders_enabled: true, + late_grading_reminder_interval_hours: intervalHours, + late_grading_reply_to: "cadence-reply@example.edu", + late_grading_cc_emails: { emails: ["cadence-cc@example.edu"] } + }); + + await insertPreBakedSubmission({ + student_profile_id: studentC.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + + const subjectLike = `Late grading reminder: ${a.title}%`; + + // First tick: reminder is queued. + const { error: firstError } = await supabase.rpc("run_assignment_grading_automation"); + expect(firstError).toBeNull(); + const { data: firstBatches } = await supabase.from("email_batches").select("id").like("subject", subjectLike); + expect(firstBatches!.length).toBe(1); + + // Second tick, immediately. Interval has not elapsed → no new batch. + const { error: secondError } = await supabase.rpc("run_assignment_grading_automation"); + expect(secondError).toBeNull(); + const { data: secondBatches } = await supabase.from("email_batches").select("id").like("subject", subjectLike); + expect(secondBatches!.length).toBe(1); + + // Backdate last_reminder_sent_at past the interval. Now the cadence check should fire again. + const backdated = new Date(Date.now() - (intervalHours + 1) * 60 * 60 * 1000).toISOString(); + const { error: updateError } = await supabase + .from("assignment_grading_automation_state") + .update({ last_reminder_sent_at: backdated }) + .eq("assignment_id", a.id); + expect(updateError).toBeNull(); + + const { error: thirdError } = await supabase.rpc("run_assignment_grading_automation"); + expect(thirdError).toBeNull(); + const { data: thirdBatches } = await supabase.from("email_batches").select("id").like("subject", subjectLike); + expect(thirdBatches!.length).toBe(2); +}); + +test("reminder email queue excludes completed, future-due, disabled, and wrong-rubric review assignments", async () => { + test.setTimeout(120_000); + const suffix = makeSuffix("reminder-filters"); + + const [graderActive, graderDisabled, graderCompleted, graderFuture, graderWrongRubric, studentSeed] = + await createUsersInClass([ + { + name: `E2E Active Grader ${suffix}`, + email: `e2e-rf-active-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E Disabled Grader ${suffix}`, + email: `e2e-rf-disabled-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E Completed Grader ${suffix}`, + email: `e2e-rf-completed-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E Future Grader ${suffix}`, + email: `e2e-rf-future-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E Wrong-Rubric Grader ${suffix}`, + email: `e2e-rf-wr-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E RF Student ${suffix}`, + email: `e2e-rf-student-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + } + ]); + + // Disable graderDisabled so the disabled-grader filter has work to do. + const { error: disableError } = await supabase + .from("user_roles") + .update({ disabled: true }) + .eq("private_profile_id", graderDisabled.private_profile_id) + .eq("class_id", course.id); + expect(disableError).toBeNull(); + + const a = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 1).toISOString(), + name: `E2E Reminder Filters ${suffix}`, + assignment_slug: `e2e-reminder-filters-${suffix}` + }); + await configureAutoAssign(a.id, { + auto_assign_at_deadline: false, // We seed review_assignments by hand for full control. + auto_assign_assignee_pool: "graders", + late_grading_reminders_enabled: true, + late_grading_reminder_interval_hours: 12, + late_grading_reply_to: "filters-reply@example.edu", + late_grading_cc_emails: { emails: ["filters-cc@example.edu"] } + }); + + // Single submission — each review_assignment row is keyed differently so all five can coexist + // on the same submission_review/rubric combo by assigning to distinct assignees. + const sub = await insertPreBakedSubmission({ + student_profile_id: studentSeed.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + + // Look up (or create) the submission_review rows that review_assignments FK-reference. Other + // triggers in the system may auto-create the grading-rubric submission_review on submission + // insert, so use upsert-style logic to be robust to that. + const ensureSubmissionReview = async (rubricId: number, name: string): Promise => { + const { data: existing } = await supabase + .from("submission_reviews") + .select("id") + .eq("submission_id", sub.submission_id) + .eq("rubric_id", rubricId) + .maybeSingle(); + if (existing) return existing.id; + const { data: created, error: createErr } = await supabase + .from("submission_reviews") + .insert({ + submission_id: sub.submission_id, + rubric_id: rubricId, + class_id: course.id, + name, + total_score: 0, + total_autograde_score: 0, + tweak: 0 + }) + .select("id") + .single(); + expect(createErr).toBeNull(); + return created!.id; + }; + const gradingReviewId = await ensureSubmissionReview(a.grading_rubric_id!, "Grading review for reminder-filter test"); + const selfReviewId = await ensureSubmissionReview(a.self_review_rubric_id!, "Self review for reminder-filter test"); + + const overdue = addDays(new Date(), -1).toISOString(); + const future = addDays(new Date(), 3).toISOString(); + + const { error: raInsertError } = await supabase.from("review_assignments").insert([ + // INCLUDED: overdue, incomplete, enabled grader, matching rubric. + { + assignee_profile_id: graderActive.private_profile_id, + submission_id: sub.submission_id, + submission_review_id: gradingReviewId, + assignment_id: a.id, + rubric_id: a.grading_rubric_id!, + class_id: course.id, + due_date: overdue + }, + // EXCLUDED (completed_at set). + { + assignee_profile_id: graderCompleted.private_profile_id, + submission_id: sub.submission_id, + submission_review_id: gradingReviewId, + assignment_id: a.id, + rubric_id: a.grading_rubric_id!, + class_id: course.id, + due_date: overdue, + completed_at: new Date().toISOString() + }, + // EXCLUDED (due_date in future). + { + assignee_profile_id: graderFuture.private_profile_id, + submission_id: sub.submission_id, + submission_review_id: gradingReviewId, + assignment_id: a.id, + rubric_id: a.grading_rubric_id!, + class_id: course.id, + due_date: future + }, + // EXCLUDED (disabled grader). + { + assignee_profile_id: graderDisabled.private_profile_id, + submission_id: sub.submission_id, + submission_review_id: gradingReviewId, + assignment_id: a.id, + rubric_id: a.grading_rubric_id!, + class_id: course.id, + due_date: overdue + }, + // EXCLUDED (rubric mismatch — uses the self-review rubric instead of the grading rubric). + { + assignee_profile_id: graderWrongRubric.private_profile_id, + submission_id: sub.submission_id, + submission_review_id: selfReviewId, + assignment_id: a.id, + rubric_id: a.self_review_rubric_id!, + class_id: course.id, + due_date: overdue + } + ]); + expect(raInsertError).toBeNull(); + + const subjectLike = `Late grading reminder: ${a.title}%`; + const { error: rpcError } = await supabase.rpc("run_assignment_grading_automation"); + expect(rpcError).toBeNull(); + + const { data: emails, error: emailsErr } = await supabase + .from("emails") + .select("user_id, subject") + .like("subject", subjectLike); + expect(emailsErr).toBeNull(); + + const recipientUserIds = new Set(emails!.map((row) => row.user_id)); + + // Only the active grader with an overdue incomplete review on the matching rubric should be + // emailed; the four excluded cases must not appear. + expect(recipientUserIds.has(graderActive.user_id)).toBe(true); + expect(recipientUserIds.has(graderCompleted.user_id)).toBe(false); + expect(recipientUserIds.has(graderFuture.user_id)).toBe(false); + expect(recipientUserIds.has(graderDisabled.user_id)).toBe(false); + expect(recipientUserIds.has(graderWrongRubric.user_id)).toBe(false); +}); + +test("late submission after deadline is auto-assigned at submission time without running the cron", async () => { + test.setTimeout(120_000); + const suffix = makeSuffix("late-submission"); + + const [graderLate, studentEarly, studentLate] = await createUsersInClass([ + { + name: `E2E Late Grader ${suffix}`, + email: `e2e-late-grader-${suffix}@pawtograder.net`, + role: "grader", + class_id: course.id + }, + { + name: `E2E Early Student ${suffix}`, + email: `e2e-early-s-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + }, + { + name: `E2E Late Student ${suffix}`, + email: `e2e-late-s-${suffix}@pawtograder.net`, + role: "student", + class_id: course.id + } + ]); + + const a = await insertAssignment({ + class_id: course.id, + due_date: addDays(new Date(), 1).toISOString(), + name: `E2E Late Submission ${suffix}`, + assignment_slug: `e2e-late-submission-${suffix}` + }); + + // Pre-deadline configuration: submission inserted now should NOT be auto-assigned by the + // trigger, because the deadline is still in the future. The cron is the right path for these. + await configureAutoAssign(a.id, { + auto_assign_at_deadline: true, + auto_assign_assignee_pool: "graders", + auto_assign_grader_subset_private_profile_ids: [graderLate.private_profile_id], + due_date: addDays(new Date(), 1).toISOString() + }); + + const earlySub = await insertPreBakedSubmission({ + student_profile_id: studentEarly.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + + // Trigger must NOT fire on this pre-deadline submission. + { + const { data: preRows, error: preErr } = await supabase + .from("review_assignments") + .select("submission_id") + .eq("assignment_id", a.id) + .eq("rubric_id", a.grading_rubric_id!); + expect(preErr).toBeNull(); + expect(preRows!.length).toBe(0); + } + + // Now flip the deadline to the past, simulating a student with a due-date extension who is + // submitting after the assignment's nominal deadline has already passed. + const { error: pastDueError } = await supabase + .from("assignments") + .update({ due_date: addDays(new Date(), -1).toISOString() }) + .eq("id", a.id); + expect(pastDueError).toBeNull(); + + // Capture review_assignments count immediately before the late insert so the post-condition + // is unambiguous. + const lateSub = await insertPreBakedSubmission({ + student_profile_id: studentLate.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); + + // Trigger must have fired during the INSERT — assert without invoking the cron RPC. + const lateRows = await fetchReviewAssignments(a.id, a.grading_rubric_id!); + const lateRowForSub = lateRows.find((r) => r.submission_id === lateSub.submission_id); + expect(lateRowForSub).toBeDefined(); + expect(lateRowForSub!.assignee_profile_id).toBe(graderLate.private_profile_id); + // The earlier (pre-deadline) submission still has no review_assignment — the cron handles those. + expect(lateRows.find((r) => r.submission_id === earlySub.submission_id)).toBeUndefined(); + + // Verify the cron makes the early submission whole. After running, both submissions have a row. + const { error: rpcError } = await supabase.rpc("run_assignment_grading_automation"); + expect(rpcError).toBeNull(); + const finalRows = await fetchReviewAssignments(a.id, a.grading_rubric_id!); + expect(finalRows.length).toBe(2); + expect(finalRows.every((r) => r.assignee_profile_id === graderLate.private_profile_id)).toBe(true); +}); diff --git a/utils/supabase/DatabaseTypes.d.ts b/utils/supabase/DatabaseTypes.d.ts index 2a9ec7625..cf4fca6f1 100644 --- a/utils/supabase/DatabaseTypes.d.ts +++ b/utils/supabase/DatabaseTypes.d.ts @@ -981,6 +981,22 @@ export type SelfReviewSettings = GetResult< "*" >; +export type GradingAssignmentDefaultProfile = GetResult< + Database["public"], + Database["public"]["Tables"]["grading_assignment_default_profiles"]["Row"], + "grading_assignment_default_profiles", + Database["public"]["Tables"]["grading_assignment_default_profiles"]["Relationships"], + "*" +>; + +export type AssignmentGradingAutomationState = GetResult< + Database["public"], + Database["public"]["Tables"]["assignment_grading_automation_state"]["Row"], + "assignment_grading_automation_state", + Database["public"]["Tables"]["assignment_grading_automation_state"]["Relationships"], + "*" +>; + export type ReviewAssignments = GetResult< Database["public"], Database["public"]["Tables"]["review_assignments"]["Row"], diff --git a/utils/supabase/SupabaseTypes.d.ts b/utils/supabase/SupabaseTypes.d.ts index 83bf9b5f7..d94bb4dfd 100644 --- a/utils/supabase/SupabaseTypes.d.ts +++ b/utils/supabase/SupabaseTypes.d.ts @@ -296,6 +296,79 @@ export type Database = { } ]; }; + assignment_grading_automation_state: { + Row: { + assignment_id: number; + auto_assigned_at: string | null; + class_id: number; + created_at: string; + last_reminder_recipient_count: number; + last_reminder_sent_at: string | null; + updated_at: string; + }; + Insert: { + assignment_id: number; + auto_assigned_at?: string | null; + class_id: number; + created_at?: string; + last_reminder_recipient_count?: number; + last_reminder_sent_at?: string | null; + updated_at?: string; + }; + Update: { + assignment_id?: number; + auto_assigned_at?: string | null; + class_id?: number; + created_at?: string; + last_reminder_recipient_count?: number; + last_reminder_sent_at?: string | null; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "assignment_grading_automation_state_assignment_id_fkey"; + columns: ["assignment_id"]; + isOneToOne: true; + referencedRelation: "assignment_overview"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "assignment_grading_automation_state_assignment_id_fkey"; + columns: ["assignment_id"]; + isOneToOne: true; + referencedRelation: "assignments"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "assignment_grading_automation_state_assignment_id_fkey"; + columns: ["assignment_id"]; + isOneToOne: true; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "assignment_grading_automation_state_assignment_id_fkey"; + columns: ["assignment_id"]; + isOneToOne: true; + referencedRelation: "assignments_with_effective_due_dates"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "assignment_grading_automation_state_assignment_id_fkey"; + columns: ["assignment_id"]; + isOneToOne: true; + referencedRelation: "submissions_with_grades_for_assignment_and_regression_test"; + referencedColumns: ["assignment_id"]; + }, + { + foreignKeyName: "assignment_grading_automation_state_class_id_fkey"; + columns: ["class_id"]; + isOneToOne: false; + referencedRelation: "classes"; + referencedColumns: ["id"]; + } + ]; + }; assignment_group_invitations: { Row: { assignment_group_id: number; @@ -1018,6 +1091,10 @@ export type Database = { allow_not_graded_submissions: boolean; allow_student_formed_groups: boolean | null; archived_at: string | null; + auto_assign_assignee_pool: string; + auto_assign_at_deadline: boolean; + auto_assign_review_due_hours: number; + auto_assign_grader_subset_private_profile_ids: Json; autograder_points: number | null; class_id: number; created_at: string; @@ -1026,6 +1103,7 @@ export type Database = { enable_repo_analytics: boolean; gradebook_column_id: number | null; grader_pseudonymous_mode: boolean; + grading_default_profile_id: number | null; grading_rubric_id: number | null; group_config: Database["public"]["Enums"]["assignment_group_mode"]; group_formation_deadline: string | null; @@ -1033,6 +1111,10 @@ export type Database = { has_autograder: boolean; has_handgrader: boolean; id: number; + late_grading_cc_emails: Json; + late_grading_reminder_interval_hours: number | null; + late_grading_reminders_enabled: boolean; + late_grading_reply_to: string | null; latest_template_sha: string | null; max_group_size: number | null; max_late_tokens: number; @@ -1057,6 +1139,10 @@ export type Database = { allow_not_graded_submissions?: boolean; allow_student_formed_groups?: boolean | null; archived_at?: string | null; + auto_assign_assignee_pool?: string; + auto_assign_at_deadline?: boolean; + auto_assign_review_due_hours?: number; + auto_assign_grader_subset_private_profile_ids?: Json; autograder_points?: number | null; class_id: number; created_at?: string; @@ -1065,6 +1151,7 @@ export type Database = { enable_repo_analytics?: boolean; gradebook_column_id?: number | null; grader_pseudonymous_mode?: boolean; + grading_default_profile_id?: number | null; grading_rubric_id?: number | null; group_config: Database["public"]["Enums"]["assignment_group_mode"]; group_formation_deadline?: string | null; @@ -1072,6 +1159,10 @@ export type Database = { has_autograder?: boolean; has_handgrader?: boolean; id?: number; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; latest_template_sha?: string | null; max_group_size?: number | null; max_late_tokens?: number; @@ -1096,6 +1187,10 @@ export type Database = { allow_not_graded_submissions?: boolean; allow_student_formed_groups?: boolean | null; archived_at?: string | null; + auto_assign_assignee_pool?: string; + auto_assign_at_deadline?: boolean; + auto_assign_review_due_hours?: number; + auto_assign_grader_subset_private_profile_ids?: Json; autograder_points?: number | null; class_id?: number; created_at?: string; @@ -1104,6 +1199,7 @@ export type Database = { enable_repo_analytics?: boolean; gradebook_column_id?: number | null; grader_pseudonymous_mode?: boolean; + grading_default_profile_id?: number | null; grading_rubric_id?: number | null; group_config?: Database["public"]["Enums"]["assignment_group_mode"]; group_formation_deadline?: string | null; @@ -1111,6 +1207,10 @@ export type Database = { has_autograder?: boolean; has_handgrader?: boolean; id?: number; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; latest_template_sha?: string | null; max_group_size?: number | null; max_late_tokens?: number; @@ -1139,6 +1239,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "assignments_grading_default_profile_id_fkey"; + columns: ["grading_default_profile_id", "class_id"]; + isOneToOne: false; + referencedRelation: "grading_assignment_default_profiles"; + referencedColumns: ["id", "class_id"]; + }, { foreignKeyName: "assignments_meta_grading_rubric_id_fkey"; columns: ["meta_grading_rubric_id"]; @@ -4093,6 +4200,65 @@ export type Database = { } ]; }; + grading_assignment_default_profiles: { + Row: { + auto_assign_assignee_pool: string; + auto_assign_at_deadline: boolean; + auto_assign_review_due_hours: number; + auto_assign_grader_subset_private_profile_ids: Json; + class_id: number; + created_at: string; + description: string | null; + id: number; + late_grading_cc_emails: Json; + late_grading_reminder_interval_hours: number | null; + late_grading_reminders_enabled: boolean; + late_grading_reply_to: string | null; + name: string; + updated_at: string; + }; + Insert: { + auto_assign_assignee_pool?: string; + auto_assign_at_deadline?: boolean; + auto_assign_review_due_hours?: number; + auto_assign_grader_subset_private_profile_ids?: Json; + class_id: number; + created_at?: string; + description?: string | null; + id?: number; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; + name: string; + updated_at?: string; + }; + Update: { + auto_assign_assignee_pool?: string; + auto_assign_at_deadline?: boolean; + auto_assign_review_due_hours?: number; + auto_assign_grader_subset_private_profile_ids?: Json; + class_id?: number; + created_at?: string; + description?: string | null; + id?: number; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; + name?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "grading_assignment_default_profiles_class_id_fkey"; + columns: ["class_id"]; + isOneToOne: false; + referencedRelation: "classes"; + referencedColumns: ["id"]; + } + ]; + }; grading_conflicts: { Row: { class_id: number; @@ -11352,6 +11518,10 @@ export type Database = { | { Args: { poll__id: number }; Returns: boolean } | { Args: { class__id: number; poll__id: number }; Returns: boolean }; authorizeforprofile: { Args: { profile_id: string }; Returns: boolean }; + auto_assign_grading_reviews_for_assignment: { + Args: { p_assignment_id: number }; + Returns: number; + }; bulk_assign_reviews: { Args: { p_assignment_id: number; @@ -12483,6 +12653,10 @@ export type Database = { }; Returns: Json; }; + queue_late_grading_reminders_for_assignment: { + Args: { p_assignment_id: number }; + Returns: number; + }; queue_repository_syncs: { Args: { p_repository_ids: number[] }; Returns: Json; @@ -12545,6 +12719,7 @@ export type Database = { Args: { p_class_id?: number }; Returns: undefined; }; + run_assignment_grading_automation: { Args: never; Returns: undefined }; safe_broadcast: { Args: { p_channel: string;