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
);
}
}
@@ -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}
@@ -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
};
};