From ad5c8db5a935c2a06cca84a7d884a2a6c4a555c9 Mon Sep 17 00:00:00 2001 From: Victor Jack <67293106+thedon-dev@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:55:51 +0100 Subject: [PATCH] feat(dashboard): moved course completion deadline to course settings page (#842) --- .../course-go-live-readiness.test.ts | 37 ++++ .../lib/features/course/api/course.svelte.ts | 11 +- .../ceritficate/certificate-settings.svelte | 147 ------------- .../lib/features/course/pages/settings.svelte | 206 ++++++++++++++++-- .../features/course/utils/settings-store.ts | 14 +- .../features/ui/attention-highlight.svelte | 40 +++- .../src/lib/utils/translations/da.json | 15 +- .../src/lib/utils/translations/de.json | 13 +- .../src/lib/utils/translations/en.json | 17 +- .../src/lib/utils/translations/es.json | 15 +- .../src/lib/utils/translations/fr.json | 13 +- .../src/lib/utils/translations/hi.json | 13 +- .../src/lib/utils/translations/pl.json | 13 +- .../src/lib/utils/translations/pt.json | 15 +- .../src/lib/utils/translations/ru.json | 15 +- .../src/lib/utils/translations/vi.json | 13 +- packages/core/src/services/course/course.ts | 31 ++- .../src/services/course/go-live-readiness.ts | 30 ++- packages/utils/src/constants/error-codes.ts | 1 + .../src/functions/compliance-deadline.ts | 38 ++++ packages/utils/src/functions/index.ts | 1 + .../utils/tests/compliance-deadline.test.ts | 74 +++++++ 22 files changed, 528 insertions(+), 244 deletions(-) create mode 100644 packages/utils/src/functions/compliance-deadline.ts create mode 100644 packages/utils/tests/compliance-deadline.test.ts diff --git a/apps/api/src/__tests__/course-go-live-readiness.test.ts b/apps/api/src/__tests__/course-go-live-readiness.test.ts index 9e293704..724a5846 100644 --- a/apps/api/src/__tests__/course-go-live-readiness.test.ts +++ b/apps/api/src/__tests__/course-go-live-readiness.test.ts @@ -177,6 +177,43 @@ describe('evaluateCourseGoLiveReadiness', () => { expect(readiness.blockers).toEqual([]); expect(readiness.courseUrl).toBe('https://school.example.com/course/workplace-safety-essentials'); }); + + it('blocks publishing a compliance course that has no completion deadline', () => { + const readiness = evaluateCourseGoLiveReadiness({ + course: buildCourse({ + type: 'COMPLIANCE', + certificate: { isDownloadable: true } + }) as never, + contentItems: [buildContentItem()], + organization: { + customDomain: 'school.example.com', + isCustomDomainVerified: true, + siteName: 'school' + } as never + }); + + expect(readiness.ready).toBe(false); + expect(readiness.blockers.map((blocker) => blocker.code)).toContain('COMPLIANCE_DEADLINE_MISSING'); + }); + + it('allows publishing a compliance course when a completion deadline is set', () => { + const readiness = evaluateCourseGoLiveReadiness({ + course: buildCourse({ + type: 'COMPLIANCE', + certificate: { deadline: '2026-12-31T23:59:00.000Z' } + }) as never, + contentItems: [buildContentItem()], + organization: { + customDomain: 'school.example.com', + isCustomDomainVerified: true, + siteName: 'school' + } as never + }); + + expect(readiness.ready).toBe(true); + expect(readiness.blockers).toEqual([]); + expect(readiness.warnings.map((warning) => warning.code)).toContain('COMPLIANCE_REVIEW_RECOMMENDED'); + }); }); describe('course go-live service', () => { diff --git a/apps/dashboard/src/lib/features/course/api/course.svelte.ts b/apps/dashboard/src/lib/features/course/api/course.svelte.ts index a37d6919..7e2e3578 100644 --- a/apps/dashboard/src/lib/features/course/api/course.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/course.svelte.ts @@ -29,7 +29,7 @@ import { resolve } from '$app/paths'; import { snackbar } from '$features/ui/snackbar/store'; import { t } from '$lib/utils/functions/translations'; import { coursesApi } from './courses.svelte'; -import { ROLE } from '@cio/utils/constants'; +import { ROLE, ErrorCodes } from '@cio/utils/constants'; import { ContentType } from '@cio/utils/constants/content'; import type { CourseMembers } from '../utils/types'; @@ -326,11 +326,11 @@ export class CourseApi extends BaseApiWithErrors { typeof result === 'object' && result !== null && 'code' in result && - result.code === 'VALIDATION_ERROR' && + result.code === ErrorCodes.VALIDATION_ERROR && 'field' in result && result.field === 'certificate.deadline' ) { - snackbar.error('course.certification.deadline_required_enroll'); + snackbar.error('course.certification.deadline_invalid'); } else if (typeof result === 'object' && result !== null && 'error' in result) { snackbar.error((result as { error: string }).error); } @@ -497,6 +497,11 @@ export class CourseApi extends BaseApiWithErrors { snackbar.error('Failed to update course'); return; } + if ('code' in result && result.code === ErrorCodes.COMPLIANCE_DEADLINE_REQUIRED) { + this.errors['certificate.deadline'] = t.get('course.certification.deadline_required'); + snackbar.error('course.certification.deadline_required'); + return; + } if ('error' in result && 'field' in result && result.field) { this.errors[result.field] = result.error; snackbar.error(result.error); diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte index f18d6e0b..9271717e 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte @@ -1,15 +1,10 @@ @@ -101,97 +43,8 @@ {$t('course.navItem.certificates.allow')} - {#if courseApi.course?.certificate?.isDownloadable && certificateValidationErrors.includes('course.certification.deadline_required')} - {$t('course.certification.deadline_required')} - {/if} - - - - - - {$t('course.certification.deadline_label')} - {#if courseApi.course?.certificate?.isDownloadable} - * - {/if} - - - {$t('course.certification.deadline_helper')} - {#if errors['certificate.deadline']} - {errors['certificate.deadline']} - {/if} - {#if certificateValidationErrors.includes('course.certification.deadline_required')} - {$t('course.certification.deadline_required')} - {/if} - - - - - - {$t('course.certification.threshold_label')} - - {$t('course.certification.threshold_helper')} - {#if errors['certificate.threshold']} - {errors['certificate.threshold']} - {/if} - - - - - {$t('course.certification.final_exercise_label')} - - {$t('course.certification.final_exercise_helper')} - {$t('course.certification.final_exercise_multiple_attempts_note')} - - - {#if courseApi.course?.certificate?.requiredExerciseId} - - {$t('course.certification.min_exercise_score_label')} - - {$t('course.certification.min_exercise_score_helper')} - {#if errors['certificate.exerciseMinScorePercent']} - {errors['certificate.exerciseMinScorePercent']} - {/if} - - {/if} - diff --git a/apps/dashboard/src/lib/features/course/pages/settings.svelte b/apps/dashboard/src/lib/features/course/pages/settings.svelte index 074d5dc5..a5bfa5f7 100644 --- a/apps/dashboard/src/lib/features/course/pages/settings.svelte +++ b/apps/dashboard/src/lib/features/course/pages/settings.svelte @@ -16,13 +16,13 @@ import { IconButton } from '@cio/ui/custom/icon-button'; import { TextareaField } from '@cio/ui/custom/textarea-field'; import { InputField } from '@cio/ui/custom/input-field'; + import { Input } from '@cio/ui/base/input'; import * as Field from '@cio/ui/base/field'; import { UpgradeBanner, UnsavedChanges, UploadWidget, TextEditor, AttentionHighlight } from '$features/ui'; import { Button } from '@cio/ui/base/button'; - import { goAndHighlight } from '$lib/routing/go-and-highlight'; - import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes'; import { settings } from '$features/course/utils/settings-store'; + import { getOrderedNavigableContent } from '$features/course/utils/content'; import Copy from '@lucide/svelte/icons/copy'; import * as Alert from '@cio/ui/base/alert'; import LockOpenIcon from '@lucide/svelte/icons/lock-open'; @@ -31,8 +31,9 @@ import { t } from '$lib/utils/functions/translations'; import { isObject } from '$lib/utils/functions/isObject'; import { snackbar } from '$features/ui/snackbar/store'; - import { generateSlug } from '@cio/utils/functions'; + import { generateSlug, isPublishedComplianceMissingDeadline } from '@cio/utils/functions'; import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils'; + import { ContentType } from '@cio/utils/constants/content'; import { DeleteModal } from '$features/ui'; import { contentApi, courseApi } from '$features/course/api'; import { collectLockedContentItems } from '$features/course/utils/content-lock-utils'; @@ -52,6 +53,7 @@ let isLoading = $state(false); let isDeleting = $state(false); let openCertificateDeadlineDialog = $state(false); + let completionDeadlineTrigger = $state(0); let errors: { title: string | undefined; description: string | undefined; @@ -169,10 +171,13 @@ return; } - const isCourseCompliance = $settings.type === 'COMPLIANCE'; - const hasCertificateDeadline = !!courseApi.course?.certificate?.deadline; - - if (isCourseCompliance && !hasCertificateDeadline) { + if ( + isPublishedComplianceMissingDeadline({ + type: $settings.type, + isPublished: true, + deadline: $settings.certificate.deadline + }) + ) { openCertificateDeadlineDialog = true; return; } @@ -183,15 +188,9 @@ hasUnsavedChanges = true; } - async function goToCertificateSettings() { + function goToCompletionDeadline() { openCertificateDeadlineDialog = false; - const courseId = courseApi.course?.id; - if (!courseId) return; - - goAndHighlight(ROUTE_NAME.COURSE_CERTIFICATE, ROUTE_SECTIONS[ROUTE_NAME.COURSE_CERTIFICATE].CERT_DEADLINE, { - id: courseId, - tab: 'settings' - }); + completionDeadlineTrigger += 1; } export async function handleSave() { @@ -215,6 +214,17 @@ if (!courseApi.course) return; + if ( + isPublishedComplianceMissingDeadline({ + type: $settings.type, + isPublished: $settings.isPublished, + deadline: $settings.certificate.deadline + }) + ) { + openCertificateDeadlineDialog = true; + return; + } + if ($settings.isPublished && !courseApi.course.slug) { courseApi.course.slug = generateSlug($settings.courseTitle, { appendTimestamp: true }); } @@ -240,7 +250,16 @@ slug: courseApi.course.slug ?? undefined, compliance: $settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined, - callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null + callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null, + certificate: { + ...(courseApi.course.certificate ?? {}), + deadline: $settings.certificate.deadline, + threshold: $settings.certificate.threshold, + requiredExerciseId: $settings.certificate.requiredExerciseId, + exerciseMinScorePercent: $settings.certificate.requiredExerciseId + ? $settings.certificate.exerciseMinScorePercent + : null + } }; const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds); @@ -293,7 +312,18 @@ isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true, progressionMode: course.metadata?.progressionMode ?? 'free', callout: normalizeCallout(course.callout), - welcomeEmailMessage: course.metadata?.welcomeEmailMessage ?? '' + welcomeEmailMessage: course.metadata?.welcomeEmailMessage ?? '', + certificate: { + deadline: course.certificate?.deadline ?? null, + threshold: typeof course.certificate?.threshold === 'number' ? course.certificate.threshold : 100, + requiredExerciseId: course.certificate?.requiredExerciseId ?? null, + exerciseMinScorePercent: + typeof course.certificate?.exerciseMinScorePercent === 'number' + ? course.certificate.exerciseMinScorePercent + : course.certificate?.requiredExerciseId + ? 100 + : null + } }); }); } @@ -416,6 +446,54 @@ }); let courseLink = $derived(courseApi.course?.slug ? `${$currentOrgDomain}/course/${courseApi.course.slug}` : '#'); + + const certExercises = $derived( + getOrderedNavigableContent(courseApi.course).filter((item) => item.type === ContentType.Exercise) + ); + + const finalExerciseTitle = $derived( + certExercises.find((item) => item.id === $settings.certificate.requiredExerciseId)?.title + ); + + function isoToDatetimeLocal(iso: string | null | undefined): string { + if (!iso) return ''; + + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; + } + + function onCompletionDeadlineChange(e: Event) { + const value = (e.currentTarget as HTMLInputElement).value; + $settings.certificate.deadline = value ? new Date(value).toISOString() : null; + hasUnsavedChanges = true; + } + + function onThresholdInput(e: Event) { + const value = Number((e.currentTarget as HTMLInputElement).value); + $settings.certificate.threshold = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 100; + hasUnsavedChanges = true; + } + + function onFinalExerciseChange(value: string) { + $settings.certificate.requiredExerciseId = value && value !== 'none' ? value : null; + + if (!$settings.certificate.requiredExerciseId) { + $settings.certificate.exerciseMinScorePercent = null; + } else if (typeof $settings.certificate.exerciseMinScorePercent !== 'number') { + $settings.certificate.exerciseMinScorePercent = 100; + } + + hasUnsavedChanges = true; + } + + function onMinExerciseScoreInput(e: Event) { + const value = Number((e.currentTarget as HTMLInputElement).value); + $settings.certificate.exerciseMinScorePercent = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 100; + hasUnsavedChanges = true; + } @@ -434,8 +512,8 @@ - @@ -595,6 +673,96 @@

{courseApi.errors.type}

{/if} + + + + + + {$t('course.navItem.settings.completion_deadline_label')} + + + {$t('course.navItem.settings.completion_deadline_helper')} + {#if courseApi.errors['certificate.deadline']} + {courseApi.errors['certificate.deadline']} + {/if} + + + + + + {$t('course.certification.threshold_label')} + + + {$t('course.certification.threshold_helper')} + {#if courseApi.errors['certificate.threshold']} + {courseApi.errors['certificate.threshold']} + {/if} + + + + + {$t('course.certification.final_exercise_label')} + + + + {finalExerciseTitle ?? $t('course.certification.final_exercise_none')} + + + + + {$t('course.certification.final_exercise_none')} + + {#each certExercises as item (item.id)} + + {item.title} + + {/each} + + + + {$t('course.certification.final_exercise_helper')} + {$t('course.certification.final_exercise_multiple_attempts_note')} + + + {#if $settings.certificate.requiredExerciseId} + + + {$t('course.certification.min_exercise_score_label')} + + + {$t('course.certification.min_exercise_score_helper')} + {#if courseApi.errors['certificate.exerciseMinScorePercent']} + {courseApi.errors['certificate.exerciseMinScorePercent']} + {/if} + + {/if} + diff --git a/apps/dashboard/src/lib/features/course/utils/settings-store.ts b/apps/dashboard/src/lib/features/course/utils/settings-store.ts index e097988c..1235e2e0 100644 --- a/apps/dashboard/src/lib/features/course/utils/settings-store.ts +++ b/apps/dashboard/src/lib/features/course/utils/settings-store.ts @@ -16,6 +16,12 @@ type CourseSettings = { progressionMode: 'free' | 'sequential'; callout: TCourseCallout | null; welcomeEmailMessage: string; + certificate: { + deadline: string | null; + threshold: number; + requiredExerciseId: string | null; + exerciseMinScorePercent: number | null; + }; }; export const settings = writable({ @@ -36,5 +42,11 @@ export const settings = writable({ isContentGroupingEnabled: true, progressionMode: 'free', callout: null, - welcomeEmailMessage: '' + welcomeEmailMessage: '', + certificate: { + deadline: null, + threshold: 100, + requiredExerciseId: null, + exerciseMinScorePercent: null + } }); diff --git a/apps/dashboard/src/lib/features/ui/attention-highlight.svelte b/apps/dashboard/src/lib/features/ui/attention-highlight.svelte index aa7188b6..2bf92bed 100644 --- a/apps/dashboard/src/lib/features/ui/attention-highlight.svelte +++ b/apps/dashboard/src/lib/features/ui/attention-highlight.svelte @@ -7,14 +7,16 @@ interface Props { id: string; duration?: number; + trigger?: number; children?: Snippet; } - let { id, duration = 3, children }: Props = $props(); + let { id, duration = 3, trigger = 0, children }: Props = $props(); let containerRef = $state(null); let pulsing = $state(false); let handledFor = $state(null); + let lastTrigger = trigger; let timeoutId: ReturnType | null = null; function getActiveHighlightId(url: URL): string | null { @@ -25,17 +27,7 @@ return hash || null; } - $effect(() => { - const activeHighlightId = getActiveHighlightId(page.url); - - if (activeHighlightId !== id) { - handledFor = null; - return; - } - - if (handledFor === activeHighlightId) return; - - handledFor = activeHighlightId; + function triggerPulse() { pulsing = true; containerRef?.scrollIntoView({ behavior: 'smooth', block: 'center' }); @@ -56,6 +48,30 @@ replaceState(resolve(`${url.pathname}${url.search}`, {}), page.state); } }, duration * 1000); + } + + $effect(() => { + const activeHighlightId = getActiveHighlightId(page.url); + + if (activeHighlightId !== id) { + handledFor = null; + return; + } + + if (handledFor === activeHighlightId) return; + + handledFor = activeHighlightId; + triggerPulse(); + }); + + $effect(() => { + if (trigger === lastTrigger) return; + + lastTrigger = trigger; + + if (trigger > 0) { + triggerPulse(); + } }); $effect(() => { diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index b7d50241..aa685256 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -794,8 +794,8 @@ "progression_mode_sequential": "Sekventiel — fuldfør tidligere elementer for at låse næste op", "lesson_download": "Hent lektioner", "available": "Gør lektionen tilgængelig til download i pdf for tilmeldte studerende", - "course_download": "Kursus download", - "course_avail": "Gør kurset tilgængeligt til download for registrerede studerende", + "course_download": "Kursusdownload", + "course_avail": "Download en kopi af dette kursus.", "download": "Download", "allow": "Tillad nye studerende", "access": "Tillad nye studerende at få adgang til dette kursus", @@ -860,8 +860,10 @@ "unlock_all": "Lås alle op" }, "certificate_deadline_required": "Indstil certifikatfrist først", - "certificate_deadline_required_description": "Overholdelseskurser kræver en certifikatfrist, før elever kan tilmeldes. Indstil venligst en deadline i certifikatindstillingerne før udgivelse.", - "go_to_certificate_settings": "Gå til Certifikatindstillinger" + "certificate_deadline_required_description": "Overholdelseskurser kræver en certifikatfrist, før elever kan tilmeldes. Indstil venligst en deadline i kursusindstillingerne før udgivelse.", + "go_to_completion_deadline": "Gå til fuldførelsesfristen", + "completion_deadline_label": "Deadline for kursusafslutning", + "completion_deadline_helper": "Studerende skal opfylde fuldførelsesprocenten inden denne dato (lokal tid). Lad være tomt uden deadline." }, "analytics": { "title": "Analyse" @@ -2479,8 +2481,9 @@ "email_message_label": "E-mail besked", "email_message_placeholder": "f.eks. Vi er stolte af dine fremskridt - fortsæt med at lære!", "final_exercise_multiple_attempts_note": "Elever får lov til ubegrænsede forsøg på den valgte øvelse.", - "deadline_required": "Certifikatfrist er påkrævet, når certifikater kan downloades.", - "deadline_required_enroll": "Overholdelseskurser kræver en certifikatfrist, før elever kan tilmeldes." + "deadline_required": "Overholdelseskurser kræver en fuldførelsesfrist, før de kan udgives.", + "deadline_required_enroll": "Overholdelseskurser kræver en certifikatfrist, før elever kan tilmeldes.", + "deadline_invalid": "Kursusets fuldførelsesfrist er ugyldig." }, "creator": { "heading": "Hvilket kursus vil du lave?", diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 03559501..c24c8816 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -795,7 +795,7 @@ "lesson_download": "Lektionen herunterladen", "available": "Stellen Sie die Lektion für registrierte Studierende zum Download im PDF-Format zur Verfügung", "course_download": "Kurs-Download", - "course_avail": "Stellen Sie den Kurs für registrierte Studierende zum Download bereit", + "course_avail": "Laden Sie eine Kopie dieses Kurses herunter.", "download": "Herunterladen", "allow": "Erlauben Sie neue Studenten", "access": "Ermöglichen Sie neuen Studierenden den Zugang zu diesem Kurs", @@ -860,8 +860,10 @@ "unlock_all": "Schalte alle frei" }, "certificate_deadline_required": "Legen Sie zuerst die Frist für das Zertifikat fest", - "certificate_deadline_required_description": "Für Compliance-Kurse ist eine Zertifikatsfrist erforderlich, bevor Lernende eingeschrieben werden können. Bitte legen Sie vor der Veröffentlichung eine Frist in den Zertifikatseinstellungen fest.", - "go_to_certificate_settings": "Gehen Sie zu Zertifikatseinstellungen" + "certificate_deadline_required_description": "Für Compliance-Kurse ist eine Zertifikatsfrist erforderlich, bevor Lernende eingeschrieben werden können. Bitte legen Sie vor der Veröffentlichung eine Frist in den Kurseinstellungen fest.", + "go_to_completion_deadline": "Gehen Sie zur Abschlussfrist", + "completion_deadline_label": "Frist für den Abschluss des Kurses", + "completion_deadline_helper": "Die Schüler müssen den Abschlussprozentsatz vor diesem Datum/Uhrzeit (Ortszeit) erreichen. Für keine Frist leer lassen." }, "analytics": { "title": "Analysen" @@ -2479,8 +2481,9 @@ "email_message_label": "E-Mail-Nachricht", "email_message_placeholder": "z.B. Wir sind stolz auf Ihre Fortschritte – lernen Sie weiter!", "final_exercise_multiple_attempts_note": "Den Schülern sind unbegrenzte Versuche für die ausgewählte Übung gestattet.", - "deadline_required": "Wenn Zertifikate herunterladbar sind, ist eine Zertifikatsfrist erforderlich.", - "deadline_required_enroll": "Für Compliance-Kurse ist eine Zertifikatsfrist erforderlich, bevor Lernende eingeschrieben werden können." + "deadline_required": "Für Compliance-Kurse ist eine Abschlussfrist erforderlich, bevor sie veröffentlicht werden können.", + "deadline_required_enroll": "Für Compliance-Kurse ist eine Zertifikatsfrist erforderlich, bevor Lernende eingeschrieben werden können.", + "deadline_invalid": "Die Abschlussfrist des Kurses ist ungültig." }, "creator": { "heading": "Welchen Kurs möchten Sie erstellen?", diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index a04c8fcd..c7d37a26 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -2027,8 +2027,9 @@ "rules_heading": "Rules", "deadline_label": "Certification deadline", "deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.", - "deadline_required": "Certificate deadline is required when certificates are downloadable.", + "deadline_required": "Compliance courses require a completion deadline before they can be published.", "deadline_required_enroll": "Compliance courses require a certificate deadline before learners can be enrolled.", + "deadline_invalid": "The course completion deadline is invalid.", "threshold_label": "Minimum completion (%)", "threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.", "final_exercise_label": "Final exercise (optional)", @@ -3639,10 +3640,10 @@ "progression_mode_description": "Control whether students can access all unlocked content or must complete items in order.", "progression_mode_free": "Free — unlocked content is accessible", "progression_mode_sequential": "Sequential — complete prior items to unlock next", - "lesson_download": "Lessons DownLoad", - "available": "Make the Lesson available for Download in pdf for registered students", - "course_download": "Course Downlaod", - "course_avail": "Make the Course available for Download for registered students", + "lesson_download": "Lesson download", + "available": "Let registered students download lessons as PDF.", + "course_download": "Course download", + "course_avail": "Download a copy of this course.", "download": "Download", "allow": "Allow New Students", "access": "Allow new students to access this course", @@ -3651,8 +3652,10 @@ "published": "Published", "unpublished": "Unpublished", "certificate_deadline_required": "Set Certificate Deadline First", - "certificate_deadline_required_description": "Compliance courses require a certificate deadline before learners can be enrolled. Please set a deadline in the certificate settings before publishing.", - "go_to_certificate_settings": "Go to Certificate Settings", + "certificate_deadline_required_description": "Compliance courses require a certificate deadline before learners can be enrolled. Please set a deadline in the course settings before publishing.", + "go_to_completion_deadline": "Go to Completion Deadline", + "completion_deadline_label": "Course completion deadline", + "completion_deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.", "locked_content": { "title": "{count} course item(s) are locked", "description": "Enrolled students won't be able to take locked lessons or exercises until you unlock them.", diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index c446baec..32a092c9 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -792,10 +792,10 @@ "progression_mode_description": "Controla si los estudiantes pueden acceder a todo el contenido desbloqueado o deben completar los elementos en orden.", "progression_mode_free": "Libre — el contenido desbloqueado es accesible", "progression_mode_sequential": "Secuencial — completar elementos previos para desbloquear el siguiente", - "lesson_download": "Lecciones DescargarCargar", + "lesson_download": "Descarga de lecciones", "available": "Haga que la lección esté disponible para descargar en pdf para estudiantes registrados", "course_download": "Descarga del curso", - "course_avail": "Hacer que el curso esté disponible para su descarga para los estudiantes registrados", + "course_avail": "Descarga una copia de este curso.", "download": "Descargar", "allow": "Permitir nuevos estudiantes", "access": "Permitir que nuevos estudiantes accedan a este curso", @@ -860,8 +860,10 @@ "unlock_all": "Desbloquear todo" }, "certificate_deadline_required": "Establecer la fecha límite del certificado primero", - "certificate_deadline_required_description": "Los cursos de cumplimiento requieren una fecha límite para el certificado antes de que los estudiantes puedan inscribirse. Establezca una fecha límite en la configuración del certificado antes de publicarlo.", - "go_to_certificate_settings": "Vaya a Configuración de certificado" + "certificate_deadline_required_description": "Los cursos de cumplimiento requieren una fecha límite para el certificado antes de que los estudiantes puedan inscribirse. Establezca una fecha límite en la configuración del curso antes de publicarlo.", + "go_to_completion_deadline": "Ir a la fecha límite de finalización", + "completion_deadline_label": "Fecha límite de finalización del curso", + "completion_deadline_helper": "Los estudiantes deben alcanzar el porcentaje de finalización antes de esta fecha (hora local). Déjelo vacío sin fecha límite." }, "analytics": { "title": "Analíticas" @@ -2479,8 +2481,9 @@ "email_message_label": "Mensaje de correo electrónico", "email_message_placeholder": "por ej. Estamos orgullosos de tu progreso: ¡sigue aprendiendo!", "final_exercise_multiple_attempts_note": "A los estudiantes se les permitirán intentos ilimitados en el ejercicio seleccionado.", - "deadline_required": "Se requiere una fecha límite para el certificado cuando los certificados se pueden descargar.", - "deadline_required_enroll": "Los cursos de cumplimiento requieren una fecha límite para el certificado antes de que los estudiantes puedan inscribirse." + "deadline_required": "Los cursos de cumplimiento requieren una fecha límite de finalización antes de poder publicarse.", + "deadline_required_enroll": "Los cursos de cumplimiento requieren una fecha límite para el certificado antes de que los estudiantes puedan inscribirse.", + "deadline_invalid": "La fecha límite de finalización del curso no es válida." }, "creator": { "heading": "¿Qué curso quieres crear?", diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index d1dd77f7..106486c5 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -795,7 +795,7 @@ "lesson_download": "Téléchargement des leçons", "available": "Rendre la leçon disponible en téléchargement en pdf pour les étudiants inscrits", "course_download": "Téléchargement du cours", - "course_avail": "Rendre le cours disponible en téléchargement pour les étudiants inscrits", + "course_avail": "Téléchargez une copie de ce cours.", "download": "Télécharger", "allow": "Autoriser les nouveaux étudiants", "access": "Autoriser les nouveaux étudiants à accéder à ce cours", @@ -860,8 +860,10 @@ "unlock_all": "Débloquez tout" }, "certificate_deadline_required": "Définir d'abord la date limite du certificat", - "certificate_deadline_required_description": "Les cours de conformité nécessitent une date limite de certificat avant que les apprenants puissent être inscrits. Veuillez définir une date limite dans les paramètres du certificat avant de publier.", - "go_to_certificate_settings": "Accédez aux paramètres du certificat" + "certificate_deadline_required_description": "Les cours de conformité nécessitent une date limite de certificat avant que les apprenants puissent être inscrits. Veuillez définir une date limite dans les paramètres du cours avant de publier.", + "go_to_completion_deadline": "Accéder à la date limite d'achèvement", + "completion_deadline_label": "Date limite de fin de cours", + "completion_deadline_helper": "Les étudiants doivent atteindre le pourcentage d'achèvement avant cette date/heure (heure locale). Laisser vide sans date limite." }, "analytics": { "title": "Analytique" @@ -2479,8 +2481,9 @@ "email_message_label": "Message électronique", "email_message_placeholder": "par ex. Nous sommes fiers de vos progrès – continuez à apprendre !", "final_exercise_multiple_attempts_note": "Les étudiants auront droit à des tentatives illimitées sur l'exercice sélectionné.", - "deadline_required": "La date limite du certificat est requise lorsque les certificats sont téléchargeables.", - "deadline_required_enroll": "Les cours de conformité nécessitent une date limite de certificat avant que les apprenants puissent être inscrits." + "deadline_required": "Les cours de conformité nécessitent une date limite d'achèvement avant de pouvoir être publiés.", + "deadline_required_enroll": "Les cours de conformité nécessitent une date limite de certificat avant que les apprenants puissent être inscrits.", + "deadline_invalid": "La date limite d'achèvement du cours n'est pas valide." }, "creator": { "heading": "Quel cours souhaitez-vous créer ?", diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index a6d2712a..26427360 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -795,7 +795,7 @@ "lesson_download": "पाठ डाउनलोड करें", "available": "पंजीकृत छात्रों के लिए पाठ को पीडीएफ में डाउनलोड के लिए उपलब्ध कराएं", "course_download": "कोर्स डाउनलोड करें", - "course_avail": "पंजीकृत छात्रों के लिए पाठ्यक्रम को डाउनलोड के लिए उपलब्ध कराएं", + "course_avail": "इस पाठ्यक्रम की एक प्रति डाउनलोड करें।", "download": "डाउनलोड करें", "allow": "नए छात्रों को अनुमति दें", "access": "नए छात्रों को इस पाठ्यक्रम तक पहुंचने की अनुमति दें", @@ -860,8 +860,10 @@ "unlock_all": "सबको अनलॉक करें" }, "certificate_deadline_required": "पहले प्रमाणपत्र की समय सीमा निर्धारित करें", - "certificate_deadline_required_description": "अनुपालन पाठ्यक्रमों में शिक्षार्थियों के नामांकन से पहले प्रमाणपत्र की समय सीमा की आवश्यकता होती है। कृपया प्रकाशन से पहले प्रमाणपत्र सेटिंग में एक समय सीमा निर्धारित करें।", - "go_to_certificate_settings": "प्रमाणपत्र सेटिंग पर जाएं" + "certificate_deadline_required_description": "अनुपालन पाठ्यक्रमों में शिक्षार्थियों के नामांकन से पहले प्रमाणपत्र की समय सीमा की आवश्यकता होती है। कृपया प्रकाशन से पहले पाठ्यक्रम सेटिंग में एक समय सीमा निर्धारित करें।", + "go_to_completion_deadline": "समापन समय सीमा पर जाएं", + "completion_deadline_label": "कोर्स पूरा करने की समय सीमा", + "completion_deadline_helper": "छात्रों को इस दिनांक समय (स्थानीय समय) से पहले पूर्णता प्रतिशत पूरा करना होगा। बिना किसी समय सीमा के खाली छोड़ दें." }, "analytics": { "title": "विश्लेषिकी" @@ -2479,8 +2481,9 @@ "email_message_label": "ईमेल संदेश", "email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!", "final_exercise_multiple_attempts_note": "छात्रों को चयनित अभ्यास पर असीमित प्रयासों की अनुमति दी जाएगी।", - "deadline_required": "प्रमाणपत्र डाउनलोड करने योग्य होने पर प्रमाणपत्र की समय सीमा आवश्यक है।", - "deadline_required_enroll": "अनुपालन पाठ्यक्रमों में शिक्षार्थियों के नामांकन से पहले प्रमाणपत्र की समय सीमा की आवश्यकता होती है।" + "deadline_required": "अनुपालन पाठ्यक्रमों को प्रकाशित करने से पहले पूरा करने की समय सीमा आवश्यक है।", + "deadline_required_enroll": "अनुपालन पाठ्यक्रमों में शिक्षार्थियों के नामांकन से पहले प्रमाणपत्र की समय सीमा की आवश्यकता होती है।", + "deadline_invalid": "पाठ्यक्रम पूरा करने की समय सीमा अमान्य है।" }, "creator": { "heading": "आप कौन सा कोर्स बनाना चाहते हैं?", diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index c1c4a8e2..f7eec0c3 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -795,7 +795,7 @@ "lesson_download": "Lekcje do pobrania", "available": "Udostępnij lekcję do pobrania w formacie PDF zarejestrowanym studentom", "course_download": "Pobieranie kursu", - "course_avail": "Udostępnij kurs do pobrania zarejestrowanym studentom", + "course_avail": "Pobierz kopię tego kursu.", "download": "Pobierz", "allow": "Zezwalaj na nowych uczniów", "access": "Zezwól nowym studentom na dostęp do tego kursu", @@ -860,8 +860,10 @@ "unlock_all": "Odblokuj wszystko" }, "certificate_deadline_required": "Najpierw ustal termin wydania certyfikatu", - "certificate_deadline_required_description": "Kursy zgodności wymagają wystawienia certyfikatu przed zapisaniem się na nie uczestników. Przed publikacją ustaw termin w ustawieniach certyfikatu.", - "go_to_certificate_settings": "Przejdź do Ustawień certyfikatu" + "certificate_deadline_required_description": "Kursy zgodności wymagają wystawienia certyfikatu przed zapisaniem się na nie uczestników. Przed publikacją ustaw termin w ustawieniach kursu.", + "go_to_completion_deadline": "Przejdź do terminu ukończenia", + "completion_deadline_label": "Termin zakończenia kursu", + "completion_deadline_helper": "Studenci muszą osiągnąć procent ukończenia przed tą datą (czasu lokalnego). Pozostaw puste bez terminu." }, "analytics": { "title": "Analityka" @@ -2479,8 +2481,9 @@ "email_message_label": "Wiadomość e-mail", "email_message_placeholder": "np. Jesteśmy dumni z Twoich postępów — ucz się dalej!", "final_exercise_multiple_attempts_note": "Studenci będą mieli nieograniczoną liczbę prób wykonania wybranego ćwiczenia.", - "deadline_required": "W przypadku możliwości pobrania certyfikatów wymagany jest termin wydania certyfikatu.", - "deadline_required_enroll": "Kursy zgodności wymagają wystawienia certyfikatu przed zapisaniem się na nie uczestników." + "deadline_required": "Kursy zgodności wymagają terminu ukończenia przed publikacją.", + "deadline_required_enroll": "Kursy zgodności wymagają wystawienia certyfikatu przed zapisaniem się na nie uczestników.", + "deadline_invalid": "Termin ukończenia kursu jest nieprawidłowy." }, "creator": { "heading": "Jaki kurs chcesz stworzyć?", diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index aa994e01..962f2404 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -792,10 +792,10 @@ "progression_mode_description": "Controle se os alunos podem acessar todo o conteúdo desbloqueado ou devem concluir os itens em ordem.", "progression_mode_free": "Livre — conteúdo desbloqueado é acessível", "progression_mode_sequential": "Sequencial — concluir itens anteriores para desbloquear o próximo", - "lesson_download": "Lições baixadas", + "lesson_download": "Download das lições", "available": "Disponibilizar a Lição para Download em PDF para alunos matriculados", "course_download": "Download do curso", - "course_avail": "Disponibilizar o Curso para Download para alunos cadastrados", + "course_avail": "Baixe uma cópia deste curso.", "download": "Baixar", "allow": "Permitir novos alunos", "access": "Permitir que novos alunos acessem este curso", @@ -860,8 +860,10 @@ "unlock_all": "Desbloquear tudo" }, "certificate_deadline_required": "Defina o prazo do certificado primeiro", - "certificate_deadline_required_description": "Os cursos de conformidade exigem um prazo de certificação antes que os alunos possam ser matriculados. Defina um prazo nas configurações do certificado antes de publicar.", - "go_to_certificate_settings": "Vá para configurações de certificado" + "certificate_deadline_required_description": "Os cursos de conformidade exigem um prazo de certificação antes que os alunos possam ser matriculados. Defina um prazo nas configurações do curso antes de publicar.", + "go_to_completion_deadline": "Ir para o prazo de conclusão", + "completion_deadline_label": "Prazo para conclusão do curso", + "completion_deadline_helper": "Os alunos devem atingir o percentual de conclusão antes desta data e hora (horário local). Deixe em branco para não ter prazo." }, "analytics": { "title": "Análises" @@ -2479,8 +2481,9 @@ "email_message_label": "Mensagem de e-mail", "email_message_placeholder": "por exemplo Estamos orgulhosos do seu progresso – continue aprendendo!", "final_exercise_multiple_attempts_note": "Os alunos terão permissão para tentativas ilimitadas no exercício selecionado.", - "deadline_required": "O prazo do certificado é necessário quando os certificados podem ser baixados.", - "deadline_required_enroll": "Os cursos de conformidade exigem um prazo de certificação antes que os alunos possam ser matriculados." + "deadline_required": "Os cursos de conformidade exigem um prazo de conclusão antes de serem publicados.", + "deadline_required_enroll": "Os cursos de conformidade exigem um prazo de certificação antes que os alunos possam ser matriculados.", + "deadline_invalid": "O prazo de conclusão do curso é inválido." }, "creator": { "heading": "Qual curso você deseja criar?", diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 9a35710d..c4743b90 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -792,10 +792,10 @@ "progression_mode_description": "Управляйте доступом студентов ко всему разблокированному контенту или требуйте прохождения по порядку.", "progression_mode_free": "Свободный — разблокированный контент доступен", "progression_mode_sequential": "Последовательный — завершите предыдущие элементы, чтобы открыть следующий", - "lesson_download": "Уроки СкачатьСкачать", + "lesson_download": "Скачивание уроков", "available": "Сделайте урок доступным для скачивания в формате pdf для зарегистрированных студентов.", "course_download": "Загрузка курса", - "course_avail": "Сделать курс доступным для скачивания зарегистрированным студентам", + "course_avail": "Скачайте копию этого курса.", "download": "Скачать", "allow": "Разрешить новым студентам", "access": "Разрешить новым студентам доступ к этому курсу", @@ -860,8 +860,10 @@ "unlock_all": "Разблокировать все" }, "certificate_deadline_required": "Сначала установите срок действия сертификата", - "certificate_deadline_required_description": "Для прохождения курсов по соблюдению требований требуется крайний срок получения сертификата, прежде чем учащиеся смогут быть зачислены. Перед публикацией установите крайний срок в настройках сертификата.", - "go_to_certificate_settings": "Зайдите в настройки сертификата" + "certificate_deadline_required_description": "Для прохождения курсов по соблюдению требований требуется крайний срок получения сертификата, прежде чем учащиеся смогут быть зачислены. Перед публикацией установите крайний срок в настройках курса.", + "go_to_completion_deadline": "Перейти к сроку завершения", + "completion_deadline_label": "Срок завершения курса", + "completion_deadline_helper": "Учащиеся должны достичь процента завершения до этой даты и времени (по местному времени). Оставьте пустым, если срок не установлен." }, "analytics": { "title": "Аналитика" @@ -2479,8 +2481,9 @@ "email_message_label": "Сообщение электронной почты", "email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!", "final_exercise_multiple_attempts_note": "Студентам будет разрешено неограниченное количество попыток выполнения выбранного упражнения.", - "deadline_required": "Крайний срок сертификата требуется, если сертификаты можно загрузить.", - "deadline_required_enroll": "Для прохождения курсов по соблюдению требований требуется крайний срок получения сертификата, прежде чем учащиеся смогут быть зачислены." + "deadline_required": "Для публикации курсов по соблюдению требований необходимо указать срок завершения.", + "deadline_required_enroll": "Для прохождения курсов по соблюдению требований требуется крайний срок получения сертификата, прежде чем учащиеся смогут быть зачислены.", + "deadline_invalid": "Срок завершения курса указан неверно." }, "creator": { "heading": "Какой курс вы хотите создать?", diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 636008de..70fe4013 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -795,7 +795,7 @@ "lesson_download": "Bài học Tải về", "available": "Cung cấp Bài học để tải xuống dưới dạng pdf cho học viên đã đăng ký", "course_download": "Tải xuống khóa học", - "course_avail": "Cung cấp Khóa học để tải xuống cho sinh viên đã đăng ký", + "course_avail": "Tải xuống một bản sao của khóa học này.", "download": "Tải xuống", "allow": "Cho phép sinh viên mới", "access": "Cho phép sinh viên mới truy cập khóa học này", @@ -860,8 +860,10 @@ "unlock_all": "Mở khóa tất cả" }, "certificate_deadline_required": "Đặt thời hạn chứng chỉ đầu tiên", - "certificate_deadline_required_description": "Các khóa học tuân thủ yêu cầu thời hạn cấp chứng chỉ trước khi người học có thể đăng ký. Vui lòng đặt thời hạn trong cài đặt chứng chỉ trước khi xuất bản.", - "go_to_certificate_settings": "Đi tới Cài đặt chứng chỉ" + "certificate_deadline_required_description": "Các khóa học tuân thủ yêu cầu thời hạn cấp chứng chỉ trước khi người học có thể đăng ký. Vui lòng đặt thời hạn trong cài đặt khóa học trước khi xuất bản.", + "go_to_completion_deadline": "Đi tới thời hạn hoàn thành", + "completion_deadline_label": "Thời hạn hoàn thành khóa học", + "completion_deadline_helper": "Học sinh phải đáp ứng tỷ lệ hoàn thành trước ngày này (giờ địa phương). Để trống không có thời hạn." }, "analytics": { "title": "Phân tích" @@ -2479,8 +2481,9 @@ "email_message_label": "Tin nhắn email", "email_message_placeholder": "ví dụ: Chúng tôi tự hào về sự tiến bộ của bạn - hãy tiếp tục học hỏi!", "final_exercise_multiple_attempts_note": "Học sinh sẽ được phép thử không giới hạn bài tập đã chọn.", - "deadline_required": "Thời hạn cấp chứng chỉ là bắt buộc khi có thể tải xuống chứng chỉ.", - "deadline_required_enroll": "Các khóa học tuân thủ yêu cầu thời hạn cấp chứng chỉ trước khi người học có thể đăng ký." + "deadline_required": "Các khóa học tuân thủ yêu cầu thời hạn hoàn thành trước khi có thể xuất bản.", + "deadline_required_enroll": "Các khóa học tuân thủ yêu cầu thời hạn cấp chứng chỉ trước khi người học có thể đăng ký.", + "deadline_invalid": "Thời hạn hoàn thành khóa học không hợp lệ." }, "creator": { "heading": "Bạn muốn tạo khóa học nào?", diff --git a/packages/core/src/services/course/course.ts b/packages/core/src/services/course/course.ts index 259afb89..e257e0c2 100644 --- a/packages/core/src/services/course/course.ts +++ b/packages/core/src/services/course/course.ts @@ -6,7 +6,7 @@ import { createCourseSections, deleteCourse as deleteCourseQuery, getCourseProgress as getCourseProgressQuery, - getCourseTypeById, + getCourseById, getCourseWithRelations, updateCourse as updateCourseQuery, updateLessonsSectionId @@ -28,6 +28,7 @@ import { getStudentCourseProgressImpactCounts } from '@cio/db/queries/course/res import { getCourseGroupId } from '@cio/db/queries/course/people'; import { ContentType, ROLE } from '@cio/utils/constants'; +import { isPublishedComplianceMissingDeadline, resolveCourseCertificateDeadline } from '@cio/utils/functions'; import type { TCourse } from '@cio/db/types'; import type { TCourseCreate } from '@cio/utils/validation/course'; import { db } from '@cio/db/drizzle'; @@ -338,16 +339,38 @@ export async function updateCourse(courseId: string, data: Partial) { slug: data.slug }; - if (data.type !== undefined) { - const currentType = await getCourseTypeById(courseId); + const [currentCourse] = await getCourseById(courseId); + if (!currentCourse) { + throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404); + } + if (data.type !== undefined) { await guardCourseTypeTransition({ courseId, - currentType: currentType ?? null, + currentType: currentCourse.type ?? null, nextType: data.type }); } + const nextType = data.type ?? currentCourse.type; + const nextIsPublished = data.isPublished ?? currentCourse.isPublished; + const nextDeadline = resolveCourseCertificateDeadline(currentCourse.certificate?.deadline, data.certificate); + + if ( + isPublishedComplianceMissingDeadline({ + type: nextType, + isPublished: nextIsPublished, + deadline: nextDeadline + }) + ) { + throw new AppError( + 'Compliance courses require a completion deadline before they can be published', + ErrorCodes.COMPLIANCE_DEADLINE_REQUIRED, + 400, + 'certificate.deadline' + ); + } + if (data.certificate?.requiredExerciseId) { const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId); if (!ok) { diff --git a/packages/core/src/services/course/go-live-readiness.ts b/packages/core/src/services/course/go-live-readiness.ts index 3ebca08d..3ac88dc2 100644 --- a/packages/core/src/services/course/go-live-readiness.ts +++ b/packages/core/src/services/course/go-live-readiness.ts @@ -3,7 +3,7 @@ import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/querie import { getCourseOrganizationId } from '@cio/db/queries/tag'; import { getOrganizationById } from '@cio/db/queries/organization'; import type { TCourse, TOrganization } from '@cio/db/types'; -import { generateSlug } from '@cio/utils/functions'; +import { generateSlug, isPublishedComplianceMissingDeadline } from '@cio/utils/functions'; import { ContentType } from '@cio/utils/constants'; import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course'; @@ -32,7 +32,17 @@ export type CourseGoLiveReadiness = { type CourseReadinessInput = { course: Pick< TCourse, - 'id' | 'title' | 'description' | 'overview' | 'slug' | 'logo' | 'bannerImage' | 'metadata' | 'type' | 'cost' + | 'id' + | 'title' + | 'description' + | 'overview' + | 'slug' + | 'logo' + | 'bannerImage' + | 'metadata' + | 'type' + | 'cost' + | 'certificate' >; contentItems: CourseContentItemRow[]; suggestedSlug?: string; @@ -190,6 +200,22 @@ export function evaluateCourseGoLiveReadiness(input: CourseReadinessInput): Cour } if (course.type === 'COMPLIANCE') { + if ( + isPublishedComplianceMissingDeadline({ + type: course.type, + isPublished: true, + deadline: course.certificate?.deadline + }) + ) { + blockers.push( + buildIssue( + 'COMPLIANCE_DEADLINE_MISSING', + 'Compliance courses require a completion deadline before they can be published.', + 'course.certificate.deadline' + ) + ); + } + warnings.push( buildIssue('COMPLIANCE_REVIEW_RECOMMENDED', 'Review compliance settings before publishing.', 'course.compliance') ); diff --git a/packages/utils/src/constants/error-codes.ts b/packages/utils/src/constants/error-codes.ts index 7a75e770..3bfb419e 100644 --- a/packages/utils/src/constants/error-codes.ts +++ b/packages/utils/src/constants/error-codes.ts @@ -108,6 +108,7 @@ export const ErrorCodes = { SLUG_IN_USE: 'SLUG_IN_USE', QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE: 'QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE', PUBLIC_COURSE_CONVERSION_BLOCKED: 'PUBLIC_COURSE_CONVERSION_BLOCKED', + COMPLIANCE_DEADLINE_REQUIRED: 'COMPLIANCE_DEADLINE_REQUIRED', // Lesson errors LESSON_NOT_FOUND: 'LESSON_NOT_FOUND', diff --git a/packages/utils/src/functions/compliance-deadline.ts b/packages/utils/src/functions/compliance-deadline.ts new file mode 100644 index 00000000..4afc1855 --- /dev/null +++ b/packages/utils/src/functions/compliance-deadline.ts @@ -0,0 +1,38 @@ +/** + * A published COMPLIANCE course must have a completion deadline. + * Draft courses, non-compliance types, and downloadable certificates do not require one. + */ +export function isPublishedComplianceMissingDeadline(params: { + type: string | null | undefined; + isPublished: boolean | null | undefined; + deadline: string | null | undefined; +}): boolean { + if (params.isPublished !== true) { + return false; + } + + if (params.type !== 'COMPLIANCE') { + return false; + } + + return !params.deadline?.trim(); +} + +/** + * Course updates replace the certificate JSON value when `certificate` is sent. + * If the patch omits `deadline`, the stored deadline is cleared. + */ +export function resolveCourseCertificateDeadline( + currentDeadline: string | null | undefined, + certificatePatch: { deadline?: string | null } | null | undefined +): string | null | undefined { + if (certificatePatch === undefined) { + return currentDeadline; + } + + if (certificatePatch === null) { + return null; + } + + return certificatePatch.deadline ?? null; +} diff --git a/packages/utils/src/functions/index.ts b/packages/utils/src/functions/index.ts index d54b559c..88d51b80 100644 --- a/packages/utils/src/functions/index.ts +++ b/packages/utils/src/functions/index.ts @@ -1,4 +1,5 @@ export * from './array'; +export * from './compliance-deadline'; export * from './course-progression'; export * from './currency'; export * from './fileValidation'; diff --git a/packages/utils/tests/compliance-deadline.test.ts b/packages/utils/tests/compliance-deadline.test.ts new file mode 100644 index 00000000..5486fc36 --- /dev/null +++ b/packages/utils/tests/compliance-deadline.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { + isPublishedComplianceMissingDeadline, + resolveCourseCertificateDeadline +} from '../src/functions/compliance-deadline'; + +describe('isPublishedComplianceMissingDeadline', () => { + it('is false for unpublished compliance courses', () => { + expect( + isPublishedComplianceMissingDeadline({ + type: 'COMPLIANCE', + isPublished: false, + deadline: null + }) + ).toBe(false); + }); + + it('is false for published non-compliance courses without a deadline', () => { + expect( + isPublishedComplianceMissingDeadline({ + type: 'SELF_PACED', + isPublished: true, + deadline: null + }) + ).toBe(false); + }); + + it('is true for published compliance courses without a deadline', () => { + expect( + isPublishedComplianceMissingDeadline({ + type: 'COMPLIANCE', + isPublished: true, + deadline: null + }) + ).toBe(true); + }); + + it('treats blank deadlines as missing', () => { + expect( + isPublishedComplianceMissingDeadline({ + type: 'COMPLIANCE', + isPublished: true, + deadline: ' ' + }) + ).toBe(true); + }); + + it('is false when a published compliance course has a deadline', () => { + expect( + isPublishedComplianceMissingDeadline({ + type: 'COMPLIANCE', + isPublished: true, + deadline: '2026-12-31T23:59:00.000Z' + }) + ).toBe(false); + }); +}); + +describe('resolveCourseCertificateDeadline', () => { + it('keeps the stored deadline when certificate is not in the patch', () => { + expect(resolveCourseCertificateDeadline('2026-12-31T23:59:00.000Z', undefined)).toBe('2026-12-31T23:59:00.000Z'); + }); + + it('clears the deadline when the certificate patch omits it', () => { + expect(resolveCourseCertificateDeadline('2026-12-31T23:59:00.000Z', {})).toBeNull(); + }); + + it('uses the patched deadline when provided', () => { + expect(resolveCourseCertificateDeadline('2026-01-01T00:00:00.000Z', { deadline: '2026-12-31T23:59:00.000Z' })).toBe( + '2026-12-31T23:59:00.000Z' + ); + }); +});