Skip to content

feat(reports): add student engagement report API (#64) - #190

Open
Fury03 wants to merge 1 commit into
Hamplard-Hub:mainfrom
Fury03:feature/issue-64-student-engagement-report
Open

feat(reports): add student engagement report API (#64)#190
Fury03 wants to merge 1 commit into
Hamplard-Hub:mainfrom
Fury03:feature/issue-64-student-engagement-report

Conversation

@Fury03

@Fury03 Fury03 commented Aug 27, 2026

Copy link
Copy Markdown

Closes #64

Problem Statement (The Bug)

There was no student-engagement reporting surface at all. The platform
stores every input a learning-engagement view needs — LessonProgress
(watchedSecs, completed, updatedAt), Enrollment (progressPercent,
status), UserPoints (currentStreak, longestStreak) — but nothing
reads them back as a summary. Admins and instructors have no way to answer
"how engaged is this cohort?" or "which students have gone quiet?".

This is not a local patch to an existing report: there is no
engagement-report.* module, no route, no aggregation code. It has to be
built as a first-class read model spanning three tables, with scope and
period validation, because ad-hoc per-table queries can't express
cross-table facts like "watch time per student" or "students inactive for N
days".

Solution Comparison and Decision

Option Why not
A. Extend the existing abuse-ReportsService Different domain entirely (moderation vs. learning analytics). Overloading it couples two unrelated concerns and one service's tests to the other's schema.
B. Add engagement fields to the analytics events pipeline AnalyticsEvent is a raw fire-and-forget event log with no link to enrollments or lesson completion state. It cannot produce completion rates or progress averages without reconstructing state that LessonProgress already holds authoritatively.
C. Precompute + store engagement snapshots in a new table Premature. Adds write-path complexity, staleness and a migration for data that is cheap to aggregate on demand at current scale. Can be layered on later behind the same endpoint.
D. On-demand read model in a dedicated service (chosen) Reads the authoritative tables directly, no new storage, no migration, scope/period validated at the edge. One endpoint, one service, isolated tests.

The Change

New DTOEngagementReportQueryDto (scope, studentId?, courseId?,
from?, to?, inactiveDays):

export enum EngagementScope { STUDENT = 'STUDENT', COURSE = 'COURSE', PLATFORM = 'PLATFORM' }

New core method — EngagementReportService.getReport(dto). It validates,
resolves the period, pulls the in-scope enrollments once, then fans out:

const [watchAgg, lessonsCompleted, lastActivityByEnrollment, points] = await Promise.all([
  this.prisma.lessonProgress.aggregate({ where, _sum: { watchedSecs: true }, _count: { _all: true } }),
  this.prisma.lessonProgress.count({ where: { ...where, completed: true } }),
  this.prisma.lessonProgress.groupBy({ by: ['enrollmentId'], where, _max: { updatedAt: true } }),
  this.prisma.userPoints.findMany({ where: { userId: { in: studentIds } } }),
]);

Inactivity flagging — per-enrollment last activity
(max(enrollment.updatedAt, max(lessonProgress.updatedAt))) is rolled up to
the student and compared against now - inactiveDays:

const inactive = studentIds.filter((id) => {
  const last = lastActivityByStudent.get(id);
  return !last || last < cutoff;
});
Entry point Behaviour
Authorization JwtAuthGuard + RolesGuard, @Roles(ADMIN, INSTRUCTOR)
Routing GET /reports/engagement (new controller, mounted alongside the existing abuse-reports controller)
Scope = STUDENT studentId required (400 if missing), student must exist (404), enrollment query filtered by studentId
Scope = COURSE courseId required (400), course must exist (404), enrollment query filtered by courseId
Scope = PLATFORM no id, all enrollments
Period from/to must be ISO-8601; from > to → 400; applied to enrolledAt and lessonProgress.updatedAt
Payload summary with watch time, lesson + course completion rates, avg progress, streaks, inactivity block
No data zeroed, well-formed payload (no divide-by-zero, groupBy skipped)

Sample response:

{
  "scope": "COURSE",
  "courseId": "course-1",
  "period": { "from": null, "to": null },
  "generatedAt": "2026-08-27T12:00:00.000Z",
  "summary": {
    "students": 2,
    "enrollments": 2,
    "completedEnrollments": 1,
    "courseCompletionRate": 0.5,
    "avgProgressPercent": 70,
    "watchTime": { "totalSeconds": 7200, "totalHours": 2, "avgSecondsPerStudent": 3600 },
    "lessons": { "started": 10, "completed": 6, "completionRate": 0.6 },
    "streaks": { "avgCurrentStreak": 3, "longestStreak": 12 },
    "inactivity": { "thresholdDays": 14, "inactiveStudents": 1, "inactiveRate": 0.5, "studentIds": ["stu-stale"], "truncated": false }
  }
}

Compatibility Note

No INTERFACE_VERSION constant exists in this repo. The change is purely
additive: one new route, one new DTO, one new service. ReportsModule
gains a controller/provider; the existing ReportsController,
ReportsService and /reports routes are untouched. No schema change, no
migration.

Incidental Fixes

  • ReportsModule now exports its providers, so EngagementReportService
    is reusable by other modules (matching the pattern the abuse
    ReportsService already followed).
  • Divide-by-zero guards (rate() / round() helpers) so an empty scope
    returns 0 rather than NaN in the JSON.

Testing

src/modules/reports/engagement-report.service.spec.ts — resolves the real
EngagementReportController + EngagementReportService from a Nest module
(Prisma mocked) and calls through controller.getReport(...):

  • rejects STUDENT scope without a studentIdFAILED (400) as required
  • 404s when the requested student does not exist
  • rejects COURSE scope without a courseId
  • rejects an inverted reporting period — the adversarial from > to case
  • scopes the enrollment query to the course for a cohort report
  • summarises watch time, lesson and course completion for a cohort
    asserts exact aggregate maths (2h watch time, 0.6 lesson completion,
    avg streak 3, longest 12)
  • flags students whose last lesson activity is older than the threshold
    the inactivity adversarial case (one stale, one fresh → exactly one flagged)
  • returns a well-formed zeroed payload when there is no data
$ npx jest src/modules/reports/engagement-report.service.spec.ts
Tests:       8 passed, 8 total

Pre-existing, unrelated TypeScript failures in src/modules/reviews/* and
src/modules/uploads/video-transcode.service.ts are present on main and
are not touched here.

Additional Notes

  • No base-branch fixup commits.
  • Scope: src/modules/reports/ only — one DTO, one controller, one
    service, and the module wiring. No changes to the abuse-report code path,
    to prisma/schema.prisma, or to any other module. Instructor-performance
    reporting ([Backend] Create instructor performance report API #65) is deliberately a separate PR.

New GET /reports/engagement endpoint summarising student activity across
STUDENT, COURSE (cohort) and PLATFORM scopes.

- EngagementReportQueryDto validates scope + required id, ISO-8601 period
  and the inactivity threshold (1-365 days)
- EngagementReportService aggregates watch time (LessonProgress.watchedSecs),
  lesson + course completion rates, average progress and learning streaks
  (UserPoints), and rolls per-enrollment activity up to the student to flag
  anyone idle past the threshold
- guarded for ADMIN / INSTRUCTOR
- spec drives the real controller->service->prisma path: scope + period
  validation, cohort scoping, aggregate maths, inactivity flagging and the
  empty-platform payload
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Backend] Create student engagement report API

1 participant