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,58 @@
<script lang="ts">
import TrashIcon from '@lucide/svelte/icons/trash';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
interface Props {
label: string;
href: string;
labelFieldLabel: string;
hrefFieldLabel: string;
labelPlaceholder?: string;
hrefPlaceholder?: string;
className?: string;
removeAriaLabel?: string;
onLabelInput: (value: string) => void;
onHrefInput: (value: string) => void;
onRemove?: () => void;
}
let {
label,
href,
labelFieldLabel,
hrefFieldLabel,
labelPlaceholder,
hrefPlaceholder,
className = 'space-y-4',
removeAriaLabel,
onLabelInput,
onHrefInput,
onRemove
}: Props = $props();
</script>
<div class={className}>
{#if onRemove}
<Button
type="button"
variant="secondary"
class="absolute! -top-3! right-2!"
aria-label={removeAriaLabel}
onclick={() => onRemove()}
>
<TrashIcon size={16} />
</Button>
{/if}
<Field.Field>
<Field.Label>{labelFieldLabel}</Field.Label>
<Input value={label} placeholder={labelPlaceholder} oninput={(event) => onLabelInput(event.currentTarget.value)} />
</Field.Field>
<Field.Field>
<Field.Label>{hrefFieldLabel}</Field.Label>
<Input value={href} placeholder={hrefPlaceholder} oninput={(event) => onHrefInput(event.currentTarget.value)} />
</Field.Field>
</div>
@@ -0,0 +1,119 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import { t } from '$lib/utils/functions/translations';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import type { OrgLandingPageCallout, OrgLandingPageJson } from '$lib/utils/types/org';
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
}
let { settings = $bindable(), markDirty }: Props = $props();
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
}
const nextSettings = cloneDeep(settings);
set(nextSettings, setterKey, value);
settings = nextSettings;
markDirty();
}
function ensureCallout(): OrgLandingPageCallout {
if (settings.callout) {
return settings.callout;
}
const callout: OrgLandingPageCallout = {
heading: t.get('settings.landing_page.editor.callout.heading_default'),
description: '',
action: {
label: t.get('settings.landing_page.editor.callout.action_label_default'),
href: '#'
}
};
settings = {
...settings,
callout
};
markDirty();
return callout;
}
function setCalloutEnabled(enabled: boolean) {
if (!enabled) {
settings = {
...settings,
callout: undefined
};
markDirty();
return;
}
setter(ensureCallout(), 'callout');
}
</script>
<Field.Group class="space-y-6">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.editor.sections.callout')}</Field.Legend>
<div class="space-y-6">
<Field.Field orientation="horizontal">
<Switch
checked={Boolean(settings.callout)}
onCheckedChange={(checked) => setCalloutEnabled(checked === true)}
/>
<Field.Label>{$t('settings.landing_page.editor.callout.toggle')}</Field.Label>
</Field.Field>
{#if settings.callout}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.callout.heading')}</Field.Label>
<Input
value={settings.callout.heading}
placeholder={$t('settings.landing_page.editor.callout.heading_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'callout.heading')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.callout.description')}</Field.Label>
<Textarea
class="ui:[field-sizing:fixed] ui:min-w-0 ui:w-full ui:max-w-full ui:box-border ui:resize-y"
value={settings.callout.description}
placeholder={$t('settings.landing_page.editor.callout.description_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'callout.description')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.callout.action_label')}</Field.Label>
<Input
value={settings.callout.action.label}
placeholder={$t('settings.landing_page.editor.callout.action_label_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'callout.action.label')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.callout.action_href')}</Field.Label>
<Input
value={settings.callout.action.href}
placeholder={$t('settings.landing_page.editor.callout.action_href_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'callout.action.href')}
/>
</Field.Field>
{/if}
</div>
</Field.Set>
</Field.Group>
@@ -0,0 +1,133 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import { t } from '$lib/utils/functions/translations';
import * as Field from '@cio/ui/base/field';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import type { OrgLandingPageEmbed, OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
import { Input } from '@cio/ui/base/input';
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
}
let { settings = $bindable(), markDirty }: Props = $props();
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
}
const nextSettings = cloneDeep(settings);
set(nextSettings, setterKey, value);
settings = nextSettings;
markDirty();
}
function ensureEmbed(): OrgLandingPageEmbed {
if (settings.embed) {
return settings.embed;
}
const embed = {
title: t.get('settings.landing_page.editor.embed.title'),
description: '',
code: '',
secondaryAction: undefined
};
settings = {
...settings,
embed
};
markDirty();
return embed;
}
function setEmbedEnabled(enabled: boolean) {
if (!enabled) {
settings = {
...settings,
embed: undefined
};
markDirty();
return;
}
setter(ensureEmbed(), 'embed');
}
function updateSecondaryAction(label: string, href: string) {
if (!settings.embed) {
return;
}
setter(
{
label,
href
},
'embed.secondaryAction'
);
}
</script>
<Field.Group class="space-y-6">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.editor.sections.embed')}</Field.Legend>
<div class="space-y-6">
<Field.Field orientation="horizontal">
<Switch checked={Boolean(settings.embed)} onCheckedChange={(checked) => setEmbedEnabled(checked === true)} />
<Field.Label>{$t('settings.landing_page.editor.embed.toggle')}</Field.Label>
</Field.Field>
{#if settings.embed}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.embed.title')}</Field.Label>
<Input
value={settings.embed.title}
placeholder={$t('settings.landing_page.editor.embed.title_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'embed.title')}
/>
</Field.Field>
<Field.Field class="min-w-0">
<Field.Label>{$t('settings.landing_page.editor.embed.description')}</Field.Label>
<Textarea
class="ui:[field-sizing:fixed] ui:min-w-0 ui:w-full ui:max-w-full ui:box-border ui:resize-y"
value={settings.embed.description ?? ''}
placeholder={$t('settings.landing_page.editor.embed.description_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'embed.description')}
/>
</Field.Field>
<Field.Field class="min-w-0">
<Field.Label>{$t('settings.landing_page.editor.embed.code')}</Field.Label>
<Field.Description>{$t('settings.landing_page.editor.embed.code_description')}</Field.Description>
<Textarea
class="ui:[field-sizing:fixed] ui:min-w-0 ui:w-full ui:max-w-full ui:box-border ui:resize-y"
value={settings.embed.code}
placeholder={$t('settings.landing_page.editor.embed.code_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'embed.code')}
/>
</Field.Field>
<ButtonLinkFields
label={settings.embed.secondaryAction?.label ?? ''}
href={settings.embed.secondaryAction?.href ?? ''}
labelFieldLabel={$t('settings.landing_page.editor.embed.secondary_label')}
hrefFieldLabel={$t('settings.landing_page.editor.embed.secondary_href')}
labelPlaceholder={$t('settings.landing_page.editor.embed.secondary_label')}
hrefPlaceholder={$t('settings.landing_page.editor.embed.secondary_href')}
onLabelInput={(value) => updateSecondaryAction(value, settings.embed.secondaryAction?.href ?? '')}
onHrefInput={(value) => updateSecondaryAction(settings.embed.secondaryAction?.label ?? '', value)}
/>
{/if}
</div>
</Field.Set>
</Field.Group>
@@ -0,0 +1,92 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Textarea } from '@cio/ui/base/textarea';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
}
let { settings = $bindable(), markDirty }: Props = $props();
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
}
const nextSettings = cloneDeep(settings);
set(nextSettings, setterKey, value);
settings = nextSettings;
markDirty();
}
function addFooterLink() {
settings = {
...settings,
footerLinks: [
...settings.footerLinks,
{
label: t.get('settings.landing_page.editor.footer.default_label'),
href: '#'
}
]
};
markDirty();
}
function removeFooterLink(index: number) {
settings = {
...settings,
footerLinks: settings.footerLinks.filter((_, linkIndex) => linkIndex !== index)
};
markDirty();
}
</script>
<Field.Group class="space-y-6">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.editor.sections.footer')}</Field.Legend>
<div class="space-y-4">
<Field.Field class="mb-6">
<Field.Label>{$t('settings.landing_page.editor.footer.text')}</Field.Label>
<Textarea
value={settings.footerText}
oninput={(event) => {
settings = {
...settings,
footerText: event.currentTarget.value
};
markDirty();
}}
/>
</Field.Field>
<div class="space-y-4">
{#each settings.footerLinks as footerLink, index (index)}
<ButtonLinkFields
label={footerLink.label}
href={footerLink.href}
labelFieldLabel={$t('settings.landing_page.editor.footer.link_label')}
hrefFieldLabel={$t('settings.landing_page.editor.footer.link_href')}
className="group ui:border-border relative space-y-3 border-b pb-5"
removeAriaLabel={$t('settings.landing_page.editor.footer.remove')}
onLabelInput={(value) => setter(value, `footerLinks.${index}.label`)}
onHrefInput={(value) => setter(value, `footerLinks.${index}.href`)}
onRemove={() => removeFooterLink(index)}
/>
{/each}
<Button type="button" variant="outline" onclick={addFooterLink}>
{$t('settings.landing_page.editor.footer.add')}
</Button>
</div>
</div>
</Field.Set>
</Field.Group>
@@ -0,0 +1,148 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import { UploadWidget } from '$features/ui';
import { handleOpenWidget } from '$features/ui/course-landing-page/store';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
}
let { settings = $bindable(), markDirty }: Props = $props();
let activeWidget = $state<'hero-image' | ''>('');
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
}
const nextSettings = cloneDeep(settings);
set(nextSettings, setterKey, value);
settings = nextSettings;
markDirty();
}
function toggleSecondaryAction(enabled: boolean) {
if (!enabled) {
settings = {
...settings,
hero: {
...settings.hero,
secondaryAction: undefined
}
};
markDirty();
return;
}
setter(
settings.hero.secondaryAction ?? {
label: t.get('settings.landing_page.editor.hero.secondary_label'),
href: '/courses'
},
'hero.secondaryAction'
);
}
</script>
<Field.Group class="space-y-6">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.editor.sections.hero')}</Field.Legend>
<div class="space-y-4">
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.hero.heading')}</Field.Label>
<Input value={settings.hero.heading} oninput={(event) => setter(event.currentTarget.value, 'hero.heading')} />
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.hero.subheading')}</Field.Label>
<Textarea
value={settings.hero.subheading}
oninput={(event) => setter(event.currentTarget.value, 'hero.subheading')}
/>
</Field.Field>
<ButtonLinkFields
label={settings.hero.primaryAction.label}
href={settings.hero.primaryAction.href}
labelFieldLabel={$t('settings.landing_page.editor.hero.primary_label')}
hrefFieldLabel={$t('settings.landing_page.editor.hero.primary_href')}
labelPlaceholder={$t('settings.landing_page.editor.hero.primary_label')}
hrefPlaceholder={$t('settings.landing_page.editor.hero.primary_href')}
onLabelInput={(value) => setter(value, 'hero.primaryAction.label')}
onHrefInput={(value) => setter(value, 'hero.primaryAction.href')}
/>
<Field.Field orientation="horizontal">
<Switch
checked={Boolean(settings.hero.secondaryAction)}
onCheckedChange={(checked) => toggleSecondaryAction(checked === true)}
/>
<Field.Label>{$t('settings.landing_page.editor.hero.secondary_toggle')}</Field.Label>
</Field.Field>
{#if settings.hero.secondaryAction}
<ButtonLinkFields
label={settings.hero.secondaryAction.label}
href={settings.hero.secondaryAction.href}
labelFieldLabel={$t('settings.landing_page.editor.hero.secondary_label')}
hrefFieldLabel={$t('settings.landing_page.editor.hero.secondary_href')}
labelPlaceholder={$t('settings.landing_page.editor.hero.secondary_label')}
hrefPlaceholder={$t('settings.landing_page.editor.hero.secondary_href')}
onLabelInput={(value) => setter(value, 'hero.secondaryAction.label')}
onHrefInput={(value) => setter(value, 'hero.secondaryAction.href')}
/>
{/if}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.hero.image')}</Field.Label>
{#if settings.hero.image}
<img src={settings.hero.image} alt="" class="border-border/60 mb-3 w-full rounded-lg border object-cover" />
{/if}
<div class="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onclick={() => {
activeWidget = 'hero-image';
$handleOpenWidget.open = true;
}}
>
{$t('settings.landing_page.editor.hero.upload')}
</Button>
<Button
type="button"
variant="ghost"
onclick={() => {
setter('', 'hero.image');
}}
>
{$t('settings.landing_page.editor.hero.clear')}
</Button>
</div>
{#if $handleOpenWidget.open && activeWidget === 'hero-image'}
<UploadWidget
imageURL={settings.hero.image}
onchange={(imageUrl) => {
setter(imageUrl, 'hero.image');
$handleOpenWidget.open = false;
}}
/>
{/if}
</Field.Field>
</div>
</Field.Set>
</Field.Group>
@@ -0,0 +1,70 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
}
let { settings = $bindable(), markDirty }: Props = $props();
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
}
const nextSettings = cloneDeep(settings);
set(nextSettings, setterKey, value);
settings = nextSettings;
markDirty();
}
function addNavigationItem() {
settings = {
...settings,
navItems: [
...settings.navItems,
{
label: t.get('settings.landing_page.editor.navigation.default_label'),
href: '#'
}
]
};
markDirty();
}
function removeNavigationItem(index: number) {
settings = {
...settings,
navItems: settings.navItems.filter((_, itemIndex) => itemIndex !== index)
};
markDirty();
}
</script>
<div class="space-y-7">
<Field.Legend>{$t('settings.landing_page.editor.sections.navigation')}</Field.Legend>
{#each settings.navItems as navItem, index (index)}
<ButtonLinkFields
label={navItem.label}
href={navItem.href}
labelFieldLabel={$t('settings.landing_page.editor.navigation.link_label')}
hrefFieldLabel={$t('settings.landing_page.editor.navigation.link_href')}
className="group ui:border-border relative space-y-3 border-b pb-5"
removeAriaLabel={$t('settings.landing_page.editor.navigation.remove')}
onLabelInput={(value) => setter(value, `navItems.${index}.label`)}
onHrefInput={(value) => setter(value, `navItems.${index}.href`)}
onRemove={() => removeNavigationItem(index)}
/>
{/each}
<Button type="button" variant="outline" onclick={addNavigationItem}>
{$t('settings.landing_page.editor.navigation.add')}
</Button>
</div>