fix(api): allow inviting students to compliance courses without a certificate deadline (#840)

* Allow teachers invite students

* Greptile comment fix

* coderabbit comment fix
This commit is contained in:
Victor Jack
2026-08-15 10:30:45 +01:00
committed by GitHub
parent 96bade402a
commit d48d85df2f
3 changed files with 47 additions and 30 deletions
+35 -10
View File
@@ -135,16 +135,17 @@ async function getTargetStudentMembers(courseId: string, profileIds?: string[])
return students; return students;
} }
function resolveComplianceInitialDueDate(course: Awaited<ReturnType<typeof getComplianceCourseOrThrow>>) { /**
* Resolves the initial compliance cycle due date from the course certificate
* deadline. Returns `null` when no deadline is set — callers must then skip
* record creation (enrollment itself is still allowed). Throws only when a
* deadline is present but malformed.
*/
function getComplianceInitialDueDate(course: Awaited<ReturnType<typeof getComplianceCourseOrThrow>>): string | null {
const dueDate = course.certificate?.deadline ?? null; const dueDate = course.certificate?.deadline ?? null;
if (!dueDate) { if (!dueDate) {
throw new AppError( return null;
'Compliance courses require a certificate deadline before learners can be enrolled',
ErrorCodes.VALIDATION_ERROR,
400,
'certificate.deadline'
);
} }
const parsedDueDate = new Date(dueDate); const parsedDueDate = new Date(dueDate);
@@ -211,7 +212,12 @@ async function getComplianceCompletionSnapshot(params: {
async function ensureComplianceEnrollmentRecordForLearner(courseId: string, groupMemberId: string, profileId: string) { async function ensureComplianceEnrollmentRecordForLearner(courseId: string, groupMemberId: string, profileId: string) {
const course = await getComplianceCourseOrThrow(courseId); const course = await getComplianceCourseOrThrow(courseId);
const dueDate = resolveComplianceInitialDueDate(course); const dueDate = getComplianceInitialDueDate(course);
if (!dueDate) {
return null;
}
const [existingRecord] = await getLatestComplianceRecordsByProfiles(courseId, [profileId]); const [existingRecord] = await getLatestComplianceRecordsByProfiles(courseId, [profileId]);
if (existingRecord) { if (existingRecord) {
@@ -242,11 +248,14 @@ export async function ensureComplianceEnrollmentRecordsForProfiles(courseIds: st
for (const courseId of courseIds) { for (const courseId of courseIds) {
const [course] = await getCourseById(courseId); const [course] = await getCourseById(courseId);
if (!course || course.type !== 'COMPLIANCE') { if (!course || course.type !== 'COMPLIANCE' || !course.compliance) {
continue; continue;
} }
resolveComplianceInitialDueDate(await getComplianceCourseOrThrow(courseId)); const initialDueDate = getComplianceInitialDueDate(course);
if (!initialDueDate) {
continue;
}
const learners = await getStudentCourseMembersForCompliance(courseId, profileIds); const learners = await getStudentCourseMembersForCompliance(courseId, profileIds);
for (const learner of learners) { for (const learner of learners) {
@@ -283,6 +292,14 @@ export async function syncComplianceProgressFromSubmission(courseId: string, gro
} }
const activeRecord = await ensureComplianceEnrollmentRecordForLearner(courseId, groupMemberId, profile.id); const activeRecord = await ensureComplianceEnrollmentRecordForLearner(courseId, groupMemberId, profile.id);
if (!activeRecord) {
return {
status: 'not_started',
completed: false
};
}
if (!activeRecord.completedAt && activeRecord.status === 'not_started') { if (!activeRecord.completedAt && activeRecord.status === 'not_started') {
await updateCourseCompletionRecord(activeRecord.id, { await updateCourseCompletionRecord(activeRecord.id, {
status: 'in_progress', status: 'in_progress',
@@ -299,6 +316,14 @@ export async function syncComplianceProgressFromSubmission(courseId: string, gro
} }
const latestRecord = await ensureComplianceEnrollmentRecordForLearner(courseId, groupMemberId, profile.id); const latestRecord = await ensureComplianceEnrollmentRecordForLearner(courseId, groupMemberId, profile.id);
if (!latestRecord) {
return {
status: 'not_started',
completed: false
};
}
if (latestRecord.completedAt) { if (latestRecord.completedAt) {
return { return {
status: latestRecord.status, status: latestRecord.status,
@@ -49,8 +49,8 @@ export class ComplianceApi extends BaseApiWithErrors {
return result?.data; return result?.data;
} }
async ensureOverview(courseId: string) { async ensureOverview(courseId: string, refresh = false) {
if (this.overviewKey === courseId && this.overview) { if (!refresh && this.overviewKey === courseId && this.overview) {
return this.overview; return this.overview;
} }
@@ -80,9 +80,9 @@ export class ComplianceApi extends BaseApiWithErrors {
return result?.data; return result?.data;
} }
async ensureLearnerHistory(courseId: string, profileId: string) { async ensureLearnerHistory(courseId: string, profileId: string, refresh = false) {
const requestKey = `${courseId}:${profileId}`; const requestKey = `${courseId}:${profileId}`;
if (this.learnerHistoryKey === requestKey && this.learnerHistory) { if (!refresh && this.learnerHistoryKey === requestKey && this.learnerHistory) {
return this.learnerHistory; return this.learnerHistory;
} }
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { Badge } from '@cio/ui/base/badge'; import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { Checkbox } from '@cio/ui/base/checkbox'; import { Checkbox } from '@cio/ui/base/checkbox';
@@ -21,8 +22,8 @@
type ComplianceStatus = CourseComplianceLearner['status'] | LearnerComplianceRecord['status'] | 'no_record'; type ComplianceStatus = CourseComplianceLearner['status'] | LearnerComplianceRecord['status'] | 'no_record';
type StaffAction = 'reset' | 'extend' | 'waive' | null; type StaffAction = 'reset' | 'extend' | 'waive' | null;
let lastRequestKey = $state<string | null>(null);
let staffTab = $state<'overview' | 'learners'>('overview'); let staffTab = $state<'overview' | 'learners'>('overview');
let loadedCourseId = $state<string | null>(null);
let activeAction = $state<StaffAction>(null); let activeAction = $state<StaffAction>(null);
let bulkActionType = $state<Exclude<StaffAction, null> | null>(null); let bulkActionType = $state<Exclude<StaffAction, null> | null>(null);
let isBulkActionModalOpen = $state(false); let isBulkActionModalOpen = $state(false);
@@ -76,29 +77,20 @@
if (!isComplianceCourse) { if (!isComplianceCourse) {
complianceApi.reset(); complianceApi.reset();
lastRequestKey = null; loadedCourseId = null;
return; return;
} }
const courseId = courseApi.course.id; const courseId = courseApi.course.id;
const needsRefresh = untrack(() => loadedCourseId) !== courseId;
loadedCourseId = courseId;
if (isStudent && currentMember?.profileId) { if (isStudent && currentMember?.profileId) {
const requestKey = `${courseId}:${currentMember.profileId}:learner`; void complianceApi.ensureLearnerHistory(courseId, currentMember.profileId, needsRefresh);
if (lastRequestKey === requestKey) {
return;
}
lastRequestKey = requestKey;
void complianceApi.ensureLearnerHistory(courseId, currentMember.profileId);
return; return;
} }
const requestKey = `${courseId}:overview`; void complianceApi.ensureOverview(courseId, needsRefresh);
if (lastRequestKey === requestKey) {
return;
}
lastRequestKey = requestKey;
void complianceApi.ensureOverview(courseId);
}); });
$effect(() => { $effect(() => {