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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user