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:
co-authored by
Rotimi Best
Cursor Agent
parent
e43d8a2444
commit
a695c97eda
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user