diff --git a/PRD-TRACKER.md b/PRD-TRACKER.md index c0fa5065..2c66a11f 100644 --- a/PRD-TRACKER.md +++ b/PRD-TRACKER.md @@ -21,6 +21,7 @@ Use this file to keep a single prioritized view of all PRDs. | - | - | `prd/customer-course-documentation` | todo | | - | - | `prd/walkthrough-widget` | draft | | - | - | `prd/copy-page-as-markdown` | todo | +| - | - | `prd/reset-student-course-progress` | todo | | - | 15 | `prd/webhooks` | todo | ## DONE (Completed PRDs) diff --git a/prd/reset-student-course-progress/README.md b/prd/reset-student-course-progress/README.md new file mode 100644 index 00000000..ecf03739 --- /dev/null +++ b/prd/reset-student-course-progress/README.md @@ -0,0 +1,392 @@ +# 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.