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:
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { OrganizationAudience, OrganizationTeamMembers } from '$features/org/utils/types';
|
||||
|
||||
type OrgTeamMemberItem = OrganizationTeamMembers[number];
|
||||
export type Tutor = Pick<OrgTeamMemberItem, 'id' | 'profileId' | 'email'> & {
|
||||
text: OrgTeamMemberItem['fullname'];
|
||||
};
|
||||
|
||||
type OrgAudienceMemberItem = OrganizationAudience[number];
|
||||
export type OrgStudent = OrgAudienceMemberItem;
|
||||
Reference in New Issue
Block a user