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
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@api/services/course/compliance', () => ({
ensureComplianceEnrollmentRecordsForProfiles: vi.fn()
}));
vi.mock('@cio/db/queries/course/people', () => ({
getCourseMember: vi.fn(),
getCourseMembers: vi.fn(),
addCourseMember: vi.fn(),
deleteCourseMember: vi.fn(),
updateCourseMember: vi.fn(),
getCourseTeachers: vi.fn()
}));
vi.mock('@cio/db/queries/course/reset-progress', () => ({
resetStudentCourseProgress: vi.fn()
}));
import { AppError } from '@api/utils/errors';
import { ROLE } from '@cio/utils/constants';
import { getCourseMember } from '@cio/db/queries/course/people';
import { resetStudentCourseProgress } from '@cio/db/queries/course/reset-progress';
import { resetMemberCourseProgress } from '@api/services/course/people';
describe('resetMemberCourseProgress', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 404 when the member is missing', async () => {
vi.mocked(getCourseMember).mockResolvedValue(null);
await expect(resetMemberCourseProgress('course-1', 'member-1', 'actor-1')).rejects.toMatchObject({
statusCode: 404
});
});
it('returns 404 when the member has no profileId', async () => {
vi.mocked(getCourseMember).mockResolvedValue({
id: 'member-1',
profileId: null,
roleId: ROLE.STUDENT
} as Awaited<ReturnType<typeof getCourseMember>>);
await expect(resetMemberCourseProgress('course-1', 'member-1', 'actor-1')).rejects.toBeInstanceOf(AppError);
});
it('returns 400 when the member is not a student', async () => {
vi.mocked(getCourseMember).mockResolvedValue({
id: 'member-1',
profileId: 'profile-1',
roleId: ROLE.TUTOR
} as Awaited<ReturnType<typeof getCourseMember>>);
await expect(resetMemberCourseProgress('course-1', 'member-1', 'actor-1')).rejects.toMatchObject({
statusCode: 400
});
expect(resetStudentCourseProgress).not.toHaveBeenCalled();
});
it('resets progress and returns the summary', async () => {
const summary = {
lessonCompletion: 2,
lessonVideoProgress: 1,
submissions: 3,
groupAttendance: 0,
lessonComments: 1,
courseNewsfeed: 0,
courseNewsfeedComments: 0,
courseCertificateIssues: 0,
courseCompletionRecords: 0,
aiChatConversations: 0
};
vi.mocked(getCourseMember).mockResolvedValue({
id: 'member-1',
profileId: 'profile-1',
roleId: ROLE.STUDENT
} as Awaited<ReturnType<typeof getCourseMember>>);
vi.mocked(resetStudentCourseProgress).mockResolvedValue(summary);
const result = await resetMemberCourseProgress('course-1', 'member-1', 'actor-1');
expect(resetStudentCourseProgress).toHaveBeenCalledWith({
courseId: 'course-1',
groupMemberId: 'member-1',
profileId: 'profile-1'
});
expect(result).toEqual(summary);
});
});
+57 -16
View File
@@ -2,12 +2,19 @@ import {
ZAddCourseMembers,
ZCourseMembersMemberParam,
ZCourseMembersParam,
ZResetCourseMemberProgressParam,
ZUpdateCourseMember
} from '@cio/utils/validation/course/people';
import { addMembers, deleteMember, listCourseMembers, updateMember } from '@api/services/course/people';
import {
addMembers,
deleteMember,
listCourseMembers,
resetMemberCourseProgress,
updateMember
} from '@api/services/course/people';
import { Hono } from '@api/utils/hono';
import { ZCourseUserAnalyticsParam } from '@cio/utils/validation/course';
import { ZCourseUserAnalyticsParam, ZCourseUserAnalyticsQuery } from '@cio/utils/validation/course';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { getUserCourseAnalytics } from '@cio/core/services/course/course';
import { handleError } from '@api/utils/errors';
@@ -115,24 +122,58 @@ export const membersRouter = new Hono()
return handleError(c, error, 'Failed to delete course member');
}
})
/**
* POST /course/:courseId/members/:memberId/reset-progress
* Clears all learner progress for a course member while keeping them enrolled.
* Requires authentication and course team membership (admin/tutor role)
*/
.post(
'/:memberId/reset-progress',
courseTeamMemberMiddleware,
zValidator('param', ZResetCourseMemberProgressParam),
async (c) => {
try {
const user = c.get('user')!;
const { courseId, memberId } = c.req.valid('param');
const summary = await resetMemberCourseProgress(courseId, memberId, user.id);
return c.json(
{
success: true,
data: summary
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to reset course member progress');
}
}
)
/**
* GET /course/:courseId/members/:userId/analytics
* Gets user course analytics for a specific course
* Requires authentication and course membership
*/
.get('/:userId/analytics', courseTeamMemberMiddleware, zValidator('param', ZCourseUserAnalyticsParam), async (c) => {
try {
const { courseId, userId } = c.req.valid('param');
const analytics = await getUserCourseAnalytics(courseId, userId);
.get(
'/:userId/analytics',
courseTeamMemberMiddleware,
zValidator('param', ZCourseUserAnalyticsParam),
zValidator('query', ZCourseUserAnalyticsQuery),
async (c) => {
try {
const { courseId, userId } = c.req.valid('param');
const { includeProgressImpact } = c.req.valid('query');
const analytics = await getUserCourseAnalytics(courseId, userId, { includeProgressImpact });
return c.json(
{
success: true,
data: analytics
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch user course analytics');
return c.json(
{
success: true,
data: analytics
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch user course analytics');
}
}
});
);
+42 -1
View File
@@ -1,3 +1,4 @@
import { ROLE } from '@cio/utils/constants';
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
addCourseMember,
@@ -7,8 +8,8 @@ import {
getCourseTeachers,
updateCourseMember
} from '@cio/db/queries/course/people';
import { resetStudentCourseProgress } from '@cio/db/queries/course/reset-progress';
import { ROLE } from '@cio/utils/constants';
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
import type { TGroupmember } from '@cio/db/types';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
@@ -316,3 +317,43 @@ export async function deleteMember(courseId: string, memberId: string) {
);
}
}
/**
* Resets all learner progress for a course member while keeping them enrolled.
*/
export async function resetMemberCourseProgress(courseId: string, memberId: string, actorProfileId: string) {
try {
const member = await getCourseMember(courseId, memberId);
if (!member || !member.profileId) {
throw new AppError('Course member not found', ErrorCodes.NOT_FOUND, 404);
}
if (member.roleId !== ROLE.STUDENT) {
throw new AppError('Only student progress can be reset', ErrorCodes.VALIDATION_ERROR, 400);
}
const summary = await resetStudentCourseProgress({
courseId,
groupMemberId: memberId,
profileId: member.profileId
});
console.info('resetMemberCourseProgress', {
actorProfileId,
courseId,
memberId,
summary
});
return summary;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to reset course member progress',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}