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,69 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import * as Page from '@cio/ui/base/page';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
let { data = $bindable(), children } = $props();
// Get back URL from query parameters
let backUrl = $derived(page.url.searchParams.get('back'));
const handleClick = () => {
goto(resolve(`${page.url.pathname}?add=true`, {}));
};
const handleBackNavigation = () => {
if (backUrl) {
goto(resolve(backUrl, {}));
} else {
goto(resolve(`/courses/${data.courseId}/people`, {}));
}
};
async function refreshPeoplePage() {
await Promise.all([courseApi.refreshCourse(data.courseId, $profile.id), invalidateAll()]);
}
</script>
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{#if data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<IconButton onclick={handleBackNavigation}>
<ArrowLeftIcon size={16} />
</IconButton>
</RoleBasedSecurity>
{/if}
{$t('course.navItem.people.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
{#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button onclick={handleClick}>
{$t('course.navItem.people.add')}
</Button>
</RoleBasedSecurity>
{/if}
<RefreshPageData onRefresh={refreshPeoplePage} />
</div>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
{@render children?.()}
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,6 @@
export const load = ({ params }) => {
return {
courseId: params.id || '',
personId: params.personId || ''
};
};
@@ -0,0 +1,5 @@
<script lang="ts">
import { PeoplePage } from '$features/course/pages';
</script>
<PeoplePage />
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -0,0 +1,42 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
import { safeServerApi } from '$lib/utils/services/api/server';
import type { GetUserCourseAnalyticsSuccess, UserCourseAnalytics } from '$features/course/utils/types';
export const load = async ({ params, cookies }) => {
const courseId = params.id || '';
const userId = params.personId;
if (!courseId || !userId) {
return {
courseId,
userId,
userCourseAnalytics: null
};
}
try {
const result = await safeServerApi<GetUserCourseAnalyticsSuccess>(() =>
classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
{
param: { courseId, userId }
},
getApiHeaders(cookies, '')
)
);
const userCourseAnalytics: UserCourseAnalytics | null = result.ok && result.body.data ? result.body.data : null;
return {
courseId,
userId,
userCourseAnalytics
};
} catch (error) {
console.error('Failed to fetch user course analytics:', error);
return {
courseId,
userId,
userCourseAnalytics: null
};
}
};
@@ -0,0 +1,183 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { Badge } from '@cio/ui/base/badge';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
import { t } from '$lib/utils/functions/translations';
import type { UserCourseAnalytics } from '$features/course/utils/types';
import { Progress } from '@cio/ui/base/progress';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
let { data } = $props();
let userCourseAnalytics: UserCourseAnalytics | undefined = $derived(data.userCourseAnalytics ?? undefined);
function getPercentage(a: number, b: number): number {
if (b === 0) {
return 0;
}
return Math.round((a / b) * 100);
}
let exerciseFilter: 'all' | 'completed' | 'incomplete' = $state('all');
function toggleExerciseFilter(filter: 'completed' | 'incomplete') {
if (exerciseFilter === filter) {
exerciseFilter = 'all';
} else {
exerciseFilter = filter;
}
}
let learningActivities = $derived([
{
description: $t('analytics.overall_course_progress_user_description'),
icon: BookOpenIcon,
percentage: userCourseAnalytics?.progressPercentage ?? 0,
title: $t('analytics.overall_course_progress')
},
{
description: $t('analytics.assignment_completion_description'),
icon: ChartLineIcon,
percentage: getPercentage(
userCourseAnalytics?.userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0,
userCourseAnalytics?.userExercisesStats?.length || 0
),
title: $t('analytics.assignment_completion')
},
{
description: $t('analytics.average_grade_description'),
icon: UnfoldVerticalIcon,
percentage: userCourseAnalytics?.averageGrade ?? 0,
title: $t('analytics.average_grade')
}
]);
let filteredExercises = $derived(
userCourseAnalytics?.userExercisesStats?.filter((exercise) => {
if (exerciseFilter === 'all') {
return true;
}
return exercise.isCompleted === (exerciseFilter === 'completed');
})
);
let completedExercises = $derived(
userCourseAnalytics?.userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0
);
let incompleteExercises = $derived(
userCourseAnalytics?.userExercisesStats?.filter((exercise) => !exercise.isCompleted)?.length || 0
);
$effect(() => {
console.log('userCourseAnalytics.user', userCourseAnalytics?.user);
});
</script>
{#if userCourseAnalytics}
<section class="px-1">
{#if userCourseAnalytics.user}
<HeroProfileCard user={userCourseAnalytics.user} />
{/if}
<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 learningActivities as activity}
<ActivityCard {activity} />
{/each}
</div>
</div>
<div class="mt-5 rounded-md border p-3 md:p-5">
<h3 class="text-2xl">
{$t('analytics.exercises')}
</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.exercises')}
</p>
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress_description', {
value: completedExercises,
total: userCourseAnalytics?.userExercisesStats?.length
})}
</p>
</div>
<Progress value={getPercentage(completedExercises, userCourseAnalytics?.userExercisesStats?.length)} />
<div class="flex items-center justify-between">
<Badge
type={exerciseFilter === 'incomplete' ? 'secondary' : 'outline'}
class="text-yellow-700 lowercase dark:text-yellow-500"
onclick={() => toggleExerciseFilter('incomplete')}
>
{incompleteExercises}
{$t('analytics.incomplete')}
</Badge>
<Badge
type={exerciseFilter === 'completed' ? 'gray' : 'outline'}
class="text-green-700 lowercase dark:text-green-500"
onclick={() => toggleExerciseFilter('completed')}
>
{completedExercises}
{$t('analytics.complete')}
</Badge>
</div>
</div>
{#each filteredExercises as exercise, index}
{#key index}
<div
class={`mt-5 flex items-center justify-between gap-4 rounded-md border p-5 ${
exercise.isCompleted
? '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 w-2/3 items-center gap-4">
<BookOpenIcon size={16} />
<div>
<div class="mb-2">
<a href={`/courses/${data.courseId}/exercises/${exercise.id}`}>
<p class="text-lg font-semibold text-gray-600">
{exercise.title}
</p>
</a>
{#if exercise.lessonId}
<a href={`/courses/${data.courseId}/lessons/${exercise.lessonId}`}>
<p class="text-sm text-gray-500">
#{exercise.lessonTitle}
</p>
</a>
{:else if exercise.lessonTitle}
<p class="text-sm text-gray-500">
#{exercise.lessonTitle}
</p>
{/if}
</div>
<p class="text-sm text-gray-500">
Score: {exercise.score}/{exercise.totalPoints}
{#if exercise.isCompleted}
<Badge type={exercise.status === 3 ? 'secondary' : 'outline'}>
{exercise.status === 3 ? $t('analytics.graded') : $t('analytics.not_graded')}
</Badge>
{/if}
</p>
</div>
</div>
<Badge class={`${exercise.isCompleted ? 'bg-green-200 text-green-700' : 'bg-yellow-200 text-yellow-700'}`}>
{exercise.isCompleted ? $t('analytics.completed') : $t('analytics.incomplete')}
</Badge>
</div>
{/key}
{/each}
</div>
</section>
{:else}
<LoadingPage />
{/if}