docs(prd): add reset student course progress plan (#670)

This commit is contained in:
Rotimi Best
2026-06-17 20:23:00 +01:00
committed by GitHub
parent f6bd0a5b1a
commit ab91056a68
2 changed files with 393 additions and 0 deletions
+1
View File
@@ -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)
+392
View File
@@ -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<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/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<ResetStudentCourseProgressSummary>;
```
- 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<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-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.