# 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_assignment` rows. ## 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 1. Admin/tutor opens `/courses/:courseId/people/:personId`. 2. 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`) and `TUTOR` (`2`) — gated via the existing `RoleBasedSecurity` wrapper used in `routes/(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). 3. 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. 4. 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_completion` where `profile_id = student.profileId` and `lesson_id` belongs to a `lesson` whose `course_id = courseId`. - `lesson_video_progress` where `profile_id = student.profileId` and `lesson_id` belongs to a `lesson` whose `course_id = courseId`. - `submission` where `course_id = courseId` and `submitted_by = groupmember.id` (the student's `groupmember.id` for **this** course). - Cascades take care of related `question_answer` rows that are linked to submissions via existing FKs. - `group_attendance` where `course_id = courseId` and `student_id = groupmember.id`. ### Student-authored social content tied to this course - `lesson_comment` where `groupmember_id = student.groupmember.id`. - `course_newsfeed` rows authored by the student in this course (`course_id = courseId` AND `author_id = student.profileId`). - `course_newsfeed_comment` authored by the student on newsfeed posts that belong to this course (join through `course_newsfeed.course_id`). - `community_question` and `community_answer` authored by the student in this course (community questions filter on `course_id`; community answers filter via the question's `course_id`). ### Certification & compliance state (course-scoped) - `course_certificate_issue` for `(course_id = courseId, profile_id = student.profileId)`. - `course_completion_notification_event` cascaded via `course_completion_record` for `(course_id, profile_id)`. - `course_completion_record` for `(course_id = courseId, profile_id = student.profileId)`. - `groupmember.certificate_earned_at` and `groupmember.certification_email_sent_at` are reset to `NULL` for the student's groupmember row in this course. ### AI / chat state scoped to this course - `ai_chat_conversation` rows for `(course_id = courseId, user_id = student.profileId)`. - Connected `ai_chat_document` etc. fall away through their FKs if any; if no cascade is wired we delete dependents explicitly inside the transaction. ### Explicitly NOT deleted - `groupmember` itself (the student stays enrolled). - `profile` / `user` / `organization_member` rows. - `course_invite` history 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`: ```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: ```ts export async function resetStudentCourseProgress(input: { courseId: string; groupMemberId: string; profileId: string; }): Promise; ``` - 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/catch` with `console.error('resetStudentCourseProgress error:', error)` per repo convention. ### 3. Service `apps/api/src/services/course/people.ts` gets a new function: ```ts export async function resetMemberCourseProgress( courseId: string, memberId: string ): Promise; ``` - Loads the member via `getCourseMember(courseId, memberId)`. - Throws `AppError('Course member not found', ErrorCodes.NOT_FOUND, 404)` if missing or if `profileId` is 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 uses `handleError(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`: ```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`: ```ts async resetCourseProgress(courseId: string, memberId: string) { await this.execute({ 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-styled `Button` that opens the dialog. Uses `RoleBasedSecurity allowedRoles={[1, 2]}`. - `reset-progress-dialog.svelte` — the `Dialog.Root` confirmation modal. - Uses `Dialog.Header` (title + description), a list of impact bullets built from `data.userCourseAnalytics`, and `Dialog.Footer` with **Cancel** (`variant="outline"`) and **Delete** (`variant="destructive"`). - Disables the **Delete** button while `peopleApi.isLoading` is true and shows a spinner on the button. Closes the dialog and snackbars on completion. Mount points: - The **Reset progress** button is rendered in the people **layout** when `data.personId` is 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.svelte` next 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.button` - `course.navItem.people.reset_progress.dialog.title` - `course.navItem.people.reset_progress.dialog.intro` - `course.navItem.people.reset_progress.dialog.bullets.lessons` - `course.navItem.people.reset_progress.dialog.bullets.exercises` - `course.navItem.people.reset_progress.dialog.bullets.comments` - `course.navItem.people.reset_progress.dialog.bullets.community` - `course.navItem.people.reset_progress.dialog.bullets.video_progress` - `course.navItem.people.reset_progress.dialog.bullets.attendance` - `course.navItem.people.reset_progress.dialog.bullets.certificate` - `course.navItem.people.reset_progress.dialog.warning` - `course.navItem.people.reset_progress.dialog.cancel` - `course.navItem.people.reset_progress.dialog.confirm` - `snackbar.course.people.progress_reset` - `snackbar.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 by `RoleBasedSecurity 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 `delete` queries 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 a `course_audit_log` later (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, calls `resetStudentCourseProgress` for 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_at` and `certification_email_sent_at` are `NULL` for the targeted student in this course. - `groupmember` row itself is still present. - **Service**: returns 404 `AppError` when called with an unknown `memberId` or with a member whose `profileId` is 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_record` rows 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.