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
@@ -1,11 +1,14 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { Button } from '@cio/ui/base/button';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { Search } from '@cio/ui/custom/search';
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { orgApi } from '$features/org/api/org.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import type { OrgStudent } from './types';
@@ -20,29 +23,39 @@
let { courseId, students, isLoading = false, onAssigned }: Props = $props();
let searchValue = $state('');
let isSearching = $state(false);
let lastSearchValue = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let activeSearchRequestId = $state(0);
const selectedProfileIds = new SvelteSet<string>();
let sendEmail = $state(true);
let isSubmitting = $state(false);
const SEARCH_DEBOUNCE_MS = 350;
const filteredStudents = $derived.by(() => {
const query = searchValue.trim().toLowerCase();
const sortedStudents = [...students].sort((a, b) =>
const sortedStudents = $derived.by(() =>
[...students].sort((a, b) =>
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
);
if (!query) {
return sortedStudents;
}
return sortedStudents.filter((student) => {
const name = student.name?.toLowerCase() || '';
const email = student.email?.toLowerCase() || '';
return name.includes(query) || email.includes(query);
});
});
)
);
const showLoadingState = $derived(isLoading || isSearching);
const emptyMessage = $derived(
searchValue.trim()
? $t('course.navItem.people.invite_modal.no_matching_existing_students')
: $t('course.navItem.people.invite_modal.no_existing_students')
);
const selectedCount = $derived(selectedProfileIds.size);
const multiSelectItems = $derived(
sortedStudents
.filter((s): s is OrgStudent & { profileId: string } => Boolean(s.profileId))
.map((s) => ({
id: s.profileId,
label: s.name || s.email,
description: s.email
}))
);
function toggleStudent(profileId: string) {
if (selectedProfileIds.has(profileId)) {
selectedProfileIds.delete(profileId);
@@ -51,8 +64,70 @@
}
}
async function searchStudents(value: string) {
const normalizedValue = value.trim();
if (normalizedValue === lastSearchValue) {
return;
}
lastSearchValue = normalizedValue;
const orgId = $currentOrg.id;
if (!orgId) {
return;
}
const requestId = activeSearchRequestId + 1;
activeSearchRequestId = requestId;
isSearching = true;
try {
await orgApi.getOrgAudience(
orgId,
{
...DEFAULT_ORG_AUDIENCE_QUERY,
search: normalizedValue || undefined
},
{
abortPrevious: true
}
);
} finally {
if (requestId === activeSearchRequestId) {
isSearching = false;
}
}
}
function handleSearchValueChange(value: string) {
searchValue = value;
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (!value.trim()) {
void searchStudents(value);
return;
}
searchTimeout = setTimeout(() => {
void searchStudents(value);
}, SEARCH_DEBOUNCE_MS);
}
onDestroy(() => {
orgApi.cancelAudienceRequest();
if (!searchTimeout) {
return;
}
clearTimeout(searchTimeout);
});
async function assignStudents() {
if (!courseId) return;
if (selectedProfileIds.size === 0) {
snackbar.error('course.navItem.people.invite_modal.select_at_least_one_student');
return;
@@ -82,56 +157,33 @@
}
</script>
<div class="rounded-md border p-4">
<div class="space-y-1">
<p class="text-base font-semibold">{$t('course.navItem.people.invite_modal.existing_students_title')}</p>
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.existing_students_description')}
</p>
</div>
<div class="mt-4 space-y-4">
<Search placeholder={$t('course.navItem.people.invite_modal.existing_students_search')} bind:value={searchValue} />
{#if selectedCount > 0}
<p class="ui:text-muted-foreground text-sm">{$t('audience.selected_count', { count: selectedCount })}</p>
{/if}
<div class="max-h-72 space-y-2 overflow-y-auto rounded-md border p-3">
{#if isLoading}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.loading_existing_students')}
</p>
{:else if students.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.people.invite_modal.no_existing_students')}</p>
{:else if filteredStudents.length === 0}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.no_matching_existing_students')}
</p>
{:else}
{#each filteredStudents as student (student.profileId)}
<div
class="ui:hover:bg-muted/40 rounded-md px-2 py-1"
role="button"
tabindex="0"
onclick={() => student.profileId && toggleStudent(student.profileId)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (student.profileId) toggleStudent(student.profileId);
}
}}
>
<CheckboxField
name={`existing-student-${student.profileId}`}
label={student.name || student.email}
checked={student.profileId ? selectedProfileIds.has(student.profileId) : false}
>
<span class="ui:text-muted-foreground text-sm">{student.email}</span>
</CheckboxField>
</div>
{/each}
{/if}
<div class="">
<div class="space-y-2">
<div class="max-h-72 overflow-y-auto">
<MultiSelectList
class="border-0"
{emptyMessage}
items={multiSelectItems}
isLoading={showLoadingState}
isSelected={(id) => selectedProfileIds.has(id)}
onSearchValueChange={handleSearchValueChange}
onToggle={(id) => toggleStudent(id)}
namePrefix="existing-student"
listClass="max-h-60"
searchPlaceholder={$t('course.navItem.people.invite_modal.existing_students_search')}
bind:searchValue
>
{#snippet headingSnippet()}
<p class="ui:text-sm ui:font-medium">
{$t('course.navItem.people.invite_modal.existing_students_title')} ({multiSelectItems.length})
{#if selectedCount > 0}
<span class="ui:text-muted-foreground text-xs"
>{$t('audience.selected_count', { count: selectedCount })}</span
>
{/if}
</p>
{/snippet}
</MultiSelectList>
</div>
<CheckboxField
@@ -12,6 +12,7 @@
import { currentOrg } from '$lib/utils/store/org';
import { peopleApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte';
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
import type { OrgTeamMember } from '$lib/utils/types/org';
import type { OrgStudent, Tutor } from './types';
@@ -75,10 +76,11 @@
function loadStudents(orgId: string | undefined) {
if (!orgId) return;
untrack(async () => {
isLoadingStudents = true;
try {
await orgApi.getOrgAudience(orgId);
await orgApi.getOrgAudience(orgId, DEFAULT_ORG_AUDIENCE_QUERY, { abortPrevious: true });
} finally {
isLoadingStudents = false;
}
@@ -125,7 +127,7 @@
if (!open) closeModal();
}}
>
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-6xl overflow-y-auto">
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.people.invite_modal.title')}</Dialog.Title>
</Dialog.Header>