Skip to content

feat(reports): add instructor performance report API (#65) - #191

Open
Fury03 wants to merge 1 commit into
Hamplard-Hub:mainfrom
Fury03:feature/issue-65-instructor-performance-report
Open

feat(reports): add instructor performance report API (#65)#191
Fury03 wants to merge 1 commit into
Hamplard-Hub:mainfrom
Fury03:feature/issue-65-instructor-performance-report

Conversation

@Fury03

@Fury03 Fury03 commented Aug 27, 2026

Copy link
Copy Markdown

Closes #65

Problem Statement (The Bug)

The platform has no instructor-performance reporting. Course carries
denormalised counters (avgRating, totalRevenue, totalEnrollments) but:

  1. they are per-course, not per-instructor — there is no query that rolls
    an instructor's whole catalogue into one view;
  2. they are all-time — they cannot answer "how did this instructor do in
    Q1?";
  3. there is no benchmark — a raw avgRating of 4.2 is meaningless
    without the platform average next to it.

This can't be a local patch because the missing capability is a
cross-entity, period-bounded read model (courses × enrollments × reviews ×
platform aggregates). Reading the denormalised Course columns would bake in
both the all-time and drift problems (those counters are updated by other
code paths and can fall out of sync).

Solution Comparison and Decision

Option Why not
A. Return the denormalised Course.* counters as-is All-time only, no period support, trusts counters that can drift from the source rows. Fails acceptance criterion "period selection is validated".
B. Put this on the instructor dashboard / analytics events AnalyticsEvent has no revenue or rating data and no enrollment linkage; it would require reconstructing state that Enrollment / CourseReview already own.
C. Nightly materialised instructor_performance table Adds a migration, a cron job and staleness for data that is a handful of aggregate queries. Can be added later behind this same endpoint if scale demands it.
D. On-demand read model computed from source rows (chosen) Correct by construction (reads Enrollment / CourseReview directly), period-aware, no new storage, comparison computed in the same request.

The Change

New DTOInstructorReportQueryDto (from?, to?, both ISO-8601).

New core method — InstructorReportService.getReport(instructorId, dto):

const instructor = await this.prisma.user.findUnique({ where: { id: instructorId }, select: { id, name, role, stellarAddress } });
if (!instructor) throw new NotFoundException('Instructor not found');
if (instructor.role !== 'INSTRUCTOR') throw new BadRequestException('User is not an instructor');

Revenue is computed per enrollment against its course's own
platformFeePercent
, not a flat rate:

const platformFees = enrollments.reduce((sum, e) => {
  const feePercent = feeByCourse.get(e.courseId) ?? 0;
  return sum + (this.toNumber(e.amountPaid) * feePercent) / 100;
}, 0);

compare() turns each metric into an instructor-vs-platform point:

private compare(instructor: number, platformAverage: number): ComparisonPoint {
  const delta = this.round(instructor - platformAverage);
  const percentDiff = platformAverage === 0 ? null : this.round((delta / platformAverage) * 100);
  const verdict = delta > 0 ? 'above' : delta < 0 ? 'below' : 'on_par';
  return { instructor, platformAverage, delta, percentDiff, verdict };
}
Entry point Behaviour
Authorization JwtAuthGuard + RolesGuard, @Roles(ADMIN)
Routing GET /reports/instructors/:instructorId/performance (new controller in ReportsModule)
Instructor resolution instructorId = User.id; 404 if missing, 400 if role !== INSTRUCTOR; courses matched via Course.instructorAddress = user.stellarAddress
Period from/to ISO-8601; from > to → 400; applied to Enrollment.enrolledAt and CourseReview.createdAt
Rating aggregate courseReview.aggregate _avg.rating, _count over the instructor's courses in-period
Revenue gross = Σ amountPaid; fees = Σ per-course platformFeePercent; net = gross − fees
Completion COMPLETED enrollments / total enrollments
Comparison rating, completionRate, grossRevenue, enrollments vs platform-average baselines

Sample response:

{
  "instructor": { "id": "inst-1", "name": "Ada", "stellarAddress": "GABC" },
  "period": { "from": null, "to": null },
  "generatedAt": "2026-08-27T12:00:00.000Z",
  "metrics": {
    "courses": { "total": 2, "active": 1 },
    "ratings": { "average": 4.5, "totalReviews": 8 },
    "students": { "enrollments": 3, "completions": 2, "completionRate": 0.6667 },
    "revenue": { "gross": 250, "platformFees": 45, "net": 205 }
  },
  "platformAverages": {
    "instructorCount": 4, "rating": 4, "completionRate": 0.5,
    "grossRevenuePerInstructor": 1000, "enrollmentsPerInstructor": 20
  },
  "comparison": {
    "rating":         { "instructor": 4.5, "platformAverage": 4,    "delta": 0.5,   "percentDiff": 12.5, "verdict": "above" },
    "completionRate": { "instructor": 0.6667, "platformAverage": 0.5, "delta": 0.17, "percentDiff": 33.4, "verdict": "above" },
    "grossRevenue":   { "instructor": 250, "platformAverage": 1000,  "delta": -750,  "percentDiff": -75,  "verdict": "below" },
    "enrollments":    { "instructor": 3,   "platformAverage": 20,    "delta": -17,   "percentDiff": -85,  "verdict": "below" }
  }
}

Compatibility Note

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

Incidental Fixes

  • ReportsModule now exports its providers so InstructorReportService
    can be consumed elsewhere (same pattern the abuse ReportsService used).
  • Divide-by-zero guards throughout (rate(), per-instructor divisor
    instructorCount || 1, percentDiff null when the platform average is 0)
    so an instructor with no courses / an empty platform returns 0 / null,
    never NaN.

Testing

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

  • 404s for an unknown instructor
  • 400s when the user is not an instructor — adversarial role check
  • rejects an inverted reporting period — adversarial from > to
  • passes the reporting period through to the enrollment query
  • computes per-instructor rating, revenue (with per-course fees) and completion rate — asserts gross 250 / fees 45 (20% + 20% + 10%) / net 205,
    completion 0.6667
  • compares the instructor against platform averages — delta / verdict for
    rating (above) and gross revenue (below)
  • handles an instructor with no courses without dividing by zero
$ npx jest src/modules/reports/instructor-report.service.spec.ts
Tests:       7 passed, 7 total

Pre-existing, unrelated TypeScript failures in src/modules/reviews/* and
src/modules/uploads/video-transcode.service.ts exist 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, module wiring. No change to the abuse-report path, to
    prisma/schema.prisma, or to any other module. Student-engagement
    reporting ([Backend] Create student engagement report API #64) is a separate PR.

New GET /reports/instructors/:instructorId/performance endpoint summarising
an instructor's ratings, revenue and student completion, benchmarked against
platform averages.

- InstructorReportQueryDto validates the optional ISO-8601 reporting period
- InstructorReportService resolves the instructor (must exist and hold the
  INSTRUCTOR role), gathers their courses, then aggregates enrollments,
  per-course platform fees, review ratings and completion counts within the
  period
- computePlatformAverages() derives rating, completion rate and per-instructor
  gross revenue / enrollment baselines, and compare() emits delta +
  percentDiff + verdict for each metric
- ADMIN-guarded
- spec drives the real controller->service->prisma path: instructor / role /
  period validation, per-course fee revenue maths, completion rate and the
  platform comparison
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 instructor performance report API

1 participant