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 <[email protected]>

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

Co-authored-by: Rotimi Best <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
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
@@ -6,6 +6,7 @@ import type {
GetUserCourseAnalyticsRequest,
ListStudentInvitesRequest,
ListPeopleRequest,
ResetMemberCourseProgressRequest,
RevokeStudentInviteRequest,
UpdatePeopleRequest
} from '../utils/types';
@@ -290,6 +291,29 @@ export class PeopleApi extends BaseApiWithErrors {
}
});
}
/**
* Resets all learner progress for a course member while keeping them enrolled.
*/
async resetCourseProgress(courseId: string, memberId: string) {
await this.execute<ResetMemberCourseProgressRequest>({
requestFn: () =>
classroomio.course[':courseId']['members'][':memberId']['reset-progress'].$post({
param: { courseId, memberId }
}),
logContext: 'resetting course member progress',
onSuccess: () => {
snackbar.success('snackbar.course.people.progress_reset');
this.success = true;
this.errors = {};
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.course.people.progress_reset_failed');
}
}
});
}
}
export const peopleApi = /* @__PURE__ */ new PeopleApi();
@@ -0,0 +1,33 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import type { UserCourseAnalytics } from '$features/course/utils/types';
import ResetProgressDialog from './reset-progress-dialog.svelte';
let {
courseId,
personId,
userCourseAnalytics,
onSuccess = () => {}
}: {
courseId: string;
personId: string;
userCourseAnalytics: UserCourseAnalytics;
onSuccess?: () => void | Promise<void>;
} = $props();
let dialogOpen = $state(false);
const memberId = $derived(courseApi.group.people.find((member) => member.profileId === personId)?.id ?? '');
const studentName = $derived(userCourseAnalytics.user.fullName || userCourseAnalytics.user.email || '');
const canReset = $derived(Boolean(memberId && userCourseAnalytics.progressImpact));
</script>
<Button variant="destructive" onclick={() => (dialogOpen = true)} disabled={!canReset}>
{$t('course.navItem.people.reset_progress.button')}
</Button>
{#if canReset}
<ResetProgressDialog bind:open={dialogOpen} {courseId} {memberId} {studentName} {userCourseAnalytics} {onSuccess} />
{/if}
@@ -0,0 +1,124 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { peopleApi } from '$features/course/api/people.svelte';
import type { UserCourseAnalytics } from '$features/course/utils/types';
let {
open = $bindable(false),
courseId,
memberId,
studentName,
userCourseAnalytics,
onSuccess = () => {}
}: {
open?: boolean;
courseId: string;
memberId: string;
studentName: string;
userCourseAnalytics: UserCourseAnalytics;
onSuccess?: () => void | Promise<void>;
} = $props();
const progressImpact = $derived(userCourseAnalytics.progressImpact);
const impactBullets = $derived.by(() => {
if (!progressImpact) {
return [];
}
return [
{
key: 'lessons',
text: t.get('course.navItem.people.reset_progress.dialog.bullets.lessons', {
completed: progressImpact.completedLessons,
total: progressImpact.totalLessons
})
},
{
key: 'exercises',
text: t.get('course.navItem.people.reset_progress.dialog.bullets.exercises', {
count: progressImpact.exerciseSubmissions
})
},
{
key: 'comments',
text: t.get('course.navItem.people.reset_progress.dialog.bullets.comments', {
count: progressImpact.lessonComments
})
},
{
key: 'newsfeed',
text: t.get('course.navItem.people.reset_progress.dialog.bullets.newsfeed', {
count: progressImpact.courseNewsfeedActivity
})
},
{
key: 'video_progress',
text: t.get('course.navItem.people.reset_progress.dialog.bullets.video_progress', {
count: progressImpact.videoProgressLessons
})
},
{
key: 'attendance',
text: t.get('course.navItem.people.reset_progress.dialog.bullets.attendance', {
count: progressImpact.attendanceEntries
})
},
{
key: 'certificate',
text: t.get(
progressImpact.hasCertificationRecords
? 'course.navItem.people.reset_progress.dialog.bullets.certificate_yes'
: 'course.navItem.people.reset_progress.dialog.bullets.certificate_no'
)
}
];
});
async function handleReset() {
if (!courseId || !memberId) {
return;
}
await peopleApi.resetCourseProgress(courseId, memberId);
if (peopleApi.success) {
open = false;
await onSuccess();
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title>
{$t('course.navItem.people.reset_progress.dialog.title', { studentName })}
</Dialog.Title>
<Dialog.Description>
{$t('course.navItem.people.reset_progress.dialog.intro')}
</Dialog.Description>
</Dialog.Header>
<ul class="list-disc space-y-2 pl-5 text-sm">
{#each impactBullets as bullet (bullet.key)}
<li>{bullet.text}</li>
{/each}
</ul>
<p class="ui:text-destructive mt-4 text-sm">
{$t('course.navItem.people.reset_progress.dialog.warning')}
</p>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)} disabled={peopleApi.isLoading}>
{$t('course.navItem.people.reset_progress.dialog.cancel')}
</Button>
<Button variant="destructive" onclick={handleReset} loading={peopleApi.isLoading} disabled={peopleApi.isLoading}>
{$t('course.navItem.people.reset_progress.dialog.confirm')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -404,6 +404,12 @@ export type GetUserCourseAnalyticsResponse = InferResponseType<GetUserCourseAnal
export type GetUserCourseAnalyticsSuccess = Extract<GetUserCourseAnalyticsResponse, { success: true }>;
export type UserCourseAnalytics = GetUserCourseAnalyticsSuccess['data'];
export type ResetMemberCourseProgressRequest =
(typeof classroomio.course)[':courseId']['members'][':memberId']['reset-progress']['$post'];
export type ResetMemberCourseProgressResponse = InferResponseType<ResetMemberCourseProgressRequest>;
export type ResetMemberCourseProgressSuccess = Extract<ResetMemberCourseProgressResponse, { success: true }>;
export type ResetMemberCourseProgressSummary = ResetMemberCourseProgressSuccess['data'];
export type GetCourseAnalyticsRequest = (typeof classroomio.course)[':courseId']['analytics']['$get'];
export type GetCourseAnalyticsResponse = InferResponseType<GetCourseAnalyticsRequest>;
export type GetCourseAnalyticsSuccess = Extract<GetCourseAnalyticsResponse, { success: true }>;