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
@@ -1,16 +1,18 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import * as Select from '@cio/ui/base/select';
import { Search } from '@cio/ui/custom/search';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { SortPopover } from '$features/ui';
import type { OrganizationAudienceSortBy, OrganizationAudienceSortOrder } from '$features/org/utils/types';
interface Props {
hasSelection: boolean;
selectedCount: number;
searchValue?: string;
sortValue: string;
onSortChange: (value: string) => void;
sortBy: OrganizationAudienceSortBy;
sortOrder: OrganizationAudienceSortOrder;
onSortChange: (sortBy: OrganizationAudienceSortBy, sortOrder: OrganizationAudienceSortOrder) => void;
onOpenAssign: () => void;
}
@@ -18,19 +20,37 @@
hasSelection,
selectedCount,
searchValue = $bindable(''),
sortValue,
sortBy,
sortOrder,
onSortChange,
onOpenAssign
}: Props = $props();
const sortOptions = [
{ label: t.get('audience.sort.newest'), value: 'createdAt:desc' },
{ label: t.get('audience.sort.oldest'), value: 'createdAt:asc' },
{ label: t.get('audience.sort.name_asc'), value: 'name:asc' },
{ label: t.get('audience.sort.name_desc'), value: 'name:desc' },
{ label: t.get('audience.sort.email_asc'), value: 'email:asc' },
{ label: t.get('audience.sort.email_desc'), value: 'email:desc' }
{ label: t.get('audience.date_joined'), value: 'createdAt' },
{ label: t.get('audience.name'), value: 'name' },
{ label: t.get('audience.email'), value: 'email' }
];
let localSortKey = $state(sortBy);
let localSortOrder = $state(sortOrder);
$effect(() => {
localSortKey = sortBy;
localSortOrder = sortOrder;
});
function handleSortKeyChange(key: string) {
onSortChange(key as OrganizationAudienceSortBy, localSortOrder);
}
function handleOrderChange(order: 'asc' | 'desc') {
onSortChange(localSortKey, order);
}
function handleClearSort() {
onSortChange('createdAt', 'desc');
}
</script>
<Page.BodyHeader>
@@ -46,20 +66,16 @@
{:else}
<div class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between">
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="w-full md:max-w-sm" />
<Select.Root type="single" value={sortValue} onValueChange={onSortChange} name="audience-sort">
<Select.Trigger class="w-full md:w-[220px]">
{sortOptions.find((option) => option.value === sortValue)?.label ?? $t('audience.sort.label')}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each sortOptions as option (option.value)}
<Select.Item value={option.value} label={option.label} disabled={option.value === sortValue}>
{option.label}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
<SortPopover
{sortOptions}
bind:sortKey={localSortKey}
bind:selectedOrder={localSortOrder}
defaultSortKey="createdAt"
defaultSortOrder="desc"
onSortKeyChange={handleSortKeyChange}
onOrderChange={handleOrderChange}
onClearFilters={handleClearSort}
/>
</div>
{/if}
</Page.BodyHeader>
@@ -53,7 +53,6 @@
let deleteCandidate = $state<OrganizationAudienceMember | null>(null);
let deleteDialogOpen = $state(false);
let searchValue = $state(query.search ?? '');
let sortValue = $state(`${query.sortBy}:${query.sortOrder}`);
async function handleResendInvite(email: string) {
inviteActionEmail = email;
@@ -126,7 +125,6 @@
$effect(() => {
searchValue = query.search ?? '';
sortValue = `${query.sortBy}:${query.sortOrder}`;
});
$effect(() => {
@@ -179,23 +177,11 @@
});
}
function handleSortChange(nextSortValue: string) {
if (nextSortValue === sortValue) {
return;
}
sortValue = nextSortValue;
const [sortBy, sortOrder] = nextSortValue.split(':') as [
OrganizationAudienceQuery['sortBy'],
OrganizationAudienceQuery['sortOrder']
];
void navigateAudience({
...query,
page: 1,
sortBy,
sortOrder
});
function handleSortChange(
sortBy: OrganizationAudienceQuery['sortBy'],
sortOrder: OrganizationAudienceQuery['sortOrder']
) {
void navigateAudience({ ...query, page: 1, sortBy, sortOrder });
}
function openDeleteConfirmation(member: OrganizationAudienceMember) {
@@ -236,7 +222,8 @@
{hasSelection}
selectedCount={selectedIds.size}
bind:searchValue
{sortValue}
sortBy={query.sortBy}
sortOrder={query.sortOrder}
onSortChange={handleSortChange}
onOpenAssign={() => (assignModalOpen = true)}
/>
@@ -1,5 +1,4 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { MultiSelectList } from '@cio/ui';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import * as RadioGroup from '@cio/ui/base/radio-group';
@@ -17,15 +16,23 @@
title: string;
}
interface Props {
courses: Course[];
interface Program {
id: string;
name: string;
}
let { courses }: Props = $props();
interface Props {
courses: Course[];
programs: Program[];
}
let { courses, programs }: Props = $props();
let recipientCsv = $state('');
let courseAccessMode = $state('none');
let programAccessMode = $state('none');
let selectedCourseIds = new SvelteSet<string>();
let selectedProgramIds = new SvelteSet<string>();
let sendEmail = $state(true);
let isSubmitting = $state(false);
@@ -37,9 +44,19 @@
}
}
function toggleProgram(programId: string) {
if (selectedProgramIds.has(programId)) {
selectedProgramIds.delete(programId);
} else {
selectedProgramIds.add(programId);
}
}
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
async function handleSubmit() {
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (!recipientCsv.trim()) {
snackbar.error('audience.import.snackbar_no_emails');
return;
@@ -51,7 +68,9 @@
const result = await orgApi.importAudienceMembers({
recipientCsv,
allCourses: courseAccessMode === 'all',
allPrograms: programAccessMode === 'all',
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
programIds: programAccessMode === 'select' ? [...selectedProgramIds] : undefined,
sendEmail
});
@@ -66,7 +85,7 @@
}
</script>
<div class="space-y-6">
<form id="import-audience-form" class="space-y-6" onsubmit={handleSubmit}>
<p class="ui:text-muted-foreground text-sm">{$t('audience.import.description')}</p>
<TextareaField
@@ -108,11 +127,38 @@
{/if}
</div>
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
{#if programs.length > 0}
<div class="space-y-3">
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
<div class="flex items-center gap-2">
<RadioGroup.Item value="none" id="program-none" />
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="all" id="program-all" />
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="select" id="program-select" />
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
</div>
</RadioGroup.Root>
<div class="flex gap-3">
<Button onclick={handleSubmit} loading={isSubmitting} disabled={isSubmitting}>
{$t('audience.import.submit')}
</Button>
</div>
</div>
{#if programAccessMode === 'select'}
<MultiSelectList
class="ml-6"
listClass="max-h-40"
heading={$t('audience.import.select_programs')}
emptyMessage={$t('audience.import.select_programs_placeholder')}
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
isSelected={(id) => selectedProgramIds.has(id)}
onToggle={toggleProgram}
namePrefix="import-program"
/>
{/if}
</div>
{/if}
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
</form>
@@ -8,9 +8,7 @@
import { Progress } from '@cio/ui/base/progress';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
import { BackButton } from '@cio/ui';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import type { UserAnalytics } from '$lib/utils/types/analytics';
let { data } = $props();
@@ -60,16 +58,12 @@
</script>
{#if userAnalytics}
<div class="p-5">
<BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
</div>
<div class="px-5 py-1">
<HeroProfileCard user={userAnalytics!.user} />
<div class="mt-5 px-0">
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each userMetrics as activity}
{#each userMetrics as activity (activity.title)}
<ActivityCard {activity} />
{/each}
</div>
@@ -114,54 +108,52 @@
</div>
{#each filteredCourses as course (course.id)}
{#key index}
<div
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
course.lessons_count === course.lessons_completed
? 'border-green-200 bg-green-50 dark:bg-green-100'
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
}`}
transition:fade={{ duration: 300 }}
>
<div class="flex items-center justify-between gap-4">
<div class="flex w-4/5 items-center gap-4">
<img
src={course.logo || '/images/classroomio-course-img-template.jpg'}
alt={course.title}
class="h-20 w-24 rounded-md"
/>
<div class="mb-4 gap-4">
<a href={`/courses/${course.id}`}>
<p class="text-lg font-semibold text-gray-600">
{course.title}
</p>
</a>
<p class="line-clamp-2 text-sm text-gray-600">
{course.description}
<div
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
course.lessons_count === course.lessons_completed
? 'border-green-200 bg-green-50 dark:bg-green-100'
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
}`}
transition:fade={{ duration: 300 }}
>
<div class="flex items-center justify-between gap-4">
<div class="flex w-4/5 items-center gap-4">
<img
src={course.logo || '/images/classroomio-course-img-template.jpg'}
alt={course.title}
class="h-20 w-24 rounded-md"
/>
<div class="mb-4 gap-4">
<a href={`/courses/${course.id}`}>
<p class="text-lg font-semibold text-gray-600">
{course.title}
</p>
</div>
</a>
<p class="line-clamp-2 text-sm text-gray-600">
{course.description}
</p>
</div>
<Badge
class={`${
course.lessons_count === course.lessons_completed
? 'bg-green-200 text-green-700'
: 'bg-yellow-200 text-yellow-700'
}`}
>
{course.lessons_count === course.lessons_completed
? $t('analytics.completed')
: $t('analytics.incomplete')}
</Badge>
</div>
<div class="flex w-full items-center gap-1">
<Progress value={course.progress_percentage} />
<p>{course.progress_percentage}%</p>
</div>
<Badge
class={`${
course.lessons_count === course.lessons_completed
? 'bg-green-200 text-green-700'
: 'bg-yellow-200 text-yellow-700'
}`}
>
{course.lessons_count === course.lessons_completed
? $t('analytics.completed')
: $t('analytics.incomplete')}
</Badge>
</div>
{/key}
<div class="flex w-full items-center gap-1">
<Progress value={course.progress_percentage} />
<p>{course.progress_percentage}%</p>
</div>
</div>
{/each}
</div>
</div>