Reset Student Course Progress
Purpose
Give course admins/tutors a safe, well-scoped way to clear a single student's progress in a single course from the student's profile page (Course → People → student detail). This is intended for cases like:
- Re-running a learner through a course after a failed cohort attempt.
- Letting a learner retake a compliance/certification cycle from scratch.
- Cleaning up test data accidentally produced by a real account.
This PRD documents only the new behavior; it does not modify the existing
"remove member from course" flow (that is destructive and removes the
groupmember row).
Scope
In scope:
- A Reset progress action on the course people detail page (
/courses/[id]/people/[personId]). - A confirmation dialog (using
Dialog.Footer) that explicitly lists what will be deleted, and offers Cancel / Delete actions. - A backend endpoint that performs the reset for one
(courseId, profileId)pair inside a single DB transaction. - Telemetry / audit logging so we can trace who reset whom.
Out of scope (for v1):
- Bulk reset (multiple students at once).
- Resetting only a subset of progress (e.g. only exercises). v1 is "reset everything for this student in this course".
- Restoring/undoing a reset. The action is final.
- Resetting program-level goal assignments. Program goals re-evaluate from
course-level state, so clearing course progress will indirectly recompute
program goal status on the next evaluation; we do not delete
program_goal_assignmentrows.
User Story
As a course admin or tutor, when I open a student in Course → People → [student], I want a clearly labeled button to clear that student's progress in this course (lesson completion, video progress, exercise submissions, comments, certificate state, …). Before anything is deleted I want a confirmation modal that spells out exactly what will be removed, with a Cancel and a destructive Delete action. The student should remain enrolled (still appear in the People list) so they can restart the course.
UX Flow
- Admin/tutor opens
/courses/:courseId/people/:personId. - In the page header (next to the existing back/refresh actions in the people
layout), a new destructive-styled Reset progress button is shown.
- Only visible for roles
ADMIN(1) andTUTOR(2) — gated via the existingRoleBasedSecuritywrapper used inroutes/(app)/courses/[id]/people/+layout.svelte. - The button is hidden / disabled if the analytics fail to load (we do not want to allow reset without seeing the impact).
- Only visible for roles
- Clicking the button opens a Reset progress dialog.
- Title: e.g. "Reset progress for {studentName}?"
- Body:
- One sentence framing: "This permanently deletes this student's progress in this course. They will stay enrolled and can restart from lesson 1."
- A bullet list spelling out what will be deleted, populated with the
counts from the loaded analytics (so the teacher sees real numbers):
- Lessons completed — N of M
- Exercises submitted — N submissions
- Lesson comments by this student — N
- Newsfeed and community posts/replies by this student — N
- Video watch progress — N lessons tracked
- Attendance entries — N
- Certification / compliance records — yes / no
- A small destructive callout: "This cannot be undone."
Dialog.Footer:- Cancel —
variant="outline", closes the dialog. - Delete —
variant="destructive", runs the reset and shows a loading state. On success the dialog closes and a snackbar confirms "Progress reset"; the page reloads the analytics so the counts and UI reflect the cleared state.
- Cancel —
- After reset, the student profile page re-renders with empty progress
(0% complete, 0 exercises, no completed lessons, etc.) but the user is
still listed in
Course → People.
What Gets Deleted
The reset is scoped to a single (courseId, profileId) pair. The
corresponding groupmember row in this course is identified by joining
groupmember → course on course.group_id and using the page's
personId (which today is a profile.id).
The following rows are deleted inside a single transaction:
Direct learner progress
lesson_completionwhereprofile_id = student.profileIdandlesson_idbelongs to alessonwhosecourse_id = courseId.lesson_video_progresswhereprofile_id = student.profileIdandlesson_idbelongs to alessonwhosecourse_id = courseId.submissionwherecourse_id = courseIdandsubmitted_by = groupmember.id(the student'sgroupmember.idfor this course).- Cascades take care of related
question_answerrows that are linked to submissions via existing FKs.
- Cascades take care of related
group_attendancewherecourse_id = courseIdandstudent_id = groupmember.id.
Student-authored social content tied to this course
lesson_commentwheregroupmember_id = student.groupmember.id.course_newsfeedrows authored by the student in this course (course_id = courseIdANDauthor_id = student.profileId).course_newsfeed_commentauthored by the student on newsfeed posts that belong to this course (join throughcourse_newsfeed.course_id).community_questionandcommunity_answerauthored by the student in this course (community questions filter oncourse_id; community answers filter via the question'scourse_id).
Certification & compliance state (course-scoped)
course_certificate_issuefor(course_id = courseId, profile_id = student.profileId).course_completion_notification_eventcascaded viacourse_completion_recordfor(course_id, profile_id).course_completion_recordfor(course_id = courseId, profile_id = student.profileId).groupmember.certificate_earned_atandgroupmember.certification_email_sent_atare reset toNULLfor the student's groupmember row in this course.
AI / chat state scoped to this course
ai_chat_conversationrows for(course_id = courseId, user_id = student.profileId).- Connected
ai_chat_documentetc. fall away through their FKs if any; if no cascade is wired we delete dependents explicitly inside the transaction.
- Connected
Explicitly NOT deleted
groupmemberitself (the student stays enrolled).profile/user/organization_memberrows.course_invitehistory and invite audit logs.- Program-level rows:
program_member,program_goal,program_goal_assignment. Program goals are re-evaluated from course state on their next run, so the goal status will naturally update. - AI tutor monthly counters (
ai_tutor_message_count,ai_tutor_cap_event). Those are billing/abuse signals, not progress. - Org-wide analytics aggregates (
analytics_course_daily, etc.). Those are point-in-time aggregates and should not be retroactively rewritten.
The PRD update step during implementation must add any newly introduced "progress-like" tables to this list and to the reset transaction.
Implementation Plan
This follows the route conventions in AGENTS.md.
1. Validation schema
packages/utils/src/validation/course/people.ts:
export const ZResetCourseMemberProgressParam = z.object({
courseId: z.string().uuid(),
memberId: z.string().uuid()
});
export type TResetCourseMemberProgressParam = z.infer<
typeof ZResetCourseMemberProgressParam
>;
We key the route on memberId (the groupmember.id) to stay consistent
with the existing PUT /course/:courseId/members/:memberId and DELETE /course/:courseId/members/:memberId shape. The service resolves the
profileId server-side.
2. DB queries
New file packages/db/src/queries/course/reset-progress.ts with a single
exported function:
export async function resetStudentCourseProgress(input: {
courseId: string;
groupMemberId: string;
profileId: string;
}): Promise<ResetStudentCourseProgressSummary>;
- Runs everything inside
db.transaction(async (tx) => { ... }). - Performs the deletes listed in What Gets Deleted above.
- Returns a small summary object with the row counts deleted per table so the service can log it and the API can return it for the snackbar / audit trail.
- Each delete is implemented with explicit, narrow
where(...)filters joined through the schema so we cannot accidentally delete rows from another course. - Wrapped in
try/catchwithconsole.error('resetStudentCourseProgress error:', error)per repo convention.
3. Service
apps/api/src/services/course/people.ts gets a new function:
export async function resetMemberCourseProgress(
courseId: string,
memberId: string
): Promise<ResetStudentCourseProgressSummary>;
- Loads the member via
getCourseMember(courseId, memberId). - Throws
AppError('Course member not found', ErrorCodes.NOT_FOUND, 404)if missing or ifprofileIdis null (invite-only ghost member — nothing to reset). - Calls
resetStudentCourseProgress({ courseId, groupMemberId: memberId, profileId: member.profileId }). - Logs
{ actorProfileId, courseId, memberId, summary }so audit grep is easy.
4. Route
In apps/api/src/routes/course/people.ts, mount a new endpoint:
POST /course/:courseId/members/:memberId/reset-progress
- Uses
courseTeamMemberMiddleware(admin/tutor only) — same gate as the other destructive member routes. - Validates params with
ZResetCourseMemberProgressParam. - Calls
resetMemberCourseProgress(...). - Returns
{ success: true, data: summary }on success and useshandleError(c, error, 'Failed to reset course member progress')in the catch.
Method choice: POST (rather than DELETE) because the action is
state-changing on multiple tables and we want the body to carry a
summary response. A DELETE on …/progress would technically work but
clients (and Hono RPC type inference) handle POST … success/data more
cleanly.
5. Register
The membersRouter is already mounted under /course/:courseId/members.
No changes to app.ts are needed.
6. Build & frontend types
After pnpm --filter @cio/api build, add to
apps/dashboard/src/lib/features/course/utils/types.ts:
export type ResetMemberCourseProgressRequest =
(typeof classroomio.course)[':courseId']['members'][':memberId']['reset-progress']['$post'];
7. Frontend API class
Add a method to apps/dashboard/src/lib/features/course/api/people.svelte.ts:
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');
}
}
});
}
The page that calls this also invalidates +page.server.ts so the
analytics are re-fetched after success.
8. UI
Two new files under
apps/dashboard/src/lib/features/course/components/people/:
reset-progress-button.svelte— a destructive-styledButtonthat opens the dialog. UsesRoleBasedSecurity allowedRoles={[1, 2]}.reset-progress-dialog.svelte— theDialog.Rootconfirmation modal.- Uses
Dialog.Header(title + description), a list of impact bullets built fromdata.userCourseAnalytics, andDialog.Footerwith Cancel (variant="outline") and Delete (variant="destructive"). - Disables the Delete button while
peopleApi.isLoadingis true and shows a spinner on the button. Closes the dialog and snackbars on completion.
- Uses
Mount points:
-
The Reset progress button is rendered in the people layout when
data.personIdis present (so it appears next to the back arrow and refresh action). The dialog itself can live alongside it.Alternative: place the button inside the person
+page.sveltenext to the hero card. Layout placement keeps the destructive action visually grouped with the rest of the per-person actions and matches where the back/refresh controls already are.Decision: layout placement, so the action is consistently positioned for every person detail view.
9. Translations
Add the following keys to
apps/dashboard/src/lib/utils/translations/en.json (exact text TBD with
copy review):
course.navItem.people.reset_progress.buttoncourse.navItem.people.reset_progress.dialog.titlecourse.navItem.people.reset_progress.dialog.introcourse.navItem.people.reset_progress.dialog.bullets.lessonscourse.navItem.people.reset_progress.dialog.bullets.exercisescourse.navItem.people.reset_progress.dialog.bullets.commentscourse.navItem.people.reset_progress.dialog.bullets.communitycourse.navItem.people.reset_progress.dialog.bullets.video_progresscourse.navItem.people.reset_progress.dialog.bullets.attendancecourse.navItem.people.reset_progress.dialog.bullets.certificatecourse.navItem.people.reset_progress.dialog.warningcourse.navItem.people.reset_progress.dialog.cancelcourse.navItem.people.reset_progress.dialog.confirmsnackbar.course.people.progress_resetsnackbar.course.people.progress_reset_failed
Bullet copy uses {count} placeholders (e.g. "Delete {count} exercise
submissions") and is fed from the analytics that already power the
person page. Per repo workflow, after editing en.json run cd apps/dashboard && pnpm translate and review the generated locale files
to make sure {count} placeholders survived.
Authorization & Safety
- Auth: the route is behind
courseTeamMemberMiddleware. Only course admins/tutors can call it. The UI button is gated byRoleBasedSecurity allowedRoles={[1, 2]}to match. - Confirmation: destructive action is gated behind a modal with an explicit "This cannot be undone." warning and a Delete (not "OK") action button.
- Idempotency: re-running the action on an already-cleared student is
a no-op (all
deletequeries simply match zero rows). - Atomicity: all deletes happen inside one DB transaction. Either the student's whole course state is cleared or nothing is.
- Audit: server logs
{ actorProfileId, courseId, memberId, summary }on every successful reset. We do not (in v1) write a new audit row; if the team wants persistent auditing we can add acourse_audit_loglater (see Future Work).
Testing
- Unit (
packages/db): a Vitest test that seeds a course with two students, fills in lessons completed / submissions / lesson comments / newsfeed comments / certificate / completion record for both, callsresetStudentCourseProgressfor one, and asserts:- The targeted student has 0 rows left in every affected table for this course.
- The other student is untouched.
- Rows from a second course owned by the same student are untouched.
groupmember.certificate_earned_atandcertification_email_sent_atareNULLfor the targeted student in this course.groupmemberrow itself is still present.
- Service: returns 404
AppErrorwhen called with an unknownmemberIdor with a member whoseprofileIdis null. - API integration: a request from a non-team member returns 403 via the existing middleware; a request from an admin returns 200 and the expected summary shape.
- Dashboard (Playwright/manual): open a populated student profile, click Reset progress, confirm the modal, verify analytics show zeroed counts after refresh.
Future Work
- Bulk reset across selected students.
- "Reset cycle only" mode for compliance courses with cycles — clear the
current cycle but keep prior
course_completion_recordrows for history. - Persistent audit log surfaced in the org admin panel.
- Granular reset (e.g. "only exercise submissions" or "only lesson progress") if user research shows demand.