feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging

This commit is contained in:
Rotimi Best
2026-03-30 08:24:55 +01:00
parent ff2ad59377
commit 5a8f50f67b
131 changed files with 5643 additions and 2134 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
const isNotFound = $derived(page.status === 404);
console.error('error message', page.error?.message);
console.error('Error message:', page.error?.message);
console.error('Error page:', page.url);
function goHome() {
window.location.href = '/';
@@ -11,6 +11,7 @@
import { Confetti } from '$features/ui';
import { courseApi } from '$features/course/api';
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
import CourseCompletionModal from '$features/course/components/ceritficate/course-completion-modal.svelte';
import { profile } from '$lib/utils/store/user';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
@@ -98,6 +99,7 @@
>
<CourseHeader />
<ContentCreateModal />
<CourseCompletionModal />
{#if !isCourseReady}
<div class="mx-auto flex h-[calc(100vh-56px)] w-full items-center justify-center">
@@ -1,10 +1,140 @@
<script lang="ts">
import { CertificatesPage } from '$features/course/pages';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import type { Course } from '$features/course/utils/types';
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
import { isFreePlan, isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { Button } from '@cio/ui/base/button';
import ZapIcon from '@lucide/svelte/icons/zap';
import { RefreshPageData, UnsavedChanges } from '$features/ui';
import { ZCourseUpdate } from '@cio/utils/validation/course/course';
import { validateWithTranslation } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
import { openUpgradeModal } from '$lib/utils/functions/org';
let errors = $state<Record<string, string>>({});
let hasUnsavedChanges = $state(false);
let savedCertificateState = $state<string | null>(null);
let savedCertificateStateCourseId = $state<string | null>(null);
const certificateTheme = $derived(courseApi.course?.certificate?.theme ?? '');
const currentCertificateState = $derived.by(() => getCertificateStateSnapshot(courseApi.course));
const userRole = $derived.by(() => {
const user = courseApi.group.people.find((person) => person.profileId === $profile.id);
return user ? Number(user.roleId) : null;
});
const canEditCertificates = $derived(!!$isOrgAdmin || (userRole !== null && [1, 2].includes(userRole)));
function getCertificateFormState(course: Course | null) {
if (!course) {
return null;
}
return {
description: course.description ?? '',
certificate: {
isDownloadable: course.certificate?.isDownloadable ?? false,
theme: parseCertificateThemeId(course.certificate?.theme) ?? 'professional',
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,
emailMessage: course.certificate?.emailMessage ?? null
}
};
}
function getCertificateStateSnapshot(course: Course | null) {
const certificateFormState = getCertificateFormState(course);
return certificateFormState ? JSON.stringify(certificateFormState) : null;
}
$effect(() => {
const course = courseApi.course;
const currentSnapshot = getCertificateStateSnapshot(course);
if (!course?.id || !currentSnapshot) {
savedCertificateState = null;
savedCertificateStateCourseId = null;
hasUnsavedChanges = false;
return;
}
if (savedCertificateStateCourseId !== course.id || savedCertificateState === null) {
savedCertificateStateCourseId = course.id;
savedCertificateState = currentSnapshot;
}
hasUnsavedChanges = currentSnapshot !== savedCertificateState;
});
async function saveCertificate() {
if (!courseApi.course) return;
if ($isFreePlan) {
errors = {};
openUpgradeModal();
return;
}
const updatePayload = {
description: courseApi.course.description ?? '',
certificate: {
isDownloadable: courseApi.course.certificate?.isDownloadable ?? false,
theme: certificateTheme || 'professional',
deadline: courseApi.course.certificate?.deadline ?? null,
threshold:
typeof courseApi.course.certificate?.threshold === 'number' ? courseApi.course.certificate.threshold : 100,
requiredExerciseId: courseApi.course.certificate?.requiredExerciseId ?? null,
exerciseMinScorePercent:
typeof courseApi.course.certificate?.exerciseMinScorePercent === 'number'
? courseApi.course.certificate.exerciseMinScorePercent
: courseApi.course.certificate?.requiredExerciseId
? 100
: null,
emailMessage: courseApi.course.certificate?.emailMessage ?? null
}
};
const validated = validateWithTranslation(ZCourseUpdate, updatePayload, 'course');
if ('errors' in validated) {
errors = validated.errors;
return;
}
errors = {};
const updated = await courseApi.update(courseApi.course.id!, validated.data, { showSuccessToast: false });
if (updated) {
savedCertificateState = getCertificateStateSnapshot(courseApi.course);
hasUnsavedChanges = false;
snackbar.success('snackbar.course_settings.success.saved');
}
}
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<UnsavedChanges bind:hasUnsavedChanges />
<Page.Root class="mx-auto flex w-[calc(95vw-var(--sidebar-width))]!">
<Page.Header>
<Page.HeaderContent>
@@ -13,12 +143,26 @@
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<RefreshPageData />
<div class="flex w-full justify-end gap-2">
{#if canEditCertificates}
<Button
onclick={saveCertificate}
loading={courseApi.isLoading}
disabled={courseApi.isLoading || !hasUnsavedChanges}
>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('course.navItem.certificates.save')}
</Button>
{/if}
<RefreshPageData />
</div>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CertificatesPage />
<CertificatesPage {errors} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -5,7 +5,7 @@ export const load = async ({ params, cookies }) => {
const exerciseId = params.exerciseId || '';
if (!courseId || !exerciseId) {
return { courseId, exerciseId, exercise: null, submissions: [], mySubmission: null };
return { courseId, exerciseId, exercise: null, submissions: [], mySubmissions: [] };
}
const headers = getApiHeaders(cookies);
@@ -26,7 +26,7 @@ export const load = async ({ params, cookies }) => {
overviewResult.success && overviewResult.data ? overviewResult.data : { mySubmission: [], allSubmissions: [] };
const mySubmissionData = Array.isArray(overview.mySubmission) ? overview.mySubmission : [];
const mySubmission = mySubmissionData.length > 0 ? mySubmissionData[0] : null;
const mySubmissions = mySubmissionData;
const submissions = Array.isArray(overview.allSubmissions) ? overview.allSubmissions : [];
@@ -35,6 +35,6 @@ export const load = async ({ params, cookies }) => {
exerciseId,
exercise,
submissions,
mySubmission
mySubmissions
};
};
@@ -63,7 +63,8 @@
isDescriptionDirty: false,
isDueByDirty: false,
questions: questions,
totalSubmissions: 0
totalSubmissions: 0,
allowMultipleAttempts: !!exercise.allowMultipleAttempts
});
questionnaireMetaData.update((m) => ({ ...m, exerciseId: data.exerciseId }));
});
@@ -84,7 +85,7 @@
goBack={() => goto(resolve(path, {}))}
isFetching={false}
submissions={data.submissions ?? []}
mySubmission={data.mySubmission ?? null}
mySubmissions={data.mySubmissions ?? []}
/>
{/if}
</Page.Root>
@@ -9,6 +9,7 @@
let settingsComponent: CourseSettingsPage | null = $state(null);
let isSaving = $state(false);
let hasUnsavedChanges = $state(false);
let { data } = $props();
async function handleSave() {
@@ -30,7 +31,7 @@
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
{$t('course.navItem.settings.save')}
</Button>
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
@@ -39,7 +40,7 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<CourseSettingsPage bind:this={settingsComponent} />
<CourseSettingsPage bind:this={settingsComponent} bind:hasUnsavedChanges />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,30 +1,41 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
import {
DEFAULT_ORG_AUDIENCE_QUERY,
getAudienceQueryFromSearchParams,
toAudienceRequestQuery
} from '$lib/features/org/utils/audience-query-utils';
export const load = async ({ parent, cookies }) => {
export const load = async ({ parent, cookies, url }) => {
const { orgId } = await parent();
if (!orgId) {
return {
audience: [],
pagination: null,
query: DEFAULT_ORG_AUDIENCE_QUERY,
courses: []
};
}
const headers = getApiHeaders(cookies, orgId);
const query = getAudienceQueryFromSearchParams(url.searchParams);
const [audienceResponse, coursesResponse] = await Promise.all([
classroomio.organization.audience.$get({}, headers),
classroomio.organization.audience.$get({ query: toAudienceRequestQuery(query)! }, headers),
classroomio.organization.courses.$get({ query: { tags: undefined } }, headers)
]);
const audienceData = await audienceResponse.json();
const audience = audienceData.success ? audienceData.data : [];
const pagination = audienceData.success ? audienceData.pagination : null;
const coursesData = await coursesResponse.json();
const courses = coursesData.success ? coursesData.data : [];
return {
audience,
pagination,
query,
courses
};
};
@@ -17,7 +17,7 @@
isLoading = false;
}
const audienceLength = $derived(data.audience?.length || 0);
const audienceLength = $derived(data.pagination?.total || 0);
</script>
<svelte:head>
@@ -47,7 +47,7 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<AudiencePage audience={data.audience} courses={data.courses} />
<AudiencePage audience={data.audience} pagination={data.pagination} query={data.query} courses={data.courses} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -9,11 +9,6 @@ export const load = async ({ parent, locals, cookies, url }) => {
const { orgId } = await parent();
if (!orgId || !locals.user?.id) {
const loadMs = Math.round((performance.now() - loadStart) * 100) / 100;
console.log(
`[org/[slug]/courses +page.server] load: ${loadMs}ms (skipped: no orgId or user) | PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}`
);
return {
courses: []
};