From 759cf83ca16f6ae8238ae63a210b4a63eac03f39 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 17:49:34 +0000 Subject: [PATCH 01/14] Add grading automation defaults and reminder settings Co-authored-by: Jonathan Bell --- app/course/[course_id]/dynamicCourseNav.tsx | 6 + .../assignments/[assignment_id]/edit/page.tsx | 35 +- .../manage/assignments/new/form.tsx | 317 +++++++++++- .../manage/assignments/new/page.tsx | 80 +-- .../grading-assignment-defaults/page.tsx | 378 ++++++++++++++ ...ding_assignment_defaults_and_reminders.sql | 488 ++++++++++++++++++ utils/supabase/DatabaseTypes.d.ts | 16 + utils/supabase/SupabaseTypes.d.ts | 172 ++++++ 8 files changed, 1453 insertions(+), 39 deletions(-) create mode 100644 app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx create mode 100644 supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql diff --git a/app/course/[course_id]/dynamicCourseNav.tsx b/app/course/[course_id]/dynamicCourseNav.tsx index c34997b01..64ac7f84b 100644 --- a/app/course/[course_id]/dynamicCourseNav.tsx +++ b/app/course/[course_id]/dynamicCourseNav.tsx @@ -168,6 +168,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..139c6720d 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 } 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 }); @@ -39,6 +39,32 @@ export default function EditAssignment() { form.setValue("eval_config", selfReviewSetting?.data.enabled ? "use_eval" : "base_only"); form.setValue("deadline_offset", selfReviewSetting?.data.deadline_offset); form.setValue("allow_early", selfReviewSetting?.data.allow_early); + form.setValue( + "grading_default_profile_id", + (queryData as AssignmentFormValues).grading_default_profile_id ?? null + ); + form.setValue("auto_assign_at_deadline", (queryData as AssignmentFormValues).auto_assign_at_deadline ?? false); + form.setValue( + "auto_assign_assignee_pool", + (queryData as AssignmentFormValues).auto_assign_assignee_pool ?? "graders" + ); + form.setValue( + "auto_assign_review_due_hours", + (queryData as AssignmentFormValues).auto_assign_review_due_hours ?? 72 + ); + form.setValue( + "late_grading_reminders_enabled", + (queryData as AssignmentFormValues).late_grading_reminders_enabled ?? false + ); + form.setValue( + "late_grading_reminder_interval_hours", + (queryData as AssignmentFormValues).late_grading_reminder_interval_hours ?? 12 + ); + form.setValue("late_grading_reply_to", (queryData as AssignmentFormValues).late_grading_reply_to ?? null); + form.setValue( + "late_grading_cc_emails", + (queryData as AssignmentFormValues).late_grading_cc_emails ?? { emails: [] } + ); } }, [ queryData, @@ -88,6 +114,11 @@ export default function EditAssignment() { values.eval_config = undefined; values.allow_early = undefined; values.deadline_offset = undefined; + values.late_grading_reminder_interval_hours = values.late_grading_reminders_enabled + ? (values.late_grading_reminder_interval_hours ?? 12) + : null; + values.late_grading_reply_to = values.late_grading_reply_to || null; + values.late_grading_cc_emails = values.late_grading_cc_emails || { emails: [] }; 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 16571e4c6..c6af7e35b 100644 --- a/app/course/[course_id]/manage/assignments/new/form.tsx +++ b/app/course/[course_id]/manage/assignments/new/form.tsx @@ -34,6 +34,56 @@ import { useCourseController } from "@/hooks/useCourseController"; import { LabSection, LabSectionMeeting } from "@/utils/supabase/DatabaseTypes"; import { useTableControllerTableValues } from "@/lib/TableController"; +type GradingAssigneePool = "graders" | "instructors" | "instructors_and_graders"; + +type GradingCcEmails = { + emails: string[]; +}; + +type GradingAssignmentDefaultProfile = { + id: number; + class_id: number; + name: string; + description: string | null; + auto_assign_at_deadline: boolean; + auto_assign_assignee_pool: GradingAssigneePool; + auto_assign_review_due_hours: number; + late_grading_reminders_enabled: boolean; + late_grading_reminder_interval_hours: number | null; + late_grading_reply_to: string | null; + late_grading_cc_emails: GradingCcEmails; +}; + +export type AssignmentFormValues = Assignment & { + grading_default_profile_id: number | null; + auto_assign_at_deadline: boolean; + auto_assign_assignee_pool: GradingAssigneePool; + auto_assign_review_due_hours: number; + late_grading_reminders_enabled: boolean; + late_grading_reminder_interval_hours: number | null; + late_grading_reply_to: string | null; + late_grading_cc_emails: GradingCcEmails; + copy_groups_from_assignment?: string; + eval_config?: "base_only" | "use_eval"; + deadline_offset?: number | null; + allow_early?: boolean | null; +}; + +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: [] }; +}; + // Helper function to calculate effective due date for a lab section function calculateLabSectionDueDate( labSection: LabSection, @@ -73,7 +123,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 +215,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 +414,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 +500,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 +598,263 @@ 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 } + }); + const profiles = profileData?.data ?? []; + const profileMap = profiles.reduce( + (acc, profile) => { + acc[profile.id] = profile; + return acc; + }, + {} as Record + ); + const [applyProfileOnSelect, setApplyProfileOnSelect] = useState(true); + + const { + register, + watch, + setValue, + 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 profileExists = selectedProfileId ? !!profileMap[selectedProfileId] : false; + + useEffect(() => { + if (!profileExists || !applyProfileOnSelect || !selectedProfileId) { + return; + } + const selectedProfile = profileMap[selectedProfileId]; + setValue("auto_assign_at_deadline", selectedProfile.auto_assign_at_deadline); + setValue("auto_assign_assignee_pool", selectedProfile.auto_assign_assignee_pool); + setValue("auto_assign_review_due_hours", selectedProfile.auto_assign_review_due_hours); + setValue("late_grading_reminders_enabled", selectedProfile.late_grading_reminders_enabled); + setValue("late_grading_reminder_interval_hours", selectedProfile.late_grading_reminder_interval_hours); + setValue("late_grading_reply_to", selectedProfile.late_grading_reply_to); + setValue("late_grading_cc_emails", normalizeCcEmails(selectedProfile.late_grading_cc_emails)); + }, [selectedProfileId, profileExists, applyProfileOnSelect, profileMap, setValue]); + + return ( + + + Grading Automation Defaults + + + + + + { + const rawValue = event.target.value; + setValue("grading_default_profile_id", rawValue ? Number(rawValue) : null, { shouldDirty: true }); + }} + > + + {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 && ( + <> + + + + + + + + + + + + + + + + + + )} + + + ( + 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, @@ -818,6 +1124,7 @@ export default function AssignmentForm({ + ({ + const form = useForm({ refineCoreProps: { resource: "assignments", action: "create" }, defaultValues: { allow_not_graded_submissions: true, - permit_empty_submissions: true + permit_empty_submissions: true, + grading_default_profile_id: null, + auto_assign_at_deadline: false, + auto_assign_assignee_pool: "graders", + auto_assign_review_due_hours: 72, + 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"; @@ -55,14 +61,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 } }, @@ -80,32 +86,42 @@ export default function NewAssignmentPage() { 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, - 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, + 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: form.getValues("auto_assign_assignee_pool") || "graders", + auto_assign_review_due_hours: form.getValues("auto_assign_review_due_hours") ?? 72, + late_grading_reminders_enabled: form.getValues("late_grading_reminders_enabled") || false, + late_grading_reminder_interval_hours: form.getValues("late_grading_reminders_enabled") + ? (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(); @@ -124,10 +140,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) }, @@ -158,7 +174,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..319b22c5c --- /dev/null +++ b/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Field } from "@/components/ui/field"; +import { toaster, Toaster } from "@/components/ui/toaster"; +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 { useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { LuCheck } from "react-icons/lu"; + +type GradingAssigneePool = "graders" | "instructors" | "instructors_and_graders"; +type GradingCcEmails = { emails: string[] }; + +type GradingAssignmentDefaultProfile = { + id: number; + class_id: number; + name: string; + description: string | null; + auto_assign_at_deadline: boolean; + auto_assign_assignee_pool: GradingAssigneePool; + auto_assign_review_due_hours: number; + late_grading_reminders_enabled: boolean; + late_grading_reminder_interval_hours: number | null; + late_grading_reply_to: string | null; + late_grading_cc_emails: GradingCcEmails; +}; + +type FormValues = Omit; + +const defaultValues: FormValues = { + name: "", + description: "", + auto_assign_at_deadline: false, + auto_assign_assignee_pool: "graders", + auto_assign_review_due_hours: 72, + 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 toCcText = (value: GradingCcEmails | null | undefined): string => (value?.emails ?? []).join(", "); + +export default function GradingAssignmentDefaultsPage() { + const { course_id } = useParams(); + const classId = Number(course_id); + 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 ccValue = watch("late_grading_cc_emails"); + 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: Number.isFinite(classId) } + }); + + 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, + 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: profile.late_grading_cc_emails ?? { emails: [] } + }); + }; + + const clearForm = () => { + setEditingId(null); + reset(defaultValues); + }; + + const onSubmit = form.handleSubmit(async (values) => { + 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, + 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: values.late_grading_cc_emails ?? { 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) => { + 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" + }); + } + }; + + 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 && ( + <> + + + + + + + + + + + + + + + + + + )} + + + ( + 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/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql new file mode 100644 index 000000000..8be7a7033 --- /dev/null +++ b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql @@ -0,0 +1,488 @@ +-- Class-level grading automation profiles + per-assignment defaults/reminders +-- Implements: +-- 1) "Auto assign at deadline" defaults via reusable class profiles +-- 2) Late grading reminders at deadline + recurring interval +-- 3) Instructor-configurable CC + reply-to for reminder emails + +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, + 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') + ), + 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) +); + +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 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) + REFERENCES public.grading_assignment_default_profiles(id) + ON DELETE SET NULL; + 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')); + 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() +); + +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; +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 + RETURN 0; + 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.created_at + LIMIT 1; + + IF v_actor_user_id IS NULL THEN + RETURN 0; + END IF; + + PERFORM set_config('request.jwt.claim.sub', v_actor_user_id::text, true); + + 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') + ) + ); + + v_staff_count := COALESCE(array_length(v_staff_ids, 1), 0); + IF v_staff_count = 0 THEN + RETURN 0; + END IF; + + v_review_due_date := v_assignment.due_date + make_interval(hours => GREATEST(v_assignment.auto_assign_review_due_hours, 0)); + + FOR v_submission IN + SELECT s.id + 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.id, + 'rubric_part_id', NULL + ) + ); + v_idx := v_idx + 1; + END LOOP; + + 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 0; + 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 (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_auto_assigned_count integer; + v_reminder_count integer; +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.auto_assign_at_deadline OR a.late_grading_reminders_enabled) + 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; + + IF v_assignment.auto_assign_at_deadline AND v_assignment.auto_assigned_at IS NULL THEN + v_auto_assigned_count := public.auto_assign_grading_reviews_for_assignment(v_assignment.id); + UPDATE public.assignment_grading_automation_state + SET auto_assigned_at = now(), + updated_at = now() + WHERE assignment_id = v_assignment.id; + + RAISE LOG 'Auto-assigned grading for assignment %, created/updated=%', v_assignment.id, v_auto_assigned_count; + END IF; + + IF v_assignment.late_grading_reminders_enabled + AND COALESCE(v_assignment.late_grading_reminder_interval_hours, 0) > 0 + AND ( + v_assignment.last_reminder_sent_at IS NULL + OR v_assignment.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; +$$; + +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.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 + 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 * * * *', + $$SELECT public.run_assignment_grading_automation();$$ + ); + ELSE + RAISE NOTICE 'pg_cron extension not available - grading automation schedule not created'; + END IF; +END $$; diff --git a/utils/supabase/DatabaseTypes.d.ts b/utils/supabase/DatabaseTypes.d.ts index 859f35aba..083dd6ec6 100644 --- a/utils/supabase/DatabaseTypes.d.ts +++ b/utils/supabase/DatabaseTypes.d.ts @@ -948,6 +948,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 23c74dd82..a30b30fdc 100644 --- a/utils/supabase/SupabaseTypes.d.ts +++ b/utils/supabase/SupabaseTypes.d.ts @@ -1013,11 +1013,143 @@ 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: "assignments"; + referencedColumns: ["id"]; + }, + { + 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_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"]; + } + ]; + }; + grading_assignment_default_profiles: { + Row: { + auto_assign_assignee_pool: string; + auto_assign_at_deadline: boolean; + auto_assign_review_due_hours: number; + 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; + 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; + 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"]; + } + ]; + }; assignments: { Row: { 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; autograder_points: number | null; class_id: number; created_at: string; @@ -1025,6 +1157,7 @@ export type Database = { due_date: string; enable_repo_analytics: boolean; gradebook_column_id: number | null; + grading_default_profile_id: number | null; grader_pseudonymous_mode: boolean; grading_rubric_id: number | null; group_config: Database["public"]["Enums"]["assignment_group_mode"]; @@ -1039,6 +1172,10 @@ export type Database = { meta_grading_rubric_id: number | null; min_group_size: number | null; minutes_due_after_lab: number | null; + late_grading_cc_emails: Json; + late_grading_reminder_interval_hours: number | null; + late_grading_reminders_enabled: boolean; + late_grading_reply_to: string | null; permit_empty_submissions: boolean; regrade_deadline: string | null; release_date: string | null; @@ -1056,6 +1193,9 @@ 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; autograder_points?: number | null; class_id: number; created_at?: string; @@ -1063,6 +1203,7 @@ export type Database = { due_date: string; enable_repo_analytics?: boolean; gradebook_column_id?: number | null; + grading_default_profile_id?: number | null; grader_pseudonymous_mode?: boolean; grading_rubric_id?: number | null; group_config: Database["public"]["Enums"]["assignment_group_mode"]; @@ -1077,6 +1218,10 @@ export type Database = { meta_grading_rubric_id?: number | null; min_group_size?: number | null; minutes_due_after_lab?: number | null; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; permit_empty_submissions?: boolean; regrade_deadline?: string | null; release_date?: string | null; @@ -1094,6 +1239,9 @@ 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; autograder_points?: number | null; class_id?: number; created_at?: string; @@ -1101,6 +1249,7 @@ export type Database = { due_date?: string; enable_repo_analytics?: boolean; gradebook_column_id?: number | null; + grading_default_profile_id?: number | null; grader_pseudonymous_mode?: boolean; grading_rubric_id?: number | null; group_config?: Database["public"]["Enums"]["assignment_group_mode"]; @@ -1115,6 +1264,10 @@ export type Database = { meta_grading_rubric_id?: number | null; min_group_size?: number | null; minutes_due_after_lab?: number | null; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; permit_empty_submissions?: boolean; regrade_deadline?: string | null; release_date?: string | null; @@ -1143,6 +1296,13 @@ export type Database = { referencedRelation: "rubrics"; referencedColumns: ["id"]; }, + { + foreignKeyName: "assignments_grading_default_profile_id_fkey"; + columns: ["grading_default_profile_id"]; + isOneToOne: false; + referencedRelation: "grading_assignment_default_profiles"; + referencedColumns: ["id"]; + }, { foreignKeyName: "assignments_rubric_id_fkey"; columns: ["grading_rubric_id"]; @@ -12356,6 +12516,18 @@ export type Database = { }; Returns: undefined; }; + run_assignment_grading_automation: { + Args: never; + Returns: undefined; + }; + auto_assign_grading_reviews_for_assignment: { + Args: { p_assignment_id: number }; + Returns: number; + }; + queue_late_grading_reminders_for_assignment: { + Args: { p_assignment_id: number }; + Returns: number; + }; release_all_grading_reviews_for_assignment: { Args: { assignment_id: number }; Returns: number; From 6b024ac53a8d26d104bf1ba3f0e8e4e094934e34 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 17:54:25 +0000 Subject: [PATCH 02/14] Sync shared Supabase types for grading automation schema Co-authored-by: Jonathan Bell --- supabase/functions/_shared/SupabaseTypes.d.ts | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/supabase/functions/_shared/SupabaseTypes.d.ts b/supabase/functions/_shared/SupabaseTypes.d.ts index 23c74dd82..a30b30fdc 100644 --- a/supabase/functions/_shared/SupabaseTypes.d.ts +++ b/supabase/functions/_shared/SupabaseTypes.d.ts @@ -1013,11 +1013,143 @@ 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: "assignments"; + referencedColumns: ["id"]; + }, + { + 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_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"]; + } + ]; + }; + grading_assignment_default_profiles: { + Row: { + auto_assign_assignee_pool: string; + auto_assign_at_deadline: boolean; + auto_assign_review_due_hours: number; + 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; + 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; + 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"]; + } + ]; + }; assignments: { Row: { 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; autograder_points: number | null; class_id: number; created_at: string; @@ -1025,6 +1157,7 @@ export type Database = { due_date: string; enable_repo_analytics: boolean; gradebook_column_id: number | null; + grading_default_profile_id: number | null; grader_pseudonymous_mode: boolean; grading_rubric_id: number | null; group_config: Database["public"]["Enums"]["assignment_group_mode"]; @@ -1039,6 +1172,10 @@ export type Database = { meta_grading_rubric_id: number | null; min_group_size: number | null; minutes_due_after_lab: number | null; + late_grading_cc_emails: Json; + late_grading_reminder_interval_hours: number | null; + late_grading_reminders_enabled: boolean; + late_grading_reply_to: string | null; permit_empty_submissions: boolean; regrade_deadline: string | null; release_date: string | null; @@ -1056,6 +1193,9 @@ 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; autograder_points?: number | null; class_id: number; created_at?: string; @@ -1063,6 +1203,7 @@ export type Database = { due_date: string; enable_repo_analytics?: boolean; gradebook_column_id?: number | null; + grading_default_profile_id?: number | null; grader_pseudonymous_mode?: boolean; grading_rubric_id?: number | null; group_config: Database["public"]["Enums"]["assignment_group_mode"]; @@ -1077,6 +1218,10 @@ export type Database = { meta_grading_rubric_id?: number | null; min_group_size?: number | null; minutes_due_after_lab?: number | null; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; permit_empty_submissions?: boolean; regrade_deadline?: string | null; release_date?: string | null; @@ -1094,6 +1239,9 @@ 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; autograder_points?: number | null; class_id?: number; created_at?: string; @@ -1101,6 +1249,7 @@ export type Database = { due_date?: string; enable_repo_analytics?: boolean; gradebook_column_id?: number | null; + grading_default_profile_id?: number | null; grader_pseudonymous_mode?: boolean; grading_rubric_id?: number | null; group_config?: Database["public"]["Enums"]["assignment_group_mode"]; @@ -1115,6 +1264,10 @@ export type Database = { meta_grading_rubric_id?: number | null; min_group_size?: number | null; minutes_due_after_lab?: number | null; + late_grading_cc_emails?: Json; + late_grading_reminder_interval_hours?: number | null; + late_grading_reminders_enabled?: boolean; + late_grading_reply_to?: string | null; permit_empty_submissions?: boolean; regrade_deadline?: string | null; release_date?: string | null; @@ -1143,6 +1296,13 @@ export type Database = { referencedRelation: "rubrics"; referencedColumns: ["id"]; }, + { + foreignKeyName: "assignments_grading_default_profile_id_fkey"; + columns: ["grading_default_profile_id"]; + isOneToOne: false; + referencedRelation: "grading_assignment_default_profiles"; + referencedColumns: ["id"]; + }, { foreignKeyName: "assignments_rubric_id_fkey"; columns: ["grading_rubric_id"]; @@ -12356,6 +12516,18 @@ export type Database = { }; Returns: undefined; }; + run_assignment_grading_automation: { + Args: never; + Returns: undefined; + }; + auto_assign_grading_reviews_for_assignment: { + Args: { p_assignment_id: number }; + Returns: number; + }; + queue_late_grading_reminders_for_assignment: { + Args: { p_assignment_id: number }; + Returns: number; + }; release_all_grading_reviews_for_assignment: { Args: { assignment_id: number }; Returns: number; From f97abc46d7e461c2c14e996a1d3cf48da5fad3d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 20:19:53 +0000 Subject: [PATCH 03/14] Fix pg_cron schedule SQL quoting in grading automation migration Co-authored-by: Jonathan Bell --- ...20260418100000_grading_assignment_defaults_and_reminders.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql index 8be7a7033..075fe06fb 100644 --- a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql +++ b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql @@ -480,7 +480,7 @@ BEGIN PERFORM cron.schedule( 'run-assignment-grading-automation-every-5-minutes', '*/5 * * * *', - $$SELECT public.run_assignment_grading_automation();$$ + $cron$SELECT public.run_assignment_grading_automation();$cron$ ); ELSE RAISE NOTICE 'pg_cron extension not available - grading automation schedule not created'; From 14e55b90d0a1a179b99538029072573c740e34f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 23:17:09 +0000 Subject: [PATCH 04/14] Address PR review feedback for grading automation Co-authored-by: Jonathan Bell --- .../assignments/[assignment_id]/edit/page.tsx | 39 ++++------ .../manage/assignments/new/form.tsx | 47 +++++------- .../manage/assignments/new/page.tsx | 14 ++-- .../grading-assignment-defaults/page.tsx | 71 ++++++++++++++----- ...ding_assignment_defaults_and_reminders.sql | 68 ++++++++++++------ 5 files changed, 138 insertions(+), 101 deletions(-) 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 139c6720d..9004c9145 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 @@ -26,7 +26,18 @@ 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, + 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]); @@ -39,32 +50,6 @@ export default function EditAssignment() { form.setValue("eval_config", selfReviewSetting?.data.enabled ? "use_eval" : "base_only"); form.setValue("deadline_offset", selfReviewSetting?.data.deadline_offset); form.setValue("allow_early", selfReviewSetting?.data.allow_early); - form.setValue( - "grading_default_profile_id", - (queryData as AssignmentFormValues).grading_default_profile_id ?? null - ); - form.setValue("auto_assign_at_deadline", (queryData as AssignmentFormValues).auto_assign_at_deadline ?? false); - form.setValue( - "auto_assign_assignee_pool", - (queryData as AssignmentFormValues).auto_assign_assignee_pool ?? "graders" - ); - form.setValue( - "auto_assign_review_due_hours", - (queryData as AssignmentFormValues).auto_assign_review_due_hours ?? 72 - ); - form.setValue( - "late_grading_reminders_enabled", - (queryData as AssignmentFormValues).late_grading_reminders_enabled ?? false - ); - form.setValue( - "late_grading_reminder_interval_hours", - (queryData as AssignmentFormValues).late_grading_reminder_interval_hours ?? 12 - ); - form.setValue("late_grading_reply_to", (queryData as AssignmentFormValues).late_grading_reply_to ?? null); - form.setValue( - "late_grading_cc_emails", - (queryData as AssignmentFormValues).late_grading_cc_emails ?? { emails: [] } - ); } }, [ queryData, diff --git a/app/course/[course_id]/manage/assignments/new/form.tsx b/app/course/[course_id]/manage/assignments/new/form.tsx index c6af7e35b..a8e2e9813 100644 --- a/app/course/[course_id]/manage/assignments/new/form.tsx +++ b/app/course/[course_id]/manage/assignments/new/form.tsx @@ -20,13 +20,13 @@ 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 } 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"; @@ -40,20 +40,6 @@ type GradingCcEmails = { emails: string[]; }; -type GradingAssignmentDefaultProfile = { - id: number; - class_id: number; - name: string; - description: string | null; - auto_assign_at_deadline: boolean; - auto_assign_assignee_pool: GradingAssigneePool; - auto_assign_review_due_hours: number; - late_grading_reminders_enabled: boolean; - late_grading_reminder_interval_hours: number | null; - late_grading_reply_to: string | null; - late_grading_cc_emails: GradingCcEmails; -}; - export type AssignmentFormValues = Assignment & { grading_default_profile_id: number | null; auto_assign_at_deadline: boolean; @@ -614,13 +600,17 @@ function GradingAutomationSubform({ filters: [{ field: "class_id", operator: "eq", value: courseId }], pagination: { pageSize: 200 } }); - const profiles = profileData?.data ?? []; - const profileMap = profiles.reduce( - (acc, profile) => { - acc[profile.id] = profile; - return acc; - }, - {} as Record + 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); @@ -634,13 +624,12 @@ function GradingAutomationSubform({ const selectedProfileId = watch("grading_default_profile_id"); const remindersEnabled = watch("late_grading_reminders_enabled"); const autoAssignAtDeadline = watch("auto_assign_at_deadline"); - const profileExists = selectedProfileId ? !!profileMap[selectedProfileId] : false; + const selectedProfile = selectedProfileId ? profileMap[selectedProfileId] : undefined; useEffect(() => { - if (!profileExists || !applyProfileOnSelect || !selectedProfileId) { + if (!selectedProfile || !applyProfileOnSelect) { return; } - const selectedProfile = profileMap[selectedProfileId]; setValue("auto_assign_at_deadline", selectedProfile.auto_assign_at_deadline); setValue("auto_assign_assignee_pool", selectedProfile.auto_assign_assignee_pool); setValue("auto_assign_review_due_hours", selectedProfile.auto_assign_review_due_hours); @@ -648,7 +637,7 @@ function GradingAutomationSubform({ setValue("late_grading_reminder_interval_hours", selectedProfile.late_grading_reminder_interval_hours); setValue("late_grading_reply_to", selectedProfile.late_grading_reply_to); setValue("late_grading_cc_emails", normalizeCcEmails(selectedProfile.late_grading_cc_emails)); - }, [selectedProfileId, profileExists, applyProfileOnSelect, profileMap, setValue]); + }, [selectedProfile, applyProfileOnSelect, setValue]); return ( @@ -663,7 +652,7 @@ function GradingAutomationSubform({ > { const rawValue = event.target.value; setValue("grading_default_profile_id", rawValue ? Number(rawValue) : null, { shouldDirty: true }); @@ -671,7 +660,7 @@ function GradingAutomationSubform({ > {profiles.map((profile) => ( - ))} diff --git a/app/course/[course_id]/manage/assignments/new/page.tsx b/app/course/[course_id]/manage/assignments/new/page.tsx index 9806f2364..ef9c25313 100644 --- a/app/course/[course_id]/manage/assignments/new/page.tsx +++ b/app/course/[course_id]/manage/assignments/new/page.tsx @@ -83,6 +83,8 @@ export default function NewAssignmentPage() { return; } + const remindersEnabled = form.getValues("late_grading_reminders_enabled") ?? false; + const { data, error } = await supabase .from("assignments") .insert({ @@ -112,15 +114,15 @@ export default function NewAssignmentPage() { 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: form.getValues("auto_assign_assignee_pool") || "graders", + 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: form.getValues("auto_assign_assignee_pool") ?? "graders", auto_assign_review_due_hours: form.getValues("auto_assign_review_due_hours") ?? 72, - late_grading_reminders_enabled: form.getValues("late_grading_reminders_enabled") || false, - late_grading_reminder_interval_hours: form.getValues("late_grading_reminders_enabled") + 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_reply_to: form.getValues("late_grading_reply_to") ?? null, late_grading_cc_emails: form.getValues("late_grading_cc_emails") ?? { emails: [] } }) .select("id") 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 index 319b22c5c..53a0d3015 100644 --- a/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx +++ b/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { Field } from "@/components/ui/field"; import { toaster, Toaster } from "@/components/ui/toaster"; +import type { GradingAssignmentDefaultProfile } from "@/utils/supabase/DatabaseTypes"; import { Box, CardBody, @@ -25,25 +26,20 @@ import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { LuCheck } from "react-icons/lu"; -type GradingAssigneePool = "graders" | "instructors" | "instructors_and_graders"; type GradingCcEmails = { emails: string[] }; -type GradingAssignmentDefaultProfile = { - id: number; - class_id: number; - name: string; - description: string | null; - auto_assign_at_deadline: boolean; - auto_assign_assignee_pool: GradingAssigneePool; - auto_assign_review_due_hours: number; - late_grading_reminders_enabled: boolean; - late_grading_reminder_interval_hours: number | null; - late_grading_reply_to: string | null; +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"]; + 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; }; -type FormValues = Omit; - const defaultValues: FormValues = { name: "", description: "", @@ -63,11 +59,28 @@ const parseCcEmails = (value: string): GradingCcEmails => ({ .filter((entry) => entry.length > 0) }); -const toCcText = (value: GradingCcEmails | null | undefined): string => (value?.emails ?? []).join(", "); +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(", "); 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 }); @@ -90,7 +103,7 @@ export default function GradingAssignmentDefaultsPage() { filters: [{ field: "class_id", operator: "eq", value: classId }], sorters: [{ field: "name", order: "asc" }], pagination: { pageSize: 200 }, - queryOptions: { enabled: Number.isFinite(classId) } + queryOptions: { enabled: isValidClassId } }); const profiles = useMemo(() => profileData?.data ?? [], [profileData?.data]); @@ -110,7 +123,7 @@ export default function GradingAssignmentDefaultsPage() { 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: profile.late_grading_cc_emails ?? { emails: [] } + late_grading_cc_emails: normalizeCcEmails(profile.late_grading_cc_emails) }); }; @@ -120,6 +133,14 @@ export default function GradingAssignmentDefaultsPage() { }; 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(), @@ -132,7 +153,7 @@ export default function GradingAssignmentDefaultsPage() { ? (values.late_grading_reminder_interval_hours ?? 12) : null, late_grading_reply_to: values.late_grading_reply_to?.trim() || null, - late_grading_cc_emails: values.late_grading_cc_emails ?? { emails: [] } + late_grading_cc_emails: normalizeCcEmails(values.late_grading_cc_emails) }; try { @@ -161,6 +182,11 @@ export default function GradingAssignmentDefaultsPage() { }); const handleDelete = async (id: number) => { + const confirmed = window.confirm("Delete this grading default profile?"); + if (!confirmed) { + return; + } + try { await deleteProfile({ resource: "grading_assignment_default_profiles", id }); toaster.success({ title: "Profile deleted" }); @@ -176,6 +202,15 @@ export default function GradingAssignmentDefaultsPage() { } }; + if (!isValidClassId) { + return ( + + Grading Assignment Defaults + Invalid course id. + + ); + } + return ( diff --git a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql index 075fe06fb..abf2cbaea 100644 --- a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql +++ b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql @@ -31,7 +31,8 @@ CREATE TABLE IF NOT EXISTS public.grading_assignment_default_profiles ( 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_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; @@ -103,9 +104,9 @@ BEGIN ) THEN ALTER TABLE public.assignments ADD CONSTRAINT assignments_grading_default_profile_id_fkey - FOREIGN KEY (grading_default_profile_id) - REFERENCES public.grading_assignment_default_profiles(id) - ON DELETE SET NULL; + 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 $$; @@ -161,6 +162,17 @@ CREATE TABLE IF NOT EXISTS public.assignment_grading_automation_state ( 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); @@ -296,7 +308,7 @@ BEGIN 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 0; + RETURN -1; END IF; RETURN COALESCE((v_result->>'assignments_created')::integer, 0) @@ -359,6 +371,7 @@ BEGIN 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') @@ -408,6 +421,7 @@ SET search_path TO public AS $$ DECLARE v_assignment record; + v_state record; v_auto_assigned_count integer; v_reminder_count integer; BEGIN @@ -431,12 +445,20 @@ BEGIN VALUES (v_assignment.id, v_assignment.class_id) ON CONFLICT (assignment_id) DO NOTHING; - IF v_assignment.auto_assign_at_deadline AND v_assignment.auto_assigned_at IS NULL THEN + 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); - 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 + UPDATE public.assignment_grading_automation_state + SET auto_assigned_at = now(), + updated_at = now() + WHERE assignment_id = v_assignment.id; + END IF; RAISE LOG 'Auto-assigned grading for assignment %, created/updated=%', v_assignment.id, v_auto_assigned_count; END IF; @@ -444,8 +466,8 @@ BEGIN IF v_assignment.late_grading_reminders_enabled AND COALESCE(v_assignment.late_grading_reminder_interval_hours, 0) > 0 AND ( - v_assignment.last_reminder_sent_at IS NULL - OR v_assignment.last_reminder_sent_at + 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); @@ -473,15 +495,19 @@ GRANT EXECUTE ON FUNCTION public.run_assignment_grading_automation() TO service_ DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN - 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$ - ); + 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; From 7f44d2e012dcf3fbf415e22d86063c9f89ba91b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 00:52:58 +0000 Subject: [PATCH 05/14] Add E2E coverage for grading assignment defaults workflows Co-authored-by: Jonathan Bell --- .../e2e/grading-assignment-defaults.test.tsx | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 tests/e2e/grading-assignment-defaults.test.tsx diff --git a/tests/e2e/grading-assignment-defaults.test.tsx b/tests/e2e/grading-assignment-defaults.test.tsx new file mode 100644 index 000000000..1d124ea08 --- /dev/null +++ b/tests/e2e/grading-assignment-defaults.test.tsx @@ -0,0 +1,169 @@ +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, createUsersInClass, insertAssignment, 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."); + await page.getByRole("checkbox", { name: "Auto assign at deadline" }).check(); + await page.getByLabel("Assignee pool").selectOption("instructors_and_graders"); + await page.getByLabel("Review due hours after deadline").fill("36"); + await page.getByRole("checkbox", { name: "Enable late grading reminders" }).check(); + 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 { 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; + + page.once("dialog", async (dialog) => { + expect(dialog.message()).toContain("Delete this grading default profile?"); + await dialog.dismiss(); + }); + await page.getByRole("button", { name: "Delete" }).first().click(); + await expect(page.getByText(gradingProfileName)).toBeVisible(); + + 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" }); + await applyProfileCheckbox.uncheck(); + await createDueHoursInput.fill("99"); + await savedProfileSelect.selectOption(""); + await savedProfileSelect.selectOption(String(profile.id)); + await expect(createDueHoursInput).toHaveValue("99"); + + await applyProfileCheckbox.check(); + 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(); + + const editProfileSelect = page.getByLabel("Saved profile"); + await expect(editProfileSelect).toHaveValue(String(profile.id)); + const editDueHoursInput = page.getByLabel("Review due hours after assignment deadline"); + await expect(editDueHoursInput).toHaveValue("48"); + await expect(page.getByLabel("Reminder interval (hours)")).toHaveValue("24"); + await expect(page.getByLabel("Reply-to email")).toHaveValue("seeded-reply@example.edu"); + await expect(page.getByLabel("CC emails")).toHaveValue("seeded-cc@example.edu"); + + await editDueHoursInput.fill("77"); + await page.waitForTimeout(1500); + await expect(editDueHoursInput).toHaveValue("77"); + + await page.getByLabel("Reminder interval (hours)").fill("6"); + await page.getByLabel("Reply-to email").fill("updated-reply@example.edu"); + await page.getByLabel("CC emails").fill("updated-cc@example.edu"); + await page.getByRole("button", { name: "Save" }).click(); + + await expect(page.getByText("Assignment Updated")).toBeVisible({ timeout: 20_000 }); + + 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 }); +}); From ad9e3d662e221abe5add18769dff52cb76f51a0c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 02:33:58 +0000 Subject: [PATCH 06/14] Extend grading defaults E2E for deadline automation Co-authored-by: Jonathan Bell --- .../e2e/grading-assignment-defaults.test.tsx | 160 +++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/tests/e2e/grading-assignment-defaults.test.tsx b/tests/e2e/grading-assignment-defaults.test.tsx index 1d124ea08..34a805e4d 100644 --- a/tests/e2e/grading-assignment-defaults.test.tsx +++ b/tests/e2e/grading-assignment-defaults.test.tsx @@ -2,7 +2,16 @@ import { Assignment, Course, GradingAssignmentDefaultProfile } from "@/utils/sup import { addDays } from "date-fns"; import dotenv from "dotenv"; import { test, expect } from "../global-setup"; -import { createClass, createUsersInClass, insertAssignment, loginAsUser, supabase, TestingUser } from "./TestingUtils"; +import { + createClass, + createLabSectionWithStudents, + createUsersInClass, + insertAssignment, + insertPreBakedSubmission, + loginAsUser, + supabase, + TestingUser +} from "./TestingUtils"; dotenv.config({ path: ".env.local" }); @@ -167,3 +176,152 @@ test("instructors can manage grading default profiles and apply them on assignme ]); }).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 }); +}); From c245c47e56603e6c43d6838bf379384c5bdd08cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 16:05:21 +0000 Subject: [PATCH 07/14] Retry auto-assignment on config fixes Co-authored-by: Jonathan Bell --- ...8100000_grading_assignment_defaults_and_reminders.sql | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql index abf2cbaea..7cdadcca8 100644 --- a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql +++ b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql @@ -226,7 +226,8 @@ BEGIN END IF; IF v_assignment.grading_rubric_id IS NULL THEN - RETURN 0; + 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. @@ -240,7 +241,8 @@ BEGIN LIMIT 1; IF v_actor_user_id IS NULL THEN - RETURN 0; + 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); @@ -262,7 +264,8 @@ BEGIN v_staff_count := COALESCE(array_length(v_staff_ids, 1), 0); IF v_staff_count = 0 THEN - RETURN 0; + RAISE WARNING 'auto_assign skipped for assignment %: empty assignee pool (%).', v_assignment.id, v_assignment.auto_assign_assignee_pool; + RETURN -1; END IF; v_review_due_date := v_assignment.due_date + make_interval(hours => GREATEST(v_assignment.auto_assign_review_due_hours, 0)); From 49b06921208ea5255b3892c7c58bfd0ea9bb8f87 Mon Sep 17 00:00:00 2001 From: Jonathan Bell Date: Sun, 3 May 2026 21:23:07 -0400 Subject: [PATCH 08/14] feedback --- .../assignments/[assignment_id]/edit/page.tsx | 11 +- .../manage/assignments/new/form.tsx | 16 +- .../grading-assignment-defaults/page.tsx | 38 +- supabase/functions/_shared/SupabaseTypes.d.ts | 445 +++++++++++------- ...ding_assignment_defaults_and_reminders.sql | 9 +- .../e2e/grading-assignment-defaults.test.tsx | 13 + utils/supabase/SupabaseTypes.d.ts | 445 +++++++++++------- 7 files changed, 598 insertions(+), 379 deletions(-) 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 9004c9145..444232bb7 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 @@ -99,11 +99,16 @@ 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 - ? (values.late_grading_reminder_interval_hours ?? 12) + ? typeof reminderInterval === "number" && Number.isFinite(reminderInterval) + ? reminderInterval + : 12 : null; - values.late_grading_reply_to = values.late_grading_reply_to || null; - values.late_grading_cc_emails = values.late_grading_cc_emails || { emails: [] }; + 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: [] }; 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 a8e2e9813..dbc163d41 100644 --- a/app/course/[course_id]/manage/assignments/new/form.tsx +++ b/app/course/[course_id]/manage/assignments/new/form.tsx @@ -55,6 +55,15 @@ export type AssignmentFormValues = Assignment & { 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; @@ -598,7 +607,8 @@ function GradingAutomationSubform({ } = useList({ resource: "grading_assignment_default_profiles", filters: [{ field: "class_id", operator: "eq", value: courseId }], - pagination: { pageSize: 200 } + pagination: { pageSize: 200 }, + queryOptions: { enabled: Number.isFinite(courseId) } }); const profiles = useMemo(() => profileData?.data ?? [], [profileData?.data]); const profileMap = useMemo( @@ -748,7 +758,7 @@ function GradingAutomationSubform({ {...register("auto_assign_review_due_hours", { required: autoAssignAtDeadline ? "This is required when auto assign is enabled" : false, min: { value: 0, message: "Must be at least 0 hours" }, - valueAsNumber: true + setValueAs: numberInputValueAs(72) })} />
@@ -789,7 +799,7 @@ function GradingAutomationSubform({ {...register("late_grading_reminder_interval_hours", { required: remindersEnabled ? "This is required when reminders are enabled" : false, min: { value: 1, message: "Must be at least 1 hour" }, - valueAsNumber: true + setValueAs: numberInputValueAs(null) })} /> 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 index 53a0d3015..6f1a42315 100644 --- a/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx +++ b/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { Field } from "@/components/ui/field"; import { toaster, Toaster } from "@/components/ui/toaster"; +import { createClient } from "@/utils/supabase/client"; import type { GradingAssignmentDefaultProfile } from "@/utils/supabase/DatabaseTypes"; import { Box, @@ -77,6 +78,14 @@ const normalizeCcEmails = (value: unknown): GradingCcEmails => { 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); @@ -182,7 +191,26 @@ export default function GradingAssignmentDefaultsPage() { }); const handleDelete = async (id: number) => { - const confirmed = window.confirm("Delete this grading default profile?"); + 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; } @@ -287,8 +315,8 @@ export default function GradingAssignmentDefaultsPage() { @@ -326,8 +354,8 @@ export default function GradingAssignmentDefaultsPage() { diff --git a/supabase/functions/_shared/SupabaseTypes.d.ts b/supabase/functions/_shared/SupabaseTypes.d.ts index 3b858ae1e..97af15c94 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; @@ -1013,135 +1086,6 @@ 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: "assignments"; - referencedColumns: ["id"]; - }, - { - 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_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"]; - } - ]; - }; - grading_assignment_default_profiles: { - Row: { - auto_assign_assignee_pool: string; - auto_assign_at_deadline: boolean; - auto_assign_review_due_hours: number; - 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; - 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; - 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"]; - } - ]; - }; assignments: { Row: { allow_not_graded_submissions: boolean; @@ -1157,8 +1101,8 @@ export type Database = { due_date: string; enable_repo_analytics: boolean; gradebook_column_id: number | null; - grading_default_profile_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; @@ -1166,16 +1110,16 @@ 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; meta_grading_rubric_id: number | null; min_group_size: number | null; minutes_due_after_lab: number | null; - late_grading_cc_emails: Json; - late_grading_reminder_interval_hours: number | null; - late_grading_reminders_enabled: boolean; - late_grading_reply_to: string | null; permit_empty_submissions: boolean; regrade_deadline: string | null; release_date: string | null; @@ -1203,8 +1147,8 @@ export type Database = { due_date: string; enable_repo_analytics?: boolean; gradebook_column_id?: number | null; - grading_default_profile_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; @@ -1212,16 +1156,16 @@ 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; meta_grading_rubric_id?: number | null; min_group_size?: number | null; minutes_due_after_lab?: number | null; - late_grading_cc_emails?: Json; - late_grading_reminder_interval_hours?: number | null; - late_grading_reminders_enabled?: boolean; - late_grading_reply_to?: string | null; permit_empty_submissions?: boolean; regrade_deadline?: string | null; release_date?: string | null; @@ -1249,8 +1193,8 @@ export type Database = { due_date?: string; enable_repo_analytics?: boolean; gradebook_column_id?: number | null; - grading_default_profile_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; @@ -1258,16 +1202,16 @@ 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; meta_grading_rubric_id?: number | null; min_group_size?: number | null; minutes_due_after_lab?: number | null; - late_grading_cc_emails?: Json; - late_grading_reminder_interval_hours?: number | null; - late_grading_reminders_enabled?: boolean; - late_grading_reply_to?: string | null; permit_empty_submissions?: boolean; regrade_deadline?: string | null; release_date?: string | null; @@ -1290,17 +1234,17 @@ export type Database = { referencedColumns: ["id"]; }, { - foreignKeyName: "assignments_meta_grading_rubric_id_fkey"; - columns: ["meta_grading_rubric_id"]; + foreignKeyName: "assignments_grading_default_profile_id_fkey"; + columns: ["grading_default_profile_id", "class_id"]; isOneToOne: false; - referencedRelation: "rubrics"; - referencedColumns: ["id"]; + referencedRelation: "grading_assignment_default_profiles"; + referencedColumns: ["id", "class_id"]; }, { - foreignKeyName: "assignments_grading_default_profile_id_fkey"; - columns: ["grading_default_profile_id"]; + foreignKeyName: "assignments_meta_grading_rubric_id_fkey"; + columns: ["meta_grading_rubric_id"]; isOneToOne: false; - referencedRelation: "grading_assignment_default_profiles"; + referencedRelation: "rubrics"; referencedColumns: ["id"]; }, { @@ -4238,6 +4182,62 @@ export type Database = { } ]; }; + grading_assignment_default_profiles: { + Row: { + auto_assign_assignee_pool: string; + auto_assign_at_deadline: boolean; + auto_assign_review_due_hours: number; + 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; + 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; + 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; @@ -6160,6 +6160,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "repository_analytics_daily_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "repository_analytics_daily_repository_id_fkey"; columns: ["repository_id"]; @@ -6243,6 +6250,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "repository_analytics_fetch_status_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "repository_analytics_fetch_status_repository_id_fkey"; columns: ["repository_id"]; @@ -6341,6 +6355,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "repository_analytics_items_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "repository_analytics_items_repository_id_fkey"; columns: ["repository_id"]; @@ -6367,6 +6388,13 @@ export type Database = { repository_id?: number; }; Relationships: [ + { + foreignKeyName: "repository_analytics_repo_status_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: true; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "repository_analytics_repo_status_repository_id_fkey"; columns: ["repository_id"]; @@ -6436,6 +6464,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "repository_check_run_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "repository_check_run_repository_id_fkey"; columns: ["repository_id"]; @@ -9155,6 +9190,13 @@ export type Database = { referencedRelation: "repository_check_runs"; referencedColumns: ["id"]; }, + { + foreignKeyName: "submissions_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "submissions_repository_id_fkey"; columns: ["repository_id"]; @@ -10002,6 +10044,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "workflow_events_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "workflow_events_repository_id_fkey"; columns: ["repository_id"]; @@ -10073,6 +10122,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "workflow_run_error_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "workflow_run_error_repository_id_fkey"; columns: ["repository_id"]; @@ -10923,8 +10979,8 @@ export type Database = { activesubmissionid: number | null; assignedgradername: string | null; assignedmetagradername: string | null; - assignment_id: number | null; assignment_group_mentor_name: string | null; + assignment_id: number | null; assignment_slug: string | null; autograder_score: number | null; checked_at: string | null; @@ -11115,6 +11171,10 @@ export type Database = { }; }; Functions: { + _cli_resolve_submission_file_id: { + Args: { p_file_name: string; p_submission_id: number }; + Returns: number; + }; _grade_targets_for_submission: { Args: { p_submission_id: number }; Returns: string[]; @@ -11135,6 +11195,14 @@ export type Database = { Args: { p_submission_review_id: number }; Returns: undefined; }; + acquire_assignment_due_date_exception_lock: { + Args: { + _assignment_group_id: number; + _assignment_id: number; + _student_id: string; + }; + Returns: undefined; + }; admin_bulk_set_user_roles_disabled: { Args: { p_admin_user_id?: string; @@ -11307,6 +11375,14 @@ export type Database = { Args: { p_name: string; p_section_id: number; p_updated_by?: string }; Returns: boolean; }; + assignment_due_date_exception_lock_key: { + Args: { + _assignment_group_id: number; + _assignment_id: number; + _student_id: string; + }; + Returns: number; + }; audit_maintain_partitions: { Args: never; Returns: undefined }; authorize_for_admin: { Args: { p_user_id?: string }; Returns: boolean }; authorize_for_private_discussion_thread: { @@ -11325,11 +11401,11 @@ export type Database = { Args: { submission_review_id: number }; Returns: boolean; }; - authorize_for_submission_review_writable: { + authorize_for_submission_review_comment_writable: { Args: { submission_review_id: number }; Returns: boolean; }; - authorize_for_submission_review_comment_writable: { + authorize_for_submission_review_writable: { Args: { submission_review_id: number }; Returns: boolean; }; @@ -11375,6 +11451,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; @@ -11385,22 +11465,22 @@ export type Database = { }; Returns: Json; }; - bulk_update_review_assignment_due_dates: { + bulk_csv_import_enrollment: { Args: { - p_assignment_id: number; p_class_id: number; - p_due_date: string; - p_only_incomplete?: boolean; - p_rubric_id: number; + p_enrollment_data: Json; + p_import_mode: string; + p_notify?: boolean; }; Returns: Json; }; - bulk_csv_import_enrollment: { + bulk_update_review_assignment_due_dates: { Args: { + p_assignment_id: number; p_class_id: number; - p_enrollment_data: Json; - p_import_mode: string; - p_notify?: boolean; + p_due_date: string; + p_only_incomplete?: boolean; + p_rubric_id: number; }; Returns: Json; }; @@ -11549,16 +11629,16 @@ export type Database = { Args: { p_artifact_comments: Json; p_assignment_id: number; - p_authors_by_submission?: Json; + p_authors_by_submission: Json; p_class_id: number; - p_default_author?: string; + p_default_author: string; p_dry_run: boolean; p_file_comments: Json; p_mode: string; p_run_sync_only?: boolean; p_skip_sync?: boolean; p_submission_comments: Json; - p_sync_submission_ids?: number[]; + p_sync_submission_ids: number[]; }; Returns: Json; }; @@ -11591,6 +11671,10 @@ export type Database = { }; Returns: undefined; }; + create_all_repos_for_assignment_internal: { + Args: { assignment_id: number; course_id: number; p_force?: boolean }; + Returns: undefined; + }; create_help_request_message_notification: { Args: { p_author_name: string; @@ -11718,7 +11802,6 @@ export type Database = { metric_value: number; }[]; }; - dual_active_invariants_version: { Args: never; Returns: number }; deactivate_expired_polls: { Args: never; Returns: undefined }; delete_assignment_with_all_data: { Args: { p_assignment_id: number; p_class_id: number }; @@ -11739,6 +11822,7 @@ export type Database = { Args: { p_campaign_id: string; p_deleted_by?: string }; Returns: number; }; + dual_active_invariants_version: { Args: never; Returns: number }; enqueue_autograder_reruns: { Args: { p_auto_promote?: boolean; @@ -12249,6 +12333,7 @@ export type Database = { external_data: Json | null; gradebook_id: number; id: number; + instructor_only: boolean; max_score: number | null; name: string; released: boolean; @@ -12277,6 +12362,7 @@ export type Database = { external_data: Json | null; gradebook_id: number; id: number; + instructor_only: boolean; max_score: number | null; name: string; released: boolean; @@ -12480,14 +12566,14 @@ 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; }; - release_instructor_only_gradebook_column: { - Args: { p_column_id: number }; - Returns: undefined; - }; recalculate_discussion_thread_children_counts: { Args: { target_class_id?: number }; Returns: number; @@ -12518,18 +12604,6 @@ export type Database = { }; Returns: undefined; }; - run_assignment_grading_automation: { - Args: never; - Returns: undefined; - }; - auto_assign_grading_reviews_for_assignment: { - Args: { p_assignment_id: number }; - Returns: number; - }; - queue_late_grading_reminders_for_assignment: { - Args: { p_assignment_id: number }; - Returns: number; - }; release_all_grading_reviews_for_assignment: { Args: { assignment_id: number }; Returns: number; @@ -12538,6 +12612,10 @@ export type Database = { Args: { p_assignment_id: number; p_submission_ids: number[] }; Returns: number; }; + release_instructor_only_gradebook_column: { + Args: { p_column_id: number }; + Returns: undefined; + }; reorder_surveys_in_series: { Args: { p_ordinal_updates: Json; p_series_id: string }; Returns: undefined; @@ -12554,6 +12632,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 index 7cdadcca8..42093b291 100644 --- a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql +++ b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql @@ -461,9 +461,14 @@ BEGIN 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; - - RAISE LOG 'Auto-assigned grading for assignment %, created/updated=%', v_assignment.id, v_auto_assigned_count; END IF; IF v_assignment.late_grading_reminders_enabled diff --git a/tests/e2e/grading-assignment-defaults.test.tsx b/tests/e2e/grading-assignment-defaults.test.tsx index 34a805e4d..77c09fd14 100644 --- a/tests/e2e/grading-assignment-defaults.test.tsx +++ b/tests/e2e/grading-assignment-defaults.test.tsx @@ -72,6 +72,11 @@ test("instructors can manage grading default profiles and apply them on assignme 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("*") @@ -91,6 +96,14 @@ test("instructors can manage grading default profiles and apply them on assignme await page.getByRole("button", { name: "Delete" }).first().click(); await expect(page.getByText(gradingProfileName)).toBeVisible(); + page.once("dialog", async (dialog) => { + expect(dialog.message()).toContain("Delete this grading default profile?"); + await dialog.accept(); + }); + await page.getByRole("button", { name: "Delete" }).nth(1).click(); + await expect(page.getByText(throwawayProfileName)).not.toBeVisible(); + await expect(page.getByText(gradingProfileName)).toBeVisible(); + await page.goto(`/course/${course.id}/manage/assignments/new`); await expect(page.getByRole("heading", { name: "Create New Assignment" })).toBeVisible(); diff --git a/utils/supabase/SupabaseTypes.d.ts b/utils/supabase/SupabaseTypes.d.ts index 3b858ae1e..97af15c94 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; @@ -1013,135 +1086,6 @@ 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: "assignments"; - referencedColumns: ["id"]; - }, - { - 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_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"]; - } - ]; - }; - grading_assignment_default_profiles: { - Row: { - auto_assign_assignee_pool: string; - auto_assign_at_deadline: boolean; - auto_assign_review_due_hours: number; - 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; - 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; - 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"]; - } - ]; - }; assignments: { Row: { allow_not_graded_submissions: boolean; @@ -1157,8 +1101,8 @@ export type Database = { due_date: string; enable_repo_analytics: boolean; gradebook_column_id: number | null; - grading_default_profile_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; @@ -1166,16 +1110,16 @@ 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; meta_grading_rubric_id: number | null; min_group_size: number | null; minutes_due_after_lab: number | null; - late_grading_cc_emails: Json; - late_grading_reminder_interval_hours: number | null; - late_grading_reminders_enabled: boolean; - late_grading_reply_to: string | null; permit_empty_submissions: boolean; regrade_deadline: string | null; release_date: string | null; @@ -1203,8 +1147,8 @@ export type Database = { due_date: string; enable_repo_analytics?: boolean; gradebook_column_id?: number | null; - grading_default_profile_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; @@ -1212,16 +1156,16 @@ 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; meta_grading_rubric_id?: number | null; min_group_size?: number | null; minutes_due_after_lab?: number | null; - late_grading_cc_emails?: Json; - late_grading_reminder_interval_hours?: number | null; - late_grading_reminders_enabled?: boolean; - late_grading_reply_to?: string | null; permit_empty_submissions?: boolean; regrade_deadline?: string | null; release_date?: string | null; @@ -1249,8 +1193,8 @@ export type Database = { due_date?: string; enable_repo_analytics?: boolean; gradebook_column_id?: number | null; - grading_default_profile_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; @@ -1258,16 +1202,16 @@ 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; meta_grading_rubric_id?: number | null; min_group_size?: number | null; minutes_due_after_lab?: number | null; - late_grading_cc_emails?: Json; - late_grading_reminder_interval_hours?: number | null; - late_grading_reminders_enabled?: boolean; - late_grading_reply_to?: string | null; permit_empty_submissions?: boolean; regrade_deadline?: string | null; release_date?: string | null; @@ -1290,17 +1234,17 @@ export type Database = { referencedColumns: ["id"]; }, { - foreignKeyName: "assignments_meta_grading_rubric_id_fkey"; - columns: ["meta_grading_rubric_id"]; + foreignKeyName: "assignments_grading_default_profile_id_fkey"; + columns: ["grading_default_profile_id", "class_id"]; isOneToOne: false; - referencedRelation: "rubrics"; - referencedColumns: ["id"]; + referencedRelation: "grading_assignment_default_profiles"; + referencedColumns: ["id", "class_id"]; }, { - foreignKeyName: "assignments_grading_default_profile_id_fkey"; - columns: ["grading_default_profile_id"]; + foreignKeyName: "assignments_meta_grading_rubric_id_fkey"; + columns: ["meta_grading_rubric_id"]; isOneToOne: false; - referencedRelation: "grading_assignment_default_profiles"; + referencedRelation: "rubrics"; referencedColumns: ["id"]; }, { @@ -4238,6 +4182,62 @@ export type Database = { } ]; }; + grading_assignment_default_profiles: { + Row: { + auto_assign_assignee_pool: string; + auto_assign_at_deadline: boolean; + auto_assign_review_due_hours: number; + 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; + 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; + 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; @@ -6160,6 +6160,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "repository_analytics_daily_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "repository_analytics_daily_repository_id_fkey"; columns: ["repository_id"]; @@ -6243,6 +6250,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "repository_analytics_fetch_status_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "repository_analytics_fetch_status_repository_id_fkey"; columns: ["repository_id"]; @@ -6341,6 +6355,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "repository_analytics_items_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "repository_analytics_items_repository_id_fkey"; columns: ["repository_id"]; @@ -6367,6 +6388,13 @@ export type Database = { repository_id?: number; }; Relationships: [ + { + foreignKeyName: "repository_analytics_repo_status_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: true; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "repository_analytics_repo_status_repository_id_fkey"; columns: ["repository_id"]; @@ -6436,6 +6464,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "repository_check_run_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "repository_check_run_repository_id_fkey"; columns: ["repository_id"]; @@ -9155,6 +9190,13 @@ export type Database = { referencedRelation: "repository_check_runs"; referencedColumns: ["id"]; }, + { + foreignKeyName: "submissions_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "submissions_repository_id_fkey"; columns: ["repository_id"]; @@ -10002,6 +10044,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "workflow_events_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "workflow_events_repository_id_fkey"; columns: ["repository_id"]; @@ -10073,6 +10122,13 @@ export type Database = { referencedRelation: "classes"; referencedColumns: ["id"]; }, + { + foreignKeyName: "workflow_run_error_repository_id_fkey"; + columns: ["repository_id"]; + isOneToOne: false; + referencedRelation: "assignments_for_student_dashboard"; + referencedColumns: ["repository_id"]; + }, { foreignKeyName: "workflow_run_error_repository_id_fkey"; columns: ["repository_id"]; @@ -10923,8 +10979,8 @@ export type Database = { activesubmissionid: number | null; assignedgradername: string | null; assignedmetagradername: string | null; - assignment_id: number | null; assignment_group_mentor_name: string | null; + assignment_id: number | null; assignment_slug: string | null; autograder_score: number | null; checked_at: string | null; @@ -11115,6 +11171,10 @@ export type Database = { }; }; Functions: { + _cli_resolve_submission_file_id: { + Args: { p_file_name: string; p_submission_id: number }; + Returns: number; + }; _grade_targets_for_submission: { Args: { p_submission_id: number }; Returns: string[]; @@ -11135,6 +11195,14 @@ export type Database = { Args: { p_submission_review_id: number }; Returns: undefined; }; + acquire_assignment_due_date_exception_lock: { + Args: { + _assignment_group_id: number; + _assignment_id: number; + _student_id: string; + }; + Returns: undefined; + }; admin_bulk_set_user_roles_disabled: { Args: { p_admin_user_id?: string; @@ -11307,6 +11375,14 @@ export type Database = { Args: { p_name: string; p_section_id: number; p_updated_by?: string }; Returns: boolean; }; + assignment_due_date_exception_lock_key: { + Args: { + _assignment_group_id: number; + _assignment_id: number; + _student_id: string; + }; + Returns: number; + }; audit_maintain_partitions: { Args: never; Returns: undefined }; authorize_for_admin: { Args: { p_user_id?: string }; Returns: boolean }; authorize_for_private_discussion_thread: { @@ -11325,11 +11401,11 @@ export type Database = { Args: { submission_review_id: number }; Returns: boolean; }; - authorize_for_submission_review_writable: { + authorize_for_submission_review_comment_writable: { Args: { submission_review_id: number }; Returns: boolean; }; - authorize_for_submission_review_comment_writable: { + authorize_for_submission_review_writable: { Args: { submission_review_id: number }; Returns: boolean; }; @@ -11375,6 +11451,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; @@ -11385,22 +11465,22 @@ export type Database = { }; Returns: Json; }; - bulk_update_review_assignment_due_dates: { + bulk_csv_import_enrollment: { Args: { - p_assignment_id: number; p_class_id: number; - p_due_date: string; - p_only_incomplete?: boolean; - p_rubric_id: number; + p_enrollment_data: Json; + p_import_mode: string; + p_notify?: boolean; }; Returns: Json; }; - bulk_csv_import_enrollment: { + bulk_update_review_assignment_due_dates: { Args: { + p_assignment_id: number; p_class_id: number; - p_enrollment_data: Json; - p_import_mode: string; - p_notify?: boolean; + p_due_date: string; + p_only_incomplete?: boolean; + p_rubric_id: number; }; Returns: Json; }; @@ -11549,16 +11629,16 @@ export type Database = { Args: { p_artifact_comments: Json; p_assignment_id: number; - p_authors_by_submission?: Json; + p_authors_by_submission: Json; p_class_id: number; - p_default_author?: string; + p_default_author: string; p_dry_run: boolean; p_file_comments: Json; p_mode: string; p_run_sync_only?: boolean; p_skip_sync?: boolean; p_submission_comments: Json; - p_sync_submission_ids?: number[]; + p_sync_submission_ids: number[]; }; Returns: Json; }; @@ -11591,6 +11671,10 @@ export type Database = { }; Returns: undefined; }; + create_all_repos_for_assignment_internal: { + Args: { assignment_id: number; course_id: number; p_force?: boolean }; + Returns: undefined; + }; create_help_request_message_notification: { Args: { p_author_name: string; @@ -11718,7 +11802,6 @@ export type Database = { metric_value: number; }[]; }; - dual_active_invariants_version: { Args: never; Returns: number }; deactivate_expired_polls: { Args: never; Returns: undefined }; delete_assignment_with_all_data: { Args: { p_assignment_id: number; p_class_id: number }; @@ -11739,6 +11822,7 @@ export type Database = { Args: { p_campaign_id: string; p_deleted_by?: string }; Returns: number; }; + dual_active_invariants_version: { Args: never; Returns: number }; enqueue_autograder_reruns: { Args: { p_auto_promote?: boolean; @@ -12249,6 +12333,7 @@ export type Database = { external_data: Json | null; gradebook_id: number; id: number; + instructor_only: boolean; max_score: number | null; name: string; released: boolean; @@ -12277,6 +12362,7 @@ export type Database = { external_data: Json | null; gradebook_id: number; id: number; + instructor_only: boolean; max_score: number | null; name: string; released: boolean; @@ -12480,14 +12566,14 @@ 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; }; - release_instructor_only_gradebook_column: { - Args: { p_column_id: number }; - Returns: undefined; - }; recalculate_discussion_thread_children_counts: { Args: { target_class_id?: number }; Returns: number; @@ -12518,18 +12604,6 @@ export type Database = { }; Returns: undefined; }; - run_assignment_grading_automation: { - Args: never; - Returns: undefined; - }; - auto_assign_grading_reviews_for_assignment: { - Args: { p_assignment_id: number }; - Returns: number; - }; - queue_late_grading_reminders_for_assignment: { - Args: { p_assignment_id: number }; - Returns: number; - }; release_all_grading_reviews_for_assignment: { Args: { assignment_id: number }; Returns: number; @@ -12538,6 +12612,10 @@ export type Database = { Args: { p_assignment_id: number; p_submission_ids: number[] }; Returns: number; }; + release_instructor_only_gradebook_column: { + Args: { p_column_id: number }; + Returns: undefined; + }; reorder_surveys_in_series: { Args: { p_ordinal_updates: Json; p_series_id: string }; Returns: undefined; @@ -12554,6 +12632,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; From c35787e9d33de301a8ca9439739390e4b4f7fce2 Mon Sep 17 00:00:00 2001 From: Jonathan Bell Date: Sun, 3 May 2026 21:48:21 -0400 Subject: [PATCH 09/14] . --- .../assignments/[assignment_id]/edit/page.tsx | 11 +- .../manage/assignments/new/form.tsx | 130 +++++++- .../manage/assignments/new/page.tsx | 11 +- .../grading-assignment-defaults/page.tsx | 102 +++++- supabase/functions/_shared/SupabaseTypes.d.ts | 6 + ...ding_assignment_defaults_and_reminders.sql | 306 +++++++++++++++--- utils/supabase/SupabaseTypes.d.ts | 6 + 7 files changed, 509 insertions(+), 63 deletions(-) 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 444232bb7..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,7 +11,7 @@ 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, { AssignmentFormValues } from "../../new/form"; +import AssignmentForm, { AssignmentFormValues, normalizeProfileIdSubset } from "../../new/form"; export default function EditAssignment() { const { course_id, assignment_id } = useParams(); @@ -33,6 +33,9 @@ export default function EditAssignment() { 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, @@ -109,6 +112,12 @@ export default function EditAssignment() { 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 dbc163d41..48f1902eb 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,7 +21,11 @@ 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, GradingAssignmentDefaultProfile } 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"; @@ -34,20 +39,22 @@ import { useCourseController } from "@/hooks/useCourseController"; import { LabSection, LabSectionMeeting } from "@/utils/supabase/DatabaseTypes"; import { useTableControllerTableValues } from "@/lib/TableController"; -type GradingAssigneePool = "graders" | "instructors" | "instructors_and_graders"; +type GradingAssigneePool = "graders" | "instructors" | "instructors_and_graders" | "lab_leaders" | "group_mentors"; type GradingCcEmails = { emails: string[]; }; -export type AssignmentFormValues = Assignment & { +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_at_deadline: boolean; auto_assign_assignee_pool: GradingAssigneePool; - auto_assign_review_due_hours: number; - late_grading_reminders_enabled: boolean; - late_grading_reminder_interval_hours: number | null; - late_grading_reply_to: string | null; + auto_assign_grader_subset_private_profile_ids: string[]; late_grading_cc_emails: GradingCcEmails; copy_groups_from_assignment?: string; eval_config?: "base_only" | "use_eval"; @@ -79,6 +86,21 @@ const normalizeCcEmails = (value: unknown): GradingCcEmails => { 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( labSection: LabSection, @@ -624,18 +646,42 @@ function GradingAutomationSubform({ ); 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"); const selectedProfile = selectedProfileId ? profileMap[selectedProfileId] : undefined; + useEffect(() => { + if (assigneePool !== "graders") { + setValue("auto_assign_grader_subset_private_profile_ids", []); + } + }, [assigneePool, setValue]); + useEffect(() => { if (!selectedProfile || !applyProfileOnSelect) { return; @@ -643,6 +689,10 @@ function GradingAutomationSubform({ setValue("auto_assign_at_deadline", selectedProfile.auto_assign_at_deadline); setValue("auto_assign_assignee_pool", selectedProfile.auto_assign_assignee_pool); setValue("auto_assign_review_due_hours", selectedProfile.auto_assign_review_due_hours); + setValue( + "auto_assign_grader_subset_private_profile_ids", + normalizeProfileIdSubset(selectedProfile.auto_assign_grader_subset_private_profile_ids) + ); setValue("late_grading_reminders_enabled", selectedProfile.late_grading_reminders_enabled); setValue("late_grading_reminder_interval_hours", selectedProfile.late_grading_reminder_interval_hours); setValue("late_grading_reply_to", selectedProfile.late_grading_reply_to); @@ -733,19 +783,77 @@ function GradingAutomationSubform({ - + + + + {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"} + + ); + }) + )} + + ); + }} + /> + + + )} 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({ @@ -129,6 +155,9 @@ export default function GradingAssignmentDefaultsPage() { 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 ?? "", @@ -157,6 +186,10 @@ export default function GradingAssignmentDefaultsPage() { 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) @@ -296,16 +329,77 @@ export default function GradingAssignmentDefaultsPage() { {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"} + + ); + }) + )} + + ); + }} + /> + + + )} = 0), CONSTRAINT grading_assignment_default_profiles_reminder_interval_check CHECK ( @@ -90,6 +108,7 @@ ALTER TABLE public.assignments 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, @@ -117,7 +136,31 @@ BEGIN ) THEN ALTER TABLE public.assignments ADD CONSTRAINT assignments_auto_assign_assignee_pool_check - CHECK (auto_assign_assignee_pool IN ('graders', 'instructors', 'instructors_and_graders')); + 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 ( + jsonb_typeof(auto_assign_grader_subset_private_profile_ids) = 'array' + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT jsonb_array_elements(auto_assign_grader_subset_private_profile_ids) AS elt + ) sub + WHERE jsonb_typeof(sub.elt) IS DISTINCT FROM 'string' + ) + ); END IF; IF NOT EXISTS ( @@ -210,6 +253,11 @@ DECLARE 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 @@ -247,54 +295,222 @@ BEGIN PERFORM set_config('request.jwt.claim.sub', v_actor_user_id::text, true); - 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') + 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; + 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; - v_review_due_date := v_assignment.due_date + make_interval(hours => GREATEST(v_assignment.auto_assign_review_due_hours, 0)); + 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; - FOR v_submission IN - SELECT s.id - 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 ( + 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.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.id, - 'rubric_part_id', NULL - ) - ); - v_idx := v_idx + 1; - END LOOP; + 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; diff --git a/utils/supabase/SupabaseTypes.d.ts b/utils/supabase/SupabaseTypes.d.ts index 97af15c94..870478ad7 100644 --- a/utils/supabase/SupabaseTypes.d.ts +++ b/utils/supabase/SupabaseTypes.d.ts @@ -1094,6 +1094,7 @@ export type Database = { 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; @@ -1140,6 +1141,7 @@ export type Database = { 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; @@ -1186,6 +1188,7 @@ export type Database = { 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; @@ -4187,6 +4190,7 @@ export type Database = { 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; @@ -4202,6 +4206,7 @@ export type Database = { 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; @@ -4217,6 +4222,7 @@ export type Database = { 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; From 81c46f73e635bf00b4818b4edacfcdd616303875 Mon Sep 17 00:00:00 2001 From: Jonathan Bell Date: Sun, 3 May 2026 22:16:39 -0400 Subject: [PATCH 10/14] . --- app/course/[course_id]/manage/assignments/new/form.tsx | 2 +- .../manage/course/grading-assignment-defaults/page.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/course/[course_id]/manage/assignments/new/form.tsx b/app/course/[course_id]/manage/assignments/new/form.tsx index 48f1902eb..788d1de48 100644 --- a/app/course/[course_id]/manage/assignments/new/form.tsx +++ b/app/course/[course_id]/manage/assignments/new/form.tsx @@ -37,7 +37,7 @@ 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"; 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 index f0cf47d3c..8aa2b0d9e 100644 --- a/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx +++ b/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx @@ -3,6 +3,8 @@ import { Button } from "@/components/ui/button"; import { Field } from "@/components/ui/field"; import { toaster, Toaster } from "@/components/ui/toaster"; +import { useCourseController } from "@/hooks/useCourseController"; +import { useListTableControllerValues } from "@/lib/TableController"; import { createClient } from "@/utils/supabase/client"; import type { GradingAssignmentDefaultProfile, From fd4c82efad090004db878fea19a292a13aae6266 Mon Sep 17 00:00:00 2001 From: Jonathan Bell Date: Sat, 16 May 2026 01:09:58 +0000 Subject: [PATCH 11/14] Fix grading-defaults migration, bound cron, add late-submission auto-assign - Fix CHECK-constraint subquery error (SQLSTATE 0A000) by wrapping the array-of-strings predicate in a new IMMUTABLE jsonb_is_string_array function and calling it from both grader_subset_ids CHECKs. - Bound run_assignment_grading_automation to a recent-deadline window and only select rows with actionable work (unrun auto-assign or elapsed reminder interval) so cron cost stays proportional to recent work rather than the lifetime of the assignments table. - Add submission-time auto-assignment for late submissions: AFTER INSERT trigger on submissions calls auto_assign_grading_review_for_submission for past-deadline insertions, with load-balanced staff picking and a re-submission guard. Errors are swallowed so a failed auto-assign cannot block the student's submission. - Add E2E coverage for the assignee-pool branches (instructors, instructors_and_graders, graders subset, lab_leaders with conflict exclusion, group_mentors with conflict exclusion), cron idempotency, reminder cadence (backdating last_reminder_sent_at), reminder filter predicates, and the late-submission trigger. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ding_assignment_defaults_and_reminders.sql | 348 ++++++++- .../e2e/grading-assignment-defaults.test.tsx | 738 ++++++++++++++++++ 2 files changed, 1069 insertions(+), 17 deletions(-) diff --git a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql index c5b8928b8..e026b7cc6 100644 --- a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql +++ b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql @@ -5,6 +5,24 @@ -- 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, @@ -30,14 +48,7 @@ CREATE TABLE IF NOT EXISTS public.grading_assignment_default_profiles ( ) ), CONSTRAINT grading_assignment_default_profiles_grader_subset_ids_check CHECK ( - jsonb_typeof(auto_assign_grader_subset_private_profile_ids) = 'array' - AND NOT EXISTS ( - SELECT 1 - FROM ( - SELECT jsonb_array_elements(auto_assign_grader_subset_private_profile_ids) AS elt - ) sub - WHERE jsonb_typeof(sub.elt) IS DISTINCT FROM 'string' - ) + 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 ( @@ -152,14 +163,7 @@ BEGIN ) THEN ALTER TABLE public.assignments ADD CONSTRAINT assignments_auto_assign_grader_subset_ids_check CHECK ( - jsonb_typeof(auto_assign_grader_subset_private_profile_ids) = 'array' - AND NOT EXISTS ( - SELECT 1 - FROM ( - SELECT jsonb_array_elements(auto_assign_grader_subset_private_profile_ids) AS elt - ) sub - WHERE jsonb_typeof(sub.elt) IS DISTINCT FROM 'string' - ) + public.jsonb_is_string_array(auto_assign_grader_subset_private_profile_ids) ); END IF; @@ -643,6 +647,13 @@ DECLARE 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 @@ -658,7 +669,27 @@ BEGIN 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.auto_assign_at_deadline OR a.late_grading_reminders_enabled) + 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) @@ -707,9 +738,292 @@ BEGIN 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.created_at + 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; diff --git a/tests/e2e/grading-assignment-defaults.test.tsx b/tests/e2e/grading-assignment-defaults.test.tsx index 77c09fd14..755deec8e 100644 --- a/tests/e2e/grading-assignment-defaults.test.tsx +++ b/tests/e2e/grading-assignment-defaults.test.tsx @@ -338,3 +338,741 @@ test("deadline automation assigns grading to lab leaders and queues reminder ema } }).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 + }); + + // Create a submission_review for the grading rubric so review_assignments inserts can satisfy + // their submission_review_id FK without going through bulk_assign_reviews. + const { data: gradingReview, error: reviewErr } = await supabase + .from("submission_reviews") + .insert({ + submission_id: sub.submission_id, + rubric_id: a.grading_rubric_id!, + class_id: course.id, + name: "Grading review for reminder-filter test", + total_score: 0, + total_autograde_score: 0, + tweak: 0 + }) + .select("id") + .single(); + expect(reviewErr).toBeNull(); + // Also create a submission_review for the SELF rubric so the wrong-rubric scenario is valid. + const { data: selfReview, error: selfReviewErr } = await supabase + .from("submission_reviews") + .insert({ + submission_id: sub.submission_id, + rubric_id: a.self_review_rubric_id!, + class_id: course.id, + name: "Self review for reminder-filter test", + total_score: 0, + total_autograde_score: 0, + tweak: 0 + }) + .select("id") + .single(); + expect(selfReviewErr).toBeNull(); + + 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: gradingReview!.id, + 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: gradingReview!.id, + 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: gradingReview!.id, + 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: gradingReview!.id, + 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: selfReview!.id, + 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); +}); From 01773f018486ed4f1be5d4a0c63954d7ee58e6fa Mon Sep 17 00:00:00 2001 From: Jonathan Bell Date: Sat, 16 May 2026 01:15:10 +0000 Subject: [PATCH 12/14] format --- package-lock.json | 1 + .../e2e/grading-assignment-defaults.test.tsx | 338 +++++++++++++++--- 2 files changed, 287 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index c850c0238..dd2024442 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6775,6 +6775,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/tests/e2e/grading-assignment-defaults.test.tsx b/tests/e2e/grading-assignment-defaults.test.tsx index 755deec8e..f3ee47eb8 100644 --- a/tests/e2e/grading-assignment-defaults.test.tsx +++ b/tests/e2e/grading-assignment-defaults.test.tsx @@ -460,11 +460,36 @@ test("auto-assign with 'instructors' pool rotates submissions across active inst 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 } + { + 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({ @@ -475,9 +500,21 @@ test("auto-assign with 'instructors' pool rotates submissions across active inst }); 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 }); + 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(); @@ -504,10 +541,30 @@ test("auto-assign with 'instructors_and_graders' pool rotates across both roles" 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 } + { + 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({ @@ -518,8 +575,16 @@ test("auto-assign with 'instructors_and_graders' pool rotates across both roles" }); 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 }); + 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(); @@ -550,11 +615,36 @@ test("auto-assign with 'graders' subset restricts rotation to the selected grade 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 } + { + 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({ @@ -568,9 +658,21 @@ test("auto-assign with 'graders' subset restricts rotation to the selected grade 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 }); + 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(); @@ -593,8 +695,18 @@ test("auto-assign with 'lab_leaders' pool assigns submissions to section leaders { 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 } + { + 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 @@ -629,15 +741,26 @@ test("auto-assign with 'lab_leaders' pool assigns submissions to section leaders }); 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 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(); + 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()); @@ -650,10 +773,30 @@ test("auto-assign with 'group_mentors' pool assigns submissions to mentor and sk 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 } + { + 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({ @@ -692,8 +835,16 @@ test("auto-assign with 'group_mentors' pool assigns submissions to mentor and sk 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 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(); @@ -713,9 +864,24 @@ test("run_assignment_grading_automation is idempotent across repeated calls", as 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 } + { + 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({ @@ -733,8 +899,16 @@ test("run_assignment_grading_automation is idempotent across repeated calls", as 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 }); + 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"); @@ -798,8 +972,18 @@ test("reminders only refire after the configured interval elapses", async () => 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 } + { + 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({ @@ -817,7 +1001,11 @@ test("reminders only refire after the configured interval elapses", async () => 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 }); + await insertPreBakedSubmission({ + student_profile_id: studentC.private_profile_id, + assignment_id: a.id, + class_id: course.id + }); const subjectLike = `Late grading reminder: ${a.title}%`; @@ -851,14 +1039,45 @@ test("reminder email queue excludes completed, future-due, disabled, and wrong-r 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 } - ]); + 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 @@ -1007,9 +1226,24 @@ test("late submission after deadline is auto-assigned at submission time without 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 } + { + 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({ From e3215bbc6b6cef9ab0d3997cefa3a4cfe1b980f1 Mon Sep 17 00:00:00 2001 From: Jonathan Bell Date: Sun, 17 May 2026 02:23:59 +0000 Subject: [PATCH 13/14] Fix CI failures in grading-defaults E2E: SQL column, form bugs, test brittleness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced and fixed the CI failure (run 25948945436) locally against a fresh Supabase + prod build. SQL fix: auto_assign_grading_reviews_for_assignment and the new auto_assign_grading_review_for_submission both did ORDER BY ur.created_at on user_roles, which has no created_at column. Use ORDER BY ur.id, which gives the same stable "earliest enrolled" semantics. Product fix: GradingAutomationSubform applied the saved profile's values to the form via a useEffect on selectedProfile every time selectedProfile became defined — including the initial form reset from the assignment's saved DB state. On edit, this clobbered per-assignment overrides with profile defaults. Move the apply-profile-values call into the Saved-profile select's onChange so it only fires on user-initiated profile changes. UI cleanup: removed the duplicate on the grading-defaults page (layout.tsx already mounts one globally), which had caused every toaster.success(...) to render twice in the DOM and tripped Playwright's strict-mode locator. E2E robustness fixes around real CI artifacts of the page: - Chakra Checkbox.Control intercepts pointer events; click the label text instead (pattern used in gradebook.test.tsx). - Lingering toast regions overlap the Delete buttons; use dispatchEvent("click") to bypass DOM-level stacking, and drive the two window.confirms with a single counter-based page.on("dialog") handler so order-of-arrival doesn't matter. - ManageAssignmentNav renders its children twice (one desktop layout, one mobile layout) — both copies are in the DOM regardless of viewport, so scope every edit-page locator to the first
wrapper. - The reminder-filter test now upserts submission_review rows (existing triggers may auto-create the grading-rubric one on submission insert) instead of unconditionally inserting and failing on the unique key. Local validation: with the fixes applied, all 10 non-UI tests in grading-assignment-defaults.test.tsx pass; the UI test progresses cleanly through every assertion until the post-save "Assignment Updated" toast, which requires a real GitHub webhook configure call (AGENTS.md documents this as expected for dummy-credentials local runs). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../manage/assignments/new/form.tsx | 42 +++-- .../grading-assignment-defaults/page.tsx | 3 +- ...ding_assignment_defaults_and_reminders.sql | 4 +- .../e2e/grading-assignment-defaults.test.tsx | 163 +++++++++++------- 4 files changed, 128 insertions(+), 84 deletions(-) diff --git a/app/course/[course_id]/manage/assignments/new/form.tsx b/app/course/[course_id]/manage/assignments/new/form.tsx index 788d1de48..bc45949e7 100644 --- a/app/course/[course_id]/manage/assignments/new/form.tsx +++ b/app/course/[course_id]/manage/assignments/new/form.tsx @@ -674,7 +674,6 @@ function GradingAutomationSubform({ const remindersEnabled = watch("late_grading_reminders_enabled"); const autoAssignAtDeadline = watch("auto_assign_at_deadline"); const assigneePool = watch("auto_assign_assignee_pool"); - const selectedProfile = selectedProfileId ? profileMap[selectedProfileId] : undefined; useEffect(() => { if (assigneePool !== "graders") { @@ -682,22 +681,22 @@ function GradingAutomationSubform({ } }, [assigneePool, setValue]); - useEffect(() => { - if (!selectedProfile || !applyProfileOnSelect) { - return; - } - setValue("auto_assign_at_deadline", selectedProfile.auto_assign_at_deadline); - setValue("auto_assign_assignee_pool", selectedProfile.auto_assign_assignee_pool); - setValue("auto_assign_review_due_hours", selectedProfile.auto_assign_review_due_hours); - setValue( - "auto_assign_grader_subset_private_profile_ids", - normalizeProfileIdSubset(selectedProfile.auto_assign_grader_subset_private_profile_ids) - ); - setValue("late_grading_reminders_enabled", selectedProfile.late_grading_reminders_enabled); - setValue("late_grading_reminder_interval_hours", selectedProfile.late_grading_reminder_interval_hours); - setValue("late_grading_reply_to", selectedProfile.late_grading_reply_to); - setValue("late_grading_cc_emails", normalizeCcEmails(selectedProfile.late_grading_cc_emails)); - }, [selectedProfile, applyProfileOnSelect, 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 ( @@ -715,7 +714,14 @@ function GradingAutomationSubform({ value={String(selectedProfileId ?? "")} onChange={(event) => { const rawValue = event.target.value; - setValue("grading_default_profile_id", rawValue ? Number(rawValue) : null, { shouldDirty: true }); + 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); + } }} > 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 index 8aa2b0d9e..127839dbf 100644 --- a/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx +++ b/app/course/[course_id]/manage/course/grading-assignment-defaults/page.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { Field } from "@/components/ui/field"; -import { toaster, Toaster } from "@/components/ui/toaster"; +import { toaster } from "@/components/ui/toaster"; import { useCourseController } from "@/hooks/useCourseController"; import { useListTableControllerValues } from "@/lib/TableController"; import { createClient } from "@/utils/supabase/client"; @@ -276,7 +276,6 @@ export default function GradingAssignmentDefaultsPage() { return ( - Grading Assignment Defaults diff --git a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql index e026b7cc6..eccd9b298 100644 --- a/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql +++ b/supabase/migrations/20260418100000_grading_assignment_defaults_and_reminders.sql @@ -289,7 +289,7 @@ BEGIN WHERE ur.class_id = v_assignment.class_id AND ur.role = 'instructor' AND ur.disabled = false - ORDER BY ur.created_at + ORDER BY ur.id LIMIT 1; IF v_actor_user_id IS NULL THEN @@ -822,7 +822,7 @@ BEGIN WHERE ur.class_id = v_assignment.class_id AND ur.role = 'instructor' AND ur.disabled = false - ORDER BY ur.created_at + ORDER BY ur.id LIMIT 1; IF v_actor_user_id IS NULL THEN diff --git a/tests/e2e/grading-assignment-defaults.test.tsx b/tests/e2e/grading-assignment-defaults.test.tsx index f3ee47eb8..92bdf2e7a 100644 --- a/tests/e2e/grading-assignment-defaults.test.tsx +++ b/tests/e2e/grading-assignment-defaults.test.tsx @@ -59,10 +59,21 @@ test("instructors can manage grading default profiles and apply them on assignme await page.getByLabel("Profile name").fill(gradingProfileName); await page.getByLabel("Description").fill("Profile used by E2E test coverage."); - await page.getByRole("checkbox", { name: "Auto assign at deadline" }).check(); + // 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"); - await page.getByRole("checkbox", { name: "Enable late grading reminders" }).check(); + 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"); @@ -89,20 +100,34 @@ test("instructors can manage grading default profiles and apply them on assignme const profile = createdProfile as GradingAssignmentDefaultProfile; - page.once("dialog", async (dialog) => { + // 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?"); - await dialog.dismiss(); - }); - await page.getByRole("button", { name: "Delete" }).first().click(); - await expect(page.getByText(gradingProfileName)).toBeVisible(); - - page.once("dialog", async (dialog) => { - expect(dialog.message()).toContain("Delete this grading default profile?"); - await dialog.accept(); - }); - await page.getByRole("button", { name: "Delete" }).nth(1).click(); - await expect(page.getByText(throwawayProfileName)).not.toBeVisible(); - await expect(page.getByText(gradingProfileName)).toBeVisible(); + 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(); @@ -120,13 +145,20 @@ test("instructors can manage grading default profiles and apply them on assignme await expect(page.getByLabel("CC emails")).toHaveValue("staff1@example.edu, staff2@example.edu"); const applyProfileCheckbox = page.getByRole("checkbox", { name: "Apply profile settings on selection" }); - await applyProfileCheckbox.uncheck(); + 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"); - await applyProfileCheckbox.check(); + 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"); @@ -150,22 +182,31 @@ test("instructors can manage grading default profiles and apply them on assignme await page.goto(`/course/${course.id}/manage/assignments/${assignment!.id}/edit`); await expect(page.getByRole("heading", { name: "Edit Assignment" })).toBeVisible(); - const editProfileSelect = page.getByLabel("Saved profile"); + // 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 = page.getByLabel("Review due hours after assignment deadline"); + const editDueHoursInput = editForm.getByLabel("Review due hours after assignment deadline"); await expect(editDueHoursInput).toHaveValue("48"); - await expect(page.getByLabel("Reminder interval (hours)")).toHaveValue("24"); - await expect(page.getByLabel("Reply-to email")).toHaveValue("seeded-reply@example.edu"); - await expect(page.getByLabel("CC emails")).toHaveValue("seeded-cc@example.edu"); + 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 page.getByLabel("Reminder interval (hours)").fill("6"); - await page.getByLabel("Reply-to email").fill("updated-reply@example.edu"); - await page.getByLabel("CC emails").fill("updated-cc@example.edu"); - await page.getByRole("button", { name: "Save" }).click(); + 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"); + await editForm.getByRole("button", { name: "Save" }).click(); await expect(page.getByText("Assignment Updated")).toBeVisible({ timeout: 20_000 }); @@ -1110,37 +1151,35 @@ test("reminder email queue excludes completed, future-due, disabled, and wrong-r class_id: course.id }); - // Create a submission_review for the grading rubric so review_assignments inserts can satisfy - // their submission_review_id FK without going through bulk_assign_reviews. - const { data: gradingReview, error: reviewErr } = await supabase - .from("submission_reviews") - .insert({ - submission_id: sub.submission_id, - rubric_id: a.grading_rubric_id!, - class_id: course.id, - name: "Grading review for reminder-filter test", - total_score: 0, - total_autograde_score: 0, - tweak: 0 - }) - .select("id") - .single(); - expect(reviewErr).toBeNull(); - // Also create a submission_review for the SELF rubric so the wrong-rubric scenario is valid. - const { data: selfReview, error: selfReviewErr } = await supabase - .from("submission_reviews") - .insert({ - submission_id: sub.submission_id, - rubric_id: a.self_review_rubric_id!, - class_id: course.id, - name: "Self review for reminder-filter test", - total_score: 0, - total_autograde_score: 0, - tweak: 0 - }) - .select("id") - .single(); - expect(selfReviewErr).toBeNull(); + // 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(); @@ -1150,7 +1189,7 @@ test("reminder email queue excludes completed, future-due, disabled, and wrong-r { assignee_profile_id: graderActive.private_profile_id, submission_id: sub.submission_id, - submission_review_id: gradingReview!.id, + submission_review_id: gradingReviewId, assignment_id: a.id, rubric_id: a.grading_rubric_id!, class_id: course.id, @@ -1160,7 +1199,7 @@ test("reminder email queue excludes completed, future-due, disabled, and wrong-r { assignee_profile_id: graderCompleted.private_profile_id, submission_id: sub.submission_id, - submission_review_id: gradingReview!.id, + submission_review_id: gradingReviewId, assignment_id: a.id, rubric_id: a.grading_rubric_id!, class_id: course.id, @@ -1171,7 +1210,7 @@ test("reminder email queue excludes completed, future-due, disabled, and wrong-r { assignee_profile_id: graderFuture.private_profile_id, submission_id: sub.submission_id, - submission_review_id: gradingReview!.id, + submission_review_id: gradingReviewId, assignment_id: a.id, rubric_id: a.grading_rubric_id!, class_id: course.id, @@ -1181,7 +1220,7 @@ test("reminder email queue excludes completed, future-due, disabled, and wrong-r { assignee_profile_id: graderDisabled.private_profile_id, submission_id: sub.submission_id, - submission_review_id: gradingReview!.id, + submission_review_id: gradingReviewId, assignment_id: a.id, rubric_id: a.grading_rubric_id!, class_id: course.id, @@ -1191,7 +1230,7 @@ test("reminder email queue excludes completed, future-due, disabled, and wrong-r { assignee_profile_id: graderWrongRubric.private_profile_id, submission_id: sub.submission_id, - submission_review_id: selfReview!.id, + submission_review_id: selfReviewId, assignment_id: a.id, rubric_id: a.self_review_rubric_id!, class_id: course.id, From 3e1ae9493cbeb3fe38fda2f9f34c28952fb4821b Mon Sep 17 00:00:00 2001 From: Jonathan Bell Date: Fri, 22 May 2026 16:26:38 -0400 Subject: [PATCH 14/14] . --- .../e2e/grading-assignment-defaults.test.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/e2e/grading-assignment-defaults.test.tsx b/tests/e2e/grading-assignment-defaults.test.tsx index 92bdf2e7a..fb6289bdd 100644 --- a/tests/e2e/grading-assignment-defaults.test.tsx +++ b/tests/e2e/grading-assignment-defaults.test.tsx @@ -206,9 +206,30 @@ test("instructors can manage grading default profiles and apply them on assignme 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"); - await editForm.getByRole("button", { name: "Save" }).click(); - await expect(page.getByText("Assignment Updated")).toBeVisible({ timeout: 20_000 }); + 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