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,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}
|
||||
Reference in New Issue
Block a user