feat(course): reset student course progress (#671)

* feat(course): add reset student course progress action

Give course admins and tutors a destructive reset-progress flow on the
student people detail page. The backend clears learner progress for one
(courseId, memberId) pair in a single transaction while keeping the
groupmember enrolled, and logs an audit summary. The dashboard shows a
confirmation dialog with impact counts from analytics before deleting.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(i18n): add localized reset progress strings for all locales

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(course): scope reset progress to course data only

Stop deleting org-level community questions and answers during course
progress reset. Fix course newsfeed deletion order so comments on a
student's posts are removed before the posts themselves. Restrict reset
to student members, move analytics loading to the person detail layout,
and rename impact copy to course newsfeed only.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(course): address Greptile review on reset progress

Make progressImpact opt-in via includeProgressImpact query param so
analytics requests avoid extra DB work unless the reset dialog needs it.
Count only student-authored course newsfeed posts and comments in the
impact summary to avoid double-counting replies on the student's posts.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-06-27 10:14:52 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent e43d8a2444
commit a695c97eda
25 changed files with 1056 additions and 59 deletions
@@ -10,9 +10,13 @@
import * as Page from '@cio/ui/base/page';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import ResetProgressButton from '$features/course/components/people/reset-progress-button.svelte';
import type { UserCourseAnalytics } from '$features/course/utils/types';
let { data = $bindable(), children } = $props();
let userCourseAnalytics = $derived(page.data.userCourseAnalytics as UserCourseAnalytics | null | undefined);
// Get back URL from query parameters
let backUrl = $derived(page.url.searchParams.get('back'));
@@ -31,6 +35,10 @@
async function refreshPeoplePage() {
await Promise.all([courseApi.refreshCourse(data.courseId, $profile.id), invalidateAll()]);
}
async function handleProgressReset() {
await invalidateAll();
}
</script>
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
@@ -49,6 +57,16 @@
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
{#if data.personId && userCourseAnalytics}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<ResetProgressButton
courseId={data.courseId}
personId={data.personId}
{userCourseAnalytics}
onSuccess={handleProgressReset}
/>
</RoleBasedSecurity>
{/if}
{#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button onclick={handleClick}>
@@ -0,0 +1,37 @@
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 personId = params.personId || '';
if (!courseId || !personId) {
return {
userCourseAnalytics: null
};
}
try {
const result = await safeServerApi<GetUserCourseAnalyticsSuccess>(() =>
classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
{
param: { courseId, userId: personId },
query: { includeProgressImpact: 'true' }
},
getApiHeaders(cookies, '')
)
);
const userCourseAnalytics: UserCourseAnalytics | null = result.ok && result.body.data ? result.body.data : null;
return {
userCourseAnalytics
};
} catch (error) {
console.error('Failed to fetch user course analytics:', error);
return {
userCourseAnalytics: null
};
}
};
@@ -1,42 +1,9 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
import { safeServerApi } from '$lib/utils/services/api/server';
export const load = async ({ parent }) => {
const parentData = await parent();
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
};
}
return {
courseId: parentData.courseId,
userId: parentData.personId,
userCourseAnalytics: parentData.userCourseAnalytics
};
};