feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -0,0 +1,68 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
interface Props {
onInvite: (recipientCsv: string, sendEmail: boolean) => void | Promise<void>;
titleKey?: string;
descriptionKey?: string;
submitKey?: string;
noEmailsKey?: string;
}
let {
onInvite,
titleKey = 'course.navItem.people.invite_modal.invite_new_students_title',
descriptionKey = 'course.navItem.people.invite_modal.invite_new_students_description',
submitKey = 'course.navItem.people.invite_modal.invite_new_students',
noEmailsKey = 'audience.import.snackbar_no_emails'
}: Props = $props();
let recipientCsv = $state('');
let sendInviteEmails = $state(true);
let isSubmitting = $state(false);
async function inviteStudents() {
if (!recipientCsv.trim()) {
snackbar.error(noEmailsKey);
return;
}
isSubmitting = true;
try {
await onInvite(recipientCsv, sendInviteEmails);
recipientCsv = '';
} finally {
isSubmitting = false;
}
}
</script>
<div class="rounded-md border p-4">
<p class="mb-2 text-base font-semibold">{$t(titleKey)}</p>
<p class="ui:text-muted-foreground mb-2 text-sm">
{$t(descriptionKey)}
</p>
<TextareaField
label={$t('audience.import.emails_label')}
bind:value={recipientCsv}
rows={4}
className="w-full"
placeholder={$t('audience.import.emails_placeholder')}
/>
<CheckboxField
className="mt-2"
name="send-invite-emails"
label={$t('audience.import.send_email')}
bind:checked={sendInviteEmails}
/>
<div class="mt-3">
<Button variant="secondary" onclick={inviteStudents} loading={isSubmitting}>
{$t(submitKey)}
</Button>
</div>
</div>
@@ -0,0 +1,151 @@
<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 { MultiSelectList } from '@cio/ui/custom/multi-select-list';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import type { OrgStudent } from '../utils/types';
interface Props {
students: OrgStudent[];
isLoading?: boolean;
onSearchValueChange: (value: string) => void | Promise<void>;
onAssign: (profileIds: string[], sendEmail: boolean) => void | Promise<void>;
headingKey?: string;
emptyMatchingKey?: string;
emptyDefaultKey?: string;
searchPlaceholderKey?: string;
notifyLabelKey?: string;
submitKey?: string;
selectAtLeastOneKey?: string;
}
let {
students,
isLoading = false,
onSearchValueChange,
onAssign,
headingKey = 'course.navItem.people.invite_modal.existing_students_title',
emptyMatchingKey = 'course.navItem.people.invite_modal.no_matching_existing_students',
emptyDefaultKey = 'course.navItem.people.invite_modal.no_existing_students',
searchPlaceholderKey = 'course.navItem.people.invite_modal.existing_students_search',
notifyLabelKey = 'course.navItem.people.invite_modal.notify_existing_students',
submitKey = 'course.navItem.people.invite_modal.grant_access',
selectAtLeastOneKey = 'course.navItem.people.invite_modal.select_at_least_one_student'
}: Props = $props();
let searchValue = $state('');
let sendEmail = $state(true);
let isSubmitting = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
const SEARCH_DEBOUNCE_MS = 350;
const selectedProfileIds = new SvelteSet<string>();
const sortedStudents = $derived.by(() =>
[...students].sort((a, b) =>
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
)
);
const selectedCount = $derived(selectedProfileIds.size);
const emptyMessage = $derived(searchValue.trim() ? $t(emptyMatchingKey) : $t(emptyDefaultKey));
const multiSelectItems = $derived(
sortedStudents
.filter((student): student is OrgStudent & { profileId: string } => Boolean(student.profileId))
.map((student) => ({
id: student.profileId,
label: student.name || student.email,
description: student.email
}))
);
function toggleStudent(profileId: string) {
if (selectedProfileIds.has(profileId)) {
selectedProfileIds.delete(profileId);
return;
}
selectedProfileIds.add(profileId);
}
function handleSearch(value: string) {
searchValue = value;
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (!value.trim()) {
void onSearchValueChange(value);
return;
}
searchTimeout = setTimeout(() => {
void onSearchValueChange(value);
}, SEARCH_DEBOUNCE_MS);
}
async function assignStudents() {
if (selectedProfileIds.size === 0) {
snackbar.error(selectAtLeastOneKey);
return;
}
isSubmitting = true;
try {
await onAssign([...selectedProfileIds], sendEmail);
selectedProfileIds.clear();
searchValue = '';
} finally {
isSubmitting = false;
}
}
onDestroy(() => {
if (!searchTimeout) {
return;
}
clearTimeout(searchTimeout);
});
</script>
<div class="space-y-2">
<div class="max-h-72 overflow-y-auto">
<MultiSelectList
class="border-0"
{emptyMessage}
items={multiSelectItems}
{isLoading}
isSelected={(id) => selectedProfileIds.has(id)}
onSearchValueChange={handleSearch}
onToggle={(id) => toggleStudent(id)}
namePrefix="existing-student"
listClass="max-h-60"
searchPlaceholder={$t(searchPlaceholderKey)}
bind:searchValue
>
{#snippet headingSnippet()}
<p class="ui:text-sm ui:font-medium">
{$t(headingKey)} ({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 name="notify-existing-students" label={$t(notifyLabelKey)} bind:checked={sendEmail} />
<div class="flex justify-end">
<Button variant="secondary" onclick={assignStudents} loading={isSubmitting} disabled={isLoading || isSubmitting}>
{$t(submitKey)}
</Button>
</div>
</div>
@@ -0,0 +1,3 @@
export { default as TutorSelectSection } from './tutor-select-section.svelte';
export { default as ExistingStudentsSection } from './existing-students-section.svelte';
export { default as BulkEmailSection } from './bulk-email-section.svelte';
@@ -0,0 +1,91 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { Badge } from '@cio/ui/base/badge';
import * as Select from '@cio/ui/base/select';
import { Spinner } from '@cio/ui/base/spinner';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import type { Tutor } from '../utils/types';
interface Props {
tutors: Tutor[];
selectedIds: string[];
isLoading: boolean;
titleKey?: string;
emptySelectionKey?: string;
loadingKey?: string;
helperTextKey?: string;
helperLinkKey?: string;
helperHref?: string;
}
let {
tutors,
selectedIds = $bindable(),
isLoading,
titleKey = 'course.navItem.people.invite_modal.invite',
emptySelectionKey = 'course.navItem.people.invite_modal.select',
loadingKey = 'course.navItem.people.invite_modal.loading',
helperTextKey = 'course.navItem.people.invite_modal.to_add',
helperLinkKey = 'course.navItem.people.invite_modal.go_to',
helperHref = '#'
}: Props = $props();
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
</script>
<div class="rounded-md border p-4">
<p class="mb-2 text-base font-semibold">{$t(titleKey)}</p>
<Select.Root type="multiple" bind:value={selectedIds} disabled={isLoading}>
<Select.Trigger class="w-full">
<div>
{#if selectedTutors.length > 0}
<div class="flex flex-wrap gap-1">
{#each selectedTutors as tutor}
<Badge variant="secondary" class="flex items-center gap-1">
{tutor.text}
<Button
type="button"
size="icon-sm"
variant="ghost"
onclick={(event) => {
event.stopPropagation();
selectedIds = selectedIds.filter((id) => id !== tutor.id.toString());
}}
class="h-5 w-5 rounded-sm hover:bg-gray-200"
aria-label={`Remove ${tutor.text}`}
>
<XIcon size={14} />
</Button>
</Badge>
{/each}
</div>
{:else}
{$t(emptySelectionKey)}
{/if}
</div>
</Select.Trigger>
<Select.Content>
{#each tutors as tutor}
<Select.Item value={tutor.id.toString()}>
{tutor.text}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if isLoading}
<div class="mt-2 flex items-center gap-2">
<Spinner class="h-4 w-4" />
<span class="text-sm">{$t(loadingKey)}</span>
</div>
{:else}
<span class="mt-2 block text-sm">
{$t(helperTextKey)}
<a href={helperHref} class="ui:text-primary underline">
{$t(helperLinkKey)}
</a>
</span>
{/if}
</div>