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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
+124
@@ -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 }>;
|
||||
|
||||
@@ -242,6 +242,10 @@
|
||||
"unpinned": "Frigjort",
|
||||
"successfully": "Med succes",
|
||||
"feed_delete_success": "Feedet blev slettet"
|
||||
},
|
||||
"people": {
|
||||
"progress_reset": "Fremskridt nulstillet",
|
||||
"progress_reset_failed": "Kunne ikke nulstille fremskridt"
|
||||
}
|
||||
},
|
||||
"landing_page_settings": {
|
||||
@@ -1100,6 +1104,26 @@
|
||||
"no": "Nej",
|
||||
"yes": "Ja"
|
||||
},
|
||||
"reset_progress": {
|
||||
"button": "Nulstil fremskridt",
|
||||
"dialog": {
|
||||
"title": "Nulstil fremskridt for {studentName}?",
|
||||
"intro": "Dette sletter permanent denne elevs fremskridt i dette kursus. Eleven forbliver tilmeldt og kan starte forfra fra lektion 1.",
|
||||
"bullets": {
|
||||
"lessons": "Gennemførte lektioner — {completed} af {total}",
|
||||
"exercises": "Afleverede øvelser — {count} afleveringer",
|
||||
"comments": "Lektionskommentarer fra denne elev — {count}",
|
||||
"newsfeed": "Kursusnyhedsfeed-opslag og svar fra denne elev — {count}",
|
||||
"video_progress": "Videofremskridt — {count} lektioner sporet",
|
||||
"attendance": "Fremmødeposter — {count}",
|
||||
"certificate_yes": "Certificerings-/compliance-data — ja",
|
||||
"certificate_no": "Certificerings-/compliance-data — nej"
|
||||
},
|
||||
"warning": "Dette kan ikke fortrydes.",
|
||||
"cancel": "Annuller",
|
||||
"confirm": "Slet"
|
||||
}
|
||||
},
|
||||
"invite_modal": {
|
||||
"title": "Inviter folk",
|
||||
"invite": "Inviter undervisere",
|
||||
|
||||
@@ -242,6 +242,10 @@
|
||||
"unpinned": "Nicht angepinnt",
|
||||
"successfully": "Erfolgreich",
|
||||
"feed_delete_success": "Feed erfolgreich gelöscht"
|
||||
},
|
||||
"people": {
|
||||
"progress_reset": "Fortschritt zurückgesetzt",
|
||||
"progress_reset_failed": "Fortschritt konnte nicht zurückgesetzt werden"
|
||||
}
|
||||
},
|
||||
"landing_page_settings": {
|
||||
@@ -1100,6 +1104,26 @@
|
||||
"no": "Nein",
|
||||
"yes": "Ja"
|
||||
},
|
||||
"reset_progress": {
|
||||
"button": "Fortschritt zurücksetzen",
|
||||
"dialog": {
|
||||
"title": "Fortschritt von {studentName} zurücksetzen?",
|
||||
"intro": "Dadurch wird der Fortschritt dieses Teilnehmers in diesem Kurs dauerhaft gelöscht. Die Person bleibt eingeschrieben und kann bei Lektion 1 neu beginnen.",
|
||||
"bullets": {
|
||||
"lessons": "Abgeschlossene Lektionen — {completed} von {total}",
|
||||
"exercises": "Eingereichte Übungen — {count} Abgaben",
|
||||
"comments": "Lektionskommentare dieses Teilnehmers — {count}",
|
||||
"newsfeed": "Kurs-Newsfeed-Beiträge und Antworten dieses Teilnehmers — {count}",
|
||||
"video_progress": "Videofortschritt — {count} verfolgte Lektionen",
|
||||
"attendance": "Anwesenheitseinträge — {count}",
|
||||
"certificate_yes": "Zertifizierungs-/Compliance-Daten — ja",
|
||||
"certificate_no": "Zertifizierungs-/Compliance-Daten — nein"
|
||||
},
|
||||
"warning": "Dies kann nicht rückgängig gemacht werden.",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Löschen"
|
||||
}
|
||||
},
|
||||
"invite_modal": {
|
||||
"title": "Leute einladen",
|
||||
"invite": "Laden Sie Tutoren ein",
|
||||
|
||||
@@ -3308,6 +3308,26 @@
|
||||
"no": "No",
|
||||
"yes": "Yes"
|
||||
},
|
||||
"reset_progress": {
|
||||
"button": "Reset progress",
|
||||
"dialog": {
|
||||
"title": "Reset progress for {studentName}?",
|
||||
"intro": "This permanently deletes this student's progress in this course. They will stay enrolled and can restart from lesson 1.",
|
||||
"bullets": {
|
||||
"lessons": "Lessons completed — {completed} of {total}",
|
||||
"exercises": "Exercises submitted — {count} submissions",
|
||||
"comments": "Lesson comments by this student — {count}",
|
||||
"newsfeed": "Course newsfeed posts and replies by this student — {count}",
|
||||
"video_progress": "Video watch progress — {count} lessons tracked",
|
||||
"attendance": "Attendance entries — {count}",
|
||||
"certificate_yes": "Certification / compliance records — yes",
|
||||
"certificate_no": "Certification / compliance records — no"
|
||||
},
|
||||
"warning": "This cannot be undone.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Admin",
|
||||
"tutor": "Tutor",
|
||||
@@ -4196,6 +4216,10 @@
|
||||
"toggle_error": "Failed to toggle pinned feed",
|
||||
"news_feed_fail": "Failed to fetch news feeds",
|
||||
"feed_fetch_error": "An error occured while fetching feed"
|
||||
},
|
||||
"people": {
|
||||
"progress_reset": "Progress reset",
|
||||
"progress_reset_failed": "Failed to reset progress"
|
||||
}
|
||||
},
|
||||
"lms": {
|
||||
|
||||
@@ -242,6 +242,10 @@
|
||||
"unpinned": "Desanclar",
|
||||
"successfully": "Con éxito",
|
||||
"feed_delete_success": "Feed eliminado correctamente"
|
||||
},
|
||||
"people": {
|
||||
"progress_reset": "Progreso restablecido",
|
||||
"progress_reset_failed": "No se pudo restablecer el progreso"
|
||||
}
|
||||
},
|
||||
"landing_page_settings": {
|
||||
@@ -1100,6 +1104,26 @@
|
||||
"no": "No",
|
||||
"yes": "si"
|
||||
},
|
||||
"reset_progress": {
|
||||
"button": "Restablecer progreso",
|
||||
"dialog": {
|
||||
"title": "¿Restablecer el progreso de {studentName}?",
|
||||
"intro": "Esto elimina permanentemente el progreso de este estudiante en este curso. Seguirá inscrito y podrá empezar de nuevo desde la lección 1.",
|
||||
"bullets": {
|
||||
"lessons": "Lecciones completadas — {completed} de {total}",
|
||||
"exercises": "Ejercicios enviados — {count} entregas",
|
||||
"comments": "Comentarios de lección de este estudiante — {count}",
|
||||
"newsfeed": "Publicaciones y respuestas del feed del curso de este estudiante — {count}",
|
||||
"video_progress": "Progreso de visualización de video — {count} lecciones registradas",
|
||||
"attendance": "Registros de asistencia — {count}",
|
||||
"certificate_yes": "Registros de certificación/cumplimiento — sí",
|
||||
"certificate_no": "Registros de certificación/cumplimiento — no"
|
||||
},
|
||||
"warning": "Esto no se puede deshacer.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Eliminar"
|
||||
}
|
||||
},
|
||||
"invite_modal": {
|
||||
"title": "invitar gente",
|
||||
"invite": "Invitar tutores",
|
||||
|
||||
@@ -242,6 +242,10 @@
|
||||
"unpinned": "Désépinglé",
|
||||
"successfully": "Avec succès",
|
||||
"feed_delete_success": "Flux supprimé avec succès"
|
||||
},
|
||||
"people": {
|
||||
"progress_reset": "Progression réinitialisée",
|
||||
"progress_reset_failed": "Échec de la réinitialisation de la progression"
|
||||
}
|
||||
},
|
||||
"landing_page_settings": {
|
||||
@@ -1100,6 +1104,26 @@
|
||||
"no": "Non",
|
||||
"yes": "Oui"
|
||||
},
|
||||
"reset_progress": {
|
||||
"button": "Réinitialiser la progression",
|
||||
"dialog": {
|
||||
"title": "Réinitialiser la progression de {studentName} ?",
|
||||
"intro": "Cela supprime définitivement la progression de cet apprenant dans ce cours. Il restera inscrit et pourra recommencer à la leçon 1.",
|
||||
"bullets": {
|
||||
"lessons": "Leçons terminées — {completed} sur {total}",
|
||||
"exercises": "Exercices soumis — {count} soumissions",
|
||||
"comments": "Commentaires de leçon de cet apprenant — {count}",
|
||||
"newsfeed": "Publications et réponses du fil d’actualité du cours de cet apprenant — {count}",
|
||||
"video_progress": "Progression de visionnage vidéo — {count} leçons suivies",
|
||||
"attendance": "Entrées de présence — {count}",
|
||||
"certificate_yes": "Dossiers de certification/conformité — oui",
|
||||
"certificate_no": "Dossiers de certification/conformité — non"
|
||||
},
|
||||
"warning": "Cette action est irréversible.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer"
|
||||
}
|
||||
},
|
||||
"invite_modal": {
|
||||
"title": "Inviter des personnes",
|
||||
"invite": "Inviter des tuteurs",
|
||||
|
||||
@@ -242,6 +242,10 @@
|
||||
"unpinned": "अनपिन किया गया",
|
||||
"successfully": "सफलतापूर्वक",
|
||||
"feed_delete_success": "फ़ीड सफलतापूर्वक हटा दी गई"
|
||||
},
|
||||
"people": {
|
||||
"progress_reset": "प्रगति रीसेट की गई",
|
||||
"progress_reset_failed": "प्रगति रीसेट नहीं हो सकी"
|
||||
}
|
||||
},
|
||||
"landing_page_settings": {
|
||||
@@ -1100,6 +1104,26 @@
|
||||
"no": "नहीं",
|
||||
"yes": "हाँ"
|
||||
},
|
||||
"reset_progress": {
|
||||
"button": "प्रगति रीसेट करें",
|
||||
"dialog": {
|
||||
"title": "{studentName} की प्रगति रीसेट करें?",
|
||||
"intro": "यह इस कोर्स में इस छात्र की प्रगति को स्थायी रूप से हटा देगा। वे नामांकित रहेंगे और पाठ 1 से फिर से शुरू कर सकेंगे।",
|
||||
"bullets": {
|
||||
"lessons": "पूर्ण पाठ — {completed} में से {total}",
|
||||
"exercises": "जमा किए गए अभ्यास — {count} सबमिशन",
|
||||
"comments": "इस छात्र के पाठ टिप्पणियाँ — {count}",
|
||||
"newsfeed": "इस छात्र की कोर्स न्यूज़फ़ीड पोस्ट और जवाब — {count}",
|
||||
"video_progress": "वीडियो देखने की प्रगति — {count} ट्रैक किए गए पाठ",
|
||||
"attendance": "उपस्थिति प्रविष्टियाँ — {count}",
|
||||
"certificate_yes": "प्रमाणन/अनुपालन रिकॉर्ड — हाँ",
|
||||
"certificate_no": "प्रमाणन/अनुपालन रिकॉर्ड — नहीं"
|
||||
},
|
||||
"warning": "इसे पूर्ववत नहीं किया जा सकता।",
|
||||
"cancel": "रद्द करें",
|
||||
"confirm": "हटाएँ"
|
||||
}
|
||||
},
|
||||
"invite_modal": {
|
||||
"title": "लोगों को आमंत्रित करें",
|
||||
"invite": "शिक्षकों को आमंत्रित करें",
|
||||
|
||||
@@ -242,6 +242,10 @@
|
||||
"unpinned": "Odpięty",
|
||||
"successfully": "Pomyślnie",
|
||||
"feed_delete_success": "Kanał został pomyślnie usunięty"
|
||||
},
|
||||
"people": {
|
||||
"progress_reset": "Postęp został zresetowany",
|
||||
"progress_reset_failed": "Nie udało się zresetować postępu"
|
||||
}
|
||||
},
|
||||
"landing_page_settings": {
|
||||
@@ -1100,6 +1104,26 @@
|
||||
"no": "Nie",
|
||||
"yes": "Tak"
|
||||
},
|
||||
"reset_progress": {
|
||||
"button": "Zresetuj postęp",
|
||||
"dialog": {
|
||||
"title": "Zresetować postęp użytkownika {studentName}?",
|
||||
"intro": "Spowoduje to trwałe usunięcie postępu tego uczestnika w tym kursie. Pozostanie zapisany i będzie mógł zacząć od lekcji 1.",
|
||||
"bullets": {
|
||||
"lessons": "Ukończone lekcje — {completed} z {total}",
|
||||
"exercises": "Przesłane ćwiczenia — {count} zgłoszeń",
|
||||
"comments": "Komentarze do lekcji tego uczestnika — {count}",
|
||||
"newsfeed": "Wpisy i odpowiedzi na kanale kursu tego uczestnika — {count}",
|
||||
"video_progress": "Postęp oglądania wideo — {count} śledzonych lekcji",
|
||||
"attendance": "Wpisy obecności — {count}",
|
||||
"certificate_yes": "Rekordy certyfikacji/zgodności — tak",
|
||||
"certificate_no": "Rekordy certyfikacji/zgodności — nie"
|
||||
},
|
||||
"warning": "Tej operacji nie można cofnąć.",
|
||||
"cancel": "Anuluj",
|
||||
"confirm": "Usuń"
|
||||
}
|
||||
},
|
||||
"invite_modal": {
|
||||
"title": "Zaproś ludzi",
|
||||
"invite": "Zaproś korepetytorów",
|
||||
|
||||
@@ -242,6 +242,10 @@
|
||||
"unpinned": "Desafixado",
|
||||
"successfully": "Com sucesso",
|
||||
"feed_delete_success": "Feed excluído com sucesso"
|
||||
},
|
||||
"people": {
|
||||
"progress_reset": "Progresso redefinido",
|
||||
"progress_reset_failed": "Falha ao redefinir o progresso"
|
||||
}
|
||||
},
|
||||
"landing_page_settings": {
|
||||
@@ -1100,6 +1104,26 @@
|
||||
"no": "Não",
|
||||
"yes": "Sim"
|
||||
},
|
||||
"reset_progress": {
|
||||
"button": "Redefinir progresso",
|
||||
"dialog": {
|
||||
"title": "Redefinir o progresso de {studentName}?",
|
||||
"intro": "Isso exclui permanentemente o progresso deste aluno neste curso. Ele permanecerá matriculado e poderá recomeçar da lição 1.",
|
||||
"bullets": {
|
||||
"lessons": "Lições concluídas — {completed} de {total}",
|
||||
"exercises": "Exercícios enviados — {count} envios",
|
||||
"comments": "Comentários de lição deste aluno — {count}",
|
||||
"newsfeed": "Publicações e respostas no feed do curso deste aluno — {count}",
|
||||
"video_progress": "Progresso de visualização de vídeo — {count} lições acompanhadas",
|
||||
"attendance": "Registros de presença — {count}",
|
||||
"certificate_yes": "Registros de certificação/conformidade — sim",
|
||||
"certificate_no": "Registros de certificação/conformidade — não"
|
||||
},
|
||||
"warning": "Isso não pode ser desfeito.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Excluir"
|
||||
}
|
||||
},
|
||||
"invite_modal": {
|
||||
"title": "Convide pessoas",
|
||||
"invite": "Convidar tutores",
|
||||
|
||||
@@ -242,6 +242,10 @@
|
||||
"unpinned": "Откреплено",
|
||||
"successfully": "Успешно",
|
||||
"feed_delete_success": "Фид успешно удален"
|
||||
},
|
||||
"people": {
|
||||
"progress_reset": "Прогресс сброшен",
|
||||
"progress_reset_failed": "Не удалось сбросить прогресс"
|
||||
}
|
||||
},
|
||||
"landing_page_settings": {
|
||||
@@ -1100,6 +1104,26 @@
|
||||
"no": "Нет",
|
||||
"yes": "Да"
|
||||
},
|
||||
"reset_progress": {
|
||||
"button": "Сбросить прогресс",
|
||||
"dialog": {
|
||||
"title": "Сбросить прогресс для {studentName}?",
|
||||
"intro": "Это навсегда удалит прогресс этого студента в данном курсе. Он останется зачисленным и сможет начать заново с урока 1.",
|
||||
"bullets": {
|
||||
"lessons": "Завершённые уроки — {completed} из {total}",
|
||||
"exercises": "Отправленные упражнения — {count} отправок",
|
||||
"comments": "Комментарии к урокам этого студента — {count}",
|
||||
"newsfeed": "Посты и ответы в ленте курса от этого студента — {count}",
|
||||
"video_progress": "Прогресс просмотра видео — {count} отслеживаемых уроков",
|
||||
"attendance": "Записи посещаемости — {count}",
|
||||
"certificate_yes": "Записи сертификации/соответствия — да",
|
||||
"certificate_no": "Записи сертификации/соответствия — нет"
|
||||
},
|
||||
"warning": "Это действие нельзя отменить.",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить"
|
||||
}
|
||||
},
|
||||
"invite_modal": {
|
||||
"title": "Пригласить людей",
|
||||
"invite": "Пригласить репетиторов",
|
||||
|
||||
@@ -242,6 +242,10 @@
|
||||
"unpinned": "Đã bỏ ghim",
|
||||
"successfully": "thành công",
|
||||
"feed_delete_success": "Đã xóa nguồn cấp dữ liệu thành công"
|
||||
},
|
||||
"people": {
|
||||
"progress_reset": "Đã đặt lại tiến độ",
|
||||
"progress_reset_failed": "Không thể đặt lại tiến độ"
|
||||
}
|
||||
},
|
||||
"landing_page_settings": {
|
||||
@@ -1100,6 +1104,26 @@
|
||||
"no": "Không",
|
||||
"yes": "Có"
|
||||
},
|
||||
"reset_progress": {
|
||||
"button": "Đặt lại tiến độ",
|
||||
"dialog": {
|
||||
"title": "Đặt lại tiến độ cho {studentName}?",
|
||||
"intro": "Thao tác này xóa vĩnh viễn tiến độ của học viên này trong khóa học. Họ vẫn được ghi danh và có thể bắt đầu lại từ bài 1.",
|
||||
"bullets": {
|
||||
"lessons": "Bài học đã hoàn thành — {completed}/{total}",
|
||||
"exercises": "Bài tập đã nộp — {count} bài nộp",
|
||||
"comments": "Bình luận bài học của học viên này — {count}",
|
||||
"newsfeed": "Bài đăng và phản hồi bảng tin khóa học của học viên này — {count}",
|
||||
"video_progress": "Tiến độ xem video — {count} bài học được theo dõi",
|
||||
"attendance": "Mục điểm danh — {count}",
|
||||
"certificate_yes": "Hồ sơ chứng nhận/tuân thủ — có",
|
||||
"certificate_no": "Hồ sơ chứng nhận/tuân thủ — không"
|
||||
},
|
||||
"warning": "Không thể hoàn tác.",
|
||||
"cancel": "Hủy",
|
||||
"confirm": "Xóa"
|
||||
}
|
||||
},
|
||||
"invite_modal": {
|
||||
"title": "Mời mọi người",
|
||||
"invite": "Mời gia sư",
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import ResetProgressButton from '$features/course/components/people/reset-progress-button.svelte';
|
||||
import type { UserCourseAnalytics } from '$features/course/utils/types';
|
||||
|
||||
let { data = $bindable(), children } = $props();
|
||||
|
||||
let userCourseAnalytics = $derived(page.data.userCourseAnalytics as UserCourseAnalytics | null | undefined);
|
||||
|
||||
// Get back URL from query parameters
|
||||
let backUrl = $derived(page.url.searchParams.get('back'));
|
||||
|
||||
@@ -31,6 +35,10 @@
|
||||
async function refreshPeoplePage() {
|
||||
await Promise.all([courseApi.refreshCourse(data.courseId, $profile.id), invalidateAll()]);
|
||||
}
|
||||
|
||||
async function handleProgressReset() {
|
||||
await invalidateAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
|
||||
@@ -49,6 +57,16 @@
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if data.personId && userCourseAnalytics}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<ResetProgressButton
|
||||
courseId={data.courseId}
|
||||
personId={data.personId}
|
||||
{userCourseAnalytics}
|
||||
onSuccess={handleProgressReset}
|
||||
/>
|
||||
</RoleBasedSecurity>
|
||||
{/if}
|
||||
{#if !data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Button onclick={handleClick}>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
import type { GetUserCourseAnalyticsSuccess, UserCourseAnalytics } from '$features/course/utils/types';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
const personId = params.personId || '';
|
||||
|
||||
if (!courseId || !personId) {
|
||||
return {
|
||||
userCourseAnalytics: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await safeServerApi<GetUserCourseAnalyticsSuccess>(() =>
|
||||
classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
|
||||
{
|
||||
param: { courseId, userId: personId },
|
||||
query: { includeProgressImpact: 'true' }
|
||||
},
|
||||
getApiHeaders(cookies, '')
|
||||
)
|
||||
);
|
||||
const userCourseAnalytics: UserCourseAnalytics | null = result.ok && result.body.data ? result.body.data : null;
|
||||
|
||||
return {
|
||||
userCourseAnalytics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user course analytics:', error);
|
||||
return {
|
||||
userCourseAnalytics: null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,42 +1,9 @@
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
export const load = async ({ parent }) => {
|
||||
const parentData = await parent();
|
||||
|
||||
import type { GetUserCourseAnalyticsSuccess, UserCourseAnalytics } from '$features/course/utils/types';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
const userId = params.personId;
|
||||
|
||||
if (!courseId || !userId) {
|
||||
return {
|
||||
courseId,
|
||||
userId,
|
||||
userCourseAnalytics: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await safeServerApi<GetUserCourseAnalyticsSuccess>(() =>
|
||||
classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
|
||||
{
|
||||
param: { courseId, userId }
|
||||
},
|
||||
getApiHeaders(cookies, '')
|
||||
)
|
||||
);
|
||||
const userCourseAnalytics: UserCourseAnalytics | null = result.ok && result.body.data ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
userId,
|
||||
userCourseAnalytics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user course analytics:', error);
|
||||
return {
|
||||
courseId,
|
||||
userId,
|
||||
userCourseAnalytics: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
courseId: parentData.courseId,
|
||||
userId: parentData.personId,
|
||||
userCourseAnalytics: parentData.userCourseAnalytics
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './content';
|
||||
export * from './progression';
|
||||
export * from './content-batch';
|
||||
export * from './people';
|
||||
export * from './reset-progress';
|
||||
export * from './invite';
|
||||
export * from './public-course';
|
||||
export * from './session';
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, count, eq, inArray, sql } from '@db/drizzle';
|
||||
import type { DbOrTxClient } from '@db/drizzle';
|
||||
import { db } from '@db/drizzle';
|
||||
|
||||
export type StudentCourseProgressImpactCounts = {
|
||||
completedLessons: number;
|
||||
totalLessons: number;
|
||||
exerciseSubmissions: number;
|
||||
lessonComments: number;
|
||||
courseNewsfeedActivity: number;
|
||||
videoProgressLessons: number;
|
||||
attendanceEntries: number;
|
||||
hasCertificationRecords: boolean;
|
||||
};
|
||||
|
||||
export type ResetStudentCourseProgressSummary = {
|
||||
lessonCompletion: number;
|
||||
lessonVideoProgress: number;
|
||||
submissions: number;
|
||||
groupAttendance: number;
|
||||
lessonComments: number;
|
||||
courseNewsfeed: number;
|
||||
courseNewsfeedComments: number;
|
||||
courseCertificateIssues: number;
|
||||
courseCompletionRecords: number;
|
||||
aiChatConversations: number;
|
||||
};
|
||||
|
||||
async function getCourseLessonIds(tx: DbOrTxClient, courseId: string): Promise<string[]> {
|
||||
const lessons = await tx
|
||||
.select({ id: schema.lesson.id })
|
||||
.from(schema.lesson)
|
||||
.where(eq(schema.lesson.courseId, courseId));
|
||||
|
||||
return lessons.map((lesson) => lesson.id);
|
||||
}
|
||||
|
||||
async function getCourseNewsfeedIds(tx: DbOrTxClient, courseId: string): Promise<string[]> {
|
||||
const newsfeeds = await tx
|
||||
.select({ id: schema.courseNewsfeed.id })
|
||||
.from(schema.courseNewsfeed)
|
||||
.where(eq(schema.courseNewsfeed.courseId, courseId));
|
||||
|
||||
return newsfeeds.map((newsfeed) => newsfeed.id);
|
||||
}
|
||||
|
||||
async function getStudentCourseNewsfeedPostIds(
|
||||
tx: DbOrTxClient,
|
||||
courseId: string,
|
||||
groupMemberId: string
|
||||
): Promise<string[]> {
|
||||
const posts = await tx
|
||||
.select({ id: schema.courseNewsfeed.id })
|
||||
.from(schema.courseNewsfeed)
|
||||
.where(and(eq(schema.courseNewsfeed.courseId, courseId), eq(schema.courseNewsfeed.authorId, groupMemberId)));
|
||||
|
||||
return posts.map((post) => post.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns counts of progress-like data that would be removed by a course reset.
|
||||
*/
|
||||
export async function getStudentCourseProgressImpactCounts(input: {
|
||||
courseId: string;
|
||||
groupMemberId: string;
|
||||
profileId: string;
|
||||
}): Promise<StudentCourseProgressImpactCounts> {
|
||||
const { courseId, groupMemberId, profileId } = input;
|
||||
|
||||
try {
|
||||
const lessonIds = await getCourseLessonIds(db, courseId);
|
||||
const newsfeedIds = await getCourseNewsfeedIds(db, courseId);
|
||||
|
||||
const lessonScope =
|
||||
lessonIds.length > 0
|
||||
? and(eq(schema.lessonCompletion.profileId, profileId), inArray(schema.lessonCompletion.lessonId, lessonIds))
|
||||
: sql`false`;
|
||||
|
||||
const [completedLessonsResult, totalLessonsResult] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(schema.lessonCompletion)
|
||||
.where(and(lessonScope, eq(schema.lessonCompletion.isComplete, true))),
|
||||
db.select({ total: count() }).from(schema.lesson).where(eq(schema.lesson.courseId, courseId))
|
||||
]);
|
||||
|
||||
const [exerciseSubmissionsResult, lessonCommentsResult, newsfeedPostsResult, newsfeedCommentsResult] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(schema.submission)
|
||||
.where(and(eq(schema.submission.courseId, courseId), eq(schema.submission.submittedBy, groupMemberId))),
|
||||
lessonIds.length > 0
|
||||
? db
|
||||
.select({ total: count() })
|
||||
.from(schema.lessonComment)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lessonComment.groupmemberId, groupMemberId),
|
||||
inArray(schema.lessonComment.lessonId, lessonIds)
|
||||
)
|
||||
)
|
||||
: Promise.resolve([{ total: 0 }]),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(schema.courseNewsfeed)
|
||||
.where(and(eq(schema.courseNewsfeed.courseId, courseId), eq(schema.courseNewsfeed.authorId, groupMemberId))),
|
||||
newsfeedIds.length > 0
|
||||
? db
|
||||
.select({ total: count() })
|
||||
.from(schema.courseNewsfeedComment)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.courseNewsfeedComment.authorId, groupMemberId),
|
||||
inArray(schema.courseNewsfeedComment.courseNewsfeedId, newsfeedIds)
|
||||
)
|
||||
)
|
||||
: Promise.resolve([{ total: 0 }])
|
||||
]);
|
||||
|
||||
const [videoProgressResult, attendanceResult, certificationResult] = await Promise.all([
|
||||
lessonIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
total: sql<number>`COUNT(DISTINCT ${schema.lessonVideoProgress.lessonId})::int`.as('total')
|
||||
})
|
||||
.from(schema.lessonVideoProgress)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lessonVideoProgress.profileId, profileId),
|
||||
inArray(schema.lessonVideoProgress.lessonId, lessonIds)
|
||||
)
|
||||
)
|
||||
: Promise.resolve([{ total: 0 }]),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(schema.groupAttendance)
|
||||
.where(and(eq(schema.groupAttendance.courseId, courseId), eq(schema.groupAttendance.studentId, groupMemberId))),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(schema.courseCompletionRecord)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.courseCompletionRecord.courseId, courseId),
|
||||
eq(schema.courseCompletionRecord.profileId, profileId)
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
const courseNewsfeedActivity = (newsfeedPostsResult[0]?.total ?? 0) + (newsfeedCommentsResult[0]?.total ?? 0);
|
||||
|
||||
const certificateIssuesResult = await db
|
||||
.select({ total: count() })
|
||||
.from(schema.courseCertificateIssue)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.courseCertificateIssue.courseId, courseId),
|
||||
eq(schema.courseCertificateIssue.profileId, profileId)
|
||||
)
|
||||
);
|
||||
|
||||
const certificationCount = (certificationResult[0]?.total ?? 0) + (certificateIssuesResult[0]?.total ?? 0);
|
||||
|
||||
return {
|
||||
completedLessons: completedLessonsResult[0]?.total ?? 0,
|
||||
totalLessons: totalLessonsResult[0]?.total ?? 0,
|
||||
exerciseSubmissions: exerciseSubmissionsResult[0]?.total ?? 0,
|
||||
lessonComments: lessonCommentsResult[0]?.total ?? 0,
|
||||
courseNewsfeedActivity,
|
||||
videoProgressLessons: videoProgressResult[0]?.total ?? 0,
|
||||
attendanceEntries: attendanceResult[0]?.total ?? 0,
|
||||
hasCertificationRecords: certificationCount > 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getStudentCourseProgressImpactCounts error:', error);
|
||||
throw new Error(
|
||||
`Failed to get student course progress impact counts: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all learner progress for one student in one course inside a single transaction.
|
||||
*/
|
||||
export async function resetStudentCourseProgress(input: {
|
||||
courseId: string;
|
||||
groupMemberId: string;
|
||||
profileId: string;
|
||||
}): Promise<ResetStudentCourseProgressSummary> {
|
||||
const { courseId, groupMemberId, profileId } = input;
|
||||
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const lessonIds = await getCourseLessonIds(tx, courseId);
|
||||
const newsfeedIds = await getCourseNewsfeedIds(tx, courseId);
|
||||
const studentNewsfeedPostIds = await getStudentCourseNewsfeedPostIds(tx, courseId, groupMemberId);
|
||||
|
||||
const deletedCommentsOnStudentPosts =
|
||||
studentNewsfeedPostIds.length > 0
|
||||
? await tx
|
||||
.delete(schema.courseNewsfeedComment)
|
||||
.where(inArray(schema.courseNewsfeedComment.courseNewsfeedId, studentNewsfeedPostIds))
|
||||
.returning({ id: schema.courseNewsfeedComment.id })
|
||||
: [];
|
||||
|
||||
const deletedStudentNewsfeedComments =
|
||||
newsfeedIds.length > 0
|
||||
? await tx
|
||||
.delete(schema.courseNewsfeedComment)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.courseNewsfeedComment.authorId, groupMemberId),
|
||||
inArray(schema.courseNewsfeedComment.courseNewsfeedId, newsfeedIds)
|
||||
)
|
||||
)
|
||||
.returning({ id: schema.courseNewsfeedComment.id })
|
||||
: [];
|
||||
|
||||
const deletedNewsfeedPosts = await tx
|
||||
.delete(schema.courseNewsfeed)
|
||||
.where(and(eq(schema.courseNewsfeed.courseId, courseId), eq(schema.courseNewsfeed.authorId, groupMemberId)))
|
||||
.returning({ id: schema.courseNewsfeed.id });
|
||||
|
||||
const deletedSubmissions = await tx
|
||||
.delete(schema.submission)
|
||||
.where(and(eq(schema.submission.courseId, courseId), eq(schema.submission.submittedBy, groupMemberId)))
|
||||
.returning({ id: schema.submission.id });
|
||||
|
||||
const deletedLessonCompletions =
|
||||
lessonIds.length > 0
|
||||
? await tx
|
||||
.delete(schema.lessonCompletion)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lessonCompletion.profileId, profileId),
|
||||
inArray(schema.lessonCompletion.lessonId, lessonIds)
|
||||
)
|
||||
)
|
||||
.returning({ id: schema.lessonCompletion.id })
|
||||
: [];
|
||||
|
||||
const deletedVideoProgress =
|
||||
lessonIds.length > 0
|
||||
? await tx
|
||||
.delete(schema.lessonVideoProgress)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lessonVideoProgress.profileId, profileId),
|
||||
inArray(schema.lessonVideoProgress.lessonId, lessonIds)
|
||||
)
|
||||
)
|
||||
.returning({ id: schema.lessonVideoProgress.id })
|
||||
: [];
|
||||
|
||||
const deletedAttendance = await tx
|
||||
.delete(schema.groupAttendance)
|
||||
.where(and(eq(schema.groupAttendance.courseId, courseId), eq(schema.groupAttendance.studentId, groupMemberId)))
|
||||
.returning({ id: schema.groupAttendance.id });
|
||||
|
||||
const deletedLessonComments =
|
||||
lessonIds.length > 0
|
||||
? await tx
|
||||
.delete(schema.lessonComment)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lessonComment.groupmemberId, groupMemberId),
|
||||
inArray(schema.lessonComment.lessonId, lessonIds)
|
||||
)
|
||||
)
|
||||
.returning({ id: schema.lessonComment.id })
|
||||
: [];
|
||||
|
||||
const deletedCompletionRecords = await tx
|
||||
.delete(schema.courseCompletionRecord)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.courseCompletionRecord.courseId, courseId),
|
||||
eq(schema.courseCompletionRecord.profileId, profileId)
|
||||
)
|
||||
)
|
||||
.returning({ id: schema.courseCompletionRecord.id });
|
||||
|
||||
const deletedCertificateIssues = await tx
|
||||
.delete(schema.courseCertificateIssue)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.courseCertificateIssue.courseId, courseId),
|
||||
eq(schema.courseCertificateIssue.profileId, profileId)
|
||||
)
|
||||
)
|
||||
.returning({ id: schema.courseCertificateIssue.id });
|
||||
|
||||
const deletedAiChatConversations = await tx
|
||||
.delete(schema.aiChatConversation)
|
||||
.where(and(eq(schema.aiChatConversation.courseId, courseId), eq(schema.aiChatConversation.userId, profileId)))
|
||||
.returning({ id: schema.aiChatConversation.id });
|
||||
|
||||
await tx
|
||||
.update(schema.groupmember)
|
||||
.set({
|
||||
certificateEarnedAt: null,
|
||||
certificationEmailSentAt: null
|
||||
})
|
||||
.where(eq(schema.groupmember.id, groupMemberId));
|
||||
|
||||
return {
|
||||
lessonCompletion: deletedLessonCompletions.length,
|
||||
lessonVideoProgress: deletedVideoProgress.length,
|
||||
submissions: deletedSubmissions.length,
|
||||
groupAttendance: deletedAttendance.length,
|
||||
lessonComments: deletedLessonComments.length,
|
||||
courseNewsfeed: deletedNewsfeedPosts.length,
|
||||
courseNewsfeedComments: deletedCommentsOnStudentPosts.length + deletedStudentNewsfeedComments.length,
|
||||
courseCertificateIssues: deletedCertificateIssues.length,
|
||||
courseCompletionRecords: deletedCompletionRecords.length,
|
||||
aiChatConversations: deletedAiChatConversations.length
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('resetStudentCourseProgress error:', error);
|
||||
throw new Error(
|
||||
`Failed to reset student course progress: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -468,3 +468,12 @@ export const ZCourseUserAnalyticsParam = z.object({
|
||||
userId: z.string().uuid()
|
||||
});
|
||||
export type TCourseUserAnalyticsParam = z.infer<typeof ZCourseUserAnalyticsParam>;
|
||||
|
||||
export const ZCourseUserAnalyticsQuery = z.object({
|
||||
includeProgressImpact: z
|
||||
.enum(['true', 'false'])
|
||||
.optional()
|
||||
.default('false')
|
||||
.transform((value) => value === 'true')
|
||||
});
|
||||
export type TCourseUserAnalyticsQuery = z.infer<typeof ZCourseUserAnalyticsQuery>;
|
||||
|
||||
@@ -26,3 +26,9 @@ export const ZUpdateCourseMember = z.object({
|
||||
email: z.email().optional()
|
||||
});
|
||||
export type TUpdateCourseMember = z.infer<typeof ZUpdateCourseMember>;
|
||||
|
||||
export const ZResetCourseMemberProgressParam = z.object({
|
||||
courseId: z.string().uuid(),
|
||||
memberId: z.string().uuid()
|
||||
});
|
||||
export type TResetCourseMemberProgressParam = z.infer<typeof ZResetCourseMemberProgressParam>;
|
||||
|
||||
Reference in New Issue
Block a user