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
+33 -2
View File
@@ -24,11 +24,15 @@ import {
getProfileCourseProgress,
getUserExercisesStats
} from '@cio/db/queries/analytics/analytics';
import { getStudentCourseProgressImpactCounts } from '@cio/db/queries/course/reset-progress';
import { getCourseGroupId } from '@cio/db/queries/course/people';
import { ContentType, ROLE } from '@cio/utils/constants';
import type { TCourse } from '@cio/db/types';
import type { TCourseCreate } from '@cio/utils/validation/course';
import { db } from '@cio/db/drizzle';
import * as schema from '@cio/db/schema';
import { and, eq } from 'drizzle-orm';
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
import { updateExercise, updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { getProfileById } from '@cio/db/queries/auth';
@@ -549,7 +553,11 @@ export async function getCourseAnalytics(courseId: string) {
* @param userId User ID (profile ID)
* @returns User course analytics data
*/
export async function getUserCourseAnalytics(courseId: string, userId: string) {
export async function getUserCourseAnalytics(
courseId: string,
userId: string,
options: { includeProgressImpact?: boolean } = {}
) {
try {
// Get user profile
const profile = await getProfileById(userId);
@@ -581,6 +589,28 @@ export async function getUserCourseAnalytics(courseId: string, userId: string) {
const completedExercises = userExercisesStats.filter((exercise) => exercise.isCompleted).length;
const totalExercises = courseProgress.exercises_count || 0;
let progressImpact = null;
if (options.includeProgressImpact) {
const groupId = await getCourseGroupId(courseId);
if (groupId) {
const [groupMember] = await db
.select({ id: schema.groupmember.id })
.from(schema.groupmember)
.where(and(eq(schema.groupmember.groupId, groupId), eq(schema.groupmember.profileId, userId)))
.limit(1);
if (groupMember) {
progressImpact = await getStudentCourseProgressImpactCounts({
courseId,
groupMemberId: groupMember.id,
profileId: userId
});
}
}
}
return {
user: {
id: userId,
@@ -593,7 +623,8 @@ export async function getUserCourseAnalytics(courseId: string, userId: string) {
userExercisesStats,
totalExercises,
completedExercises,
progressPercentage
progressPercentage,
progressImpact
};
} catch (error) {
if (error instanceof AppError) {