diff --git a/src/modules/reports/dto/instructor-report-query.dto.ts b/src/modules/reports/dto/instructor-report-query.dto.ts new file mode 100644 index 0000000..c3fa92e --- /dev/null +++ b/src/modules/reports/dto/instructor-report-query.dto.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsOptional } from 'class-validator'; + +export class InstructorReportQueryDto { + @ApiPropertyOptional({ + description: 'Start of the reporting period (ISO-8601). Inclusive.', + example: '2026-01-01T00:00:00.000Z', + }) + @IsOptional() + @IsISO8601() + from?: string; + + @ApiPropertyOptional({ + description: 'End of the reporting period (ISO-8601). Inclusive.', + example: '2026-06-30T23:59:59.999Z', + }) + @IsOptional() + @IsISO8601() + to?: string; +} diff --git a/src/modules/reports/instructor-report.controller.ts b/src/modules/reports/instructor-report.controller.ts new file mode 100644 index 0000000..e9ccaec --- /dev/null +++ b/src/modules/reports/instructor-report.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; +import { UserRole } from '@prisma/client'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { Roles, RolesGuard } from '../../common/guards/roles.guard'; +import { InstructorReportQueryDto } from './dto/instructor-report-query.dto'; +import { InstructorReportService } from './instructor-report.service'; + +@ApiTags('reports') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +@Controller('reports/instructors') +export class InstructorReportController { + constructor(private readonly instructorReport: InstructorReportService) {} + + @Get(':instructorId/performance') + @ApiOperation({ + summary: + 'Instructor performance report: ratings, revenue and completion rates vs platform averages', + }) + @ApiParam({ name: 'instructorId', description: 'User id of the instructor' }) + getReport( + @Param('instructorId') instructorId: string, + @Query() query: InstructorReportQueryDto, + ) { + return this.instructorReport.getReport(instructorId, query); + } +} diff --git a/src/modules/reports/instructor-report.service.spec.ts b/src/modules/reports/instructor-report.service.spec.ts new file mode 100644 index 0000000..81ca546 --- /dev/null +++ b/src/modules/reports/instructor-report.service.spec.ts @@ -0,0 +1,206 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { InstructorReportController } from './instructor-report.controller'; +import { InstructorReportService } from './instructor-report.service'; +import { InstructorReportQueryDto } from './dto/instructor-report-query.dto'; + +describe('InstructorReport (Issue #65)', () => { + let controller: InstructorReportController; + let service: InstructorReportService; + + const mockPrisma = { + user: { findUnique: jest.fn(), count: jest.fn() }, + course: { findMany: jest.fn() }, + enrollment: { findMany: jest.fn(), aggregate: jest.fn(), count: jest.fn() }, + courseReview: { aggregate: jest.fn() }, + }; + + const query = (over: Partial = {}) => + Object.assign(new InstructorReportQueryDto(), over); + + const instructorUser = { + id: 'inst-1', + name: 'Ada', + role: 'INSTRUCTOR', + stellarAddress: 'GABC', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [InstructorReportController], + providers: [ + InstructorReportService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(InstructorReportController); + service = module.get(InstructorReportService); + + jest.clearAllMocks(); + // sensible platform-average defaults + mockPrisma.user.count.mockResolvedValue(4); + mockPrisma.enrollment.aggregate.mockResolvedValue({ + _sum: { amountPaid: 4000 }, + _count: { _all: 80 }, + }); + mockPrisma.enrollment.count.mockResolvedValue(40); + mockPrisma.courseReview.aggregate.mockResolvedValue({ + _avg: { rating: 4 }, + _count: { _all: 20 }, + }); + mockPrisma.course.findMany.mockResolvedValue([]); + mockPrisma.enrollment.findMany.mockResolvedValue([]); + }); + + describe('validation', () => { + it('404s for an unknown instructor', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + await expect(service.getReport('nope', query())).rejects.toThrow( + NotFoundException, + ); + }); + + it('400s when the user is not an instructor', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + ...instructorUser, + role: 'STUDENT', + }); + await expect(service.getReport('inst-1', query())).rejects.toThrow( + BadRequestException, + ); + }); + + it('rejects an inverted reporting period', async () => { + mockPrisma.user.findUnique.mockResolvedValue(instructorUser); + await expect( + service.getReport( + 'inst-1', + query({ + from: '2026-06-01T00:00:00.000Z', + to: '2026-01-01T00:00:00.000Z', + }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('passes the reporting period through to the enrollment query', async () => { + mockPrisma.user.findUnique.mockResolvedValue(instructorUser); + mockPrisma.course.findMany.mockResolvedValue([ + { id: 'c1', status: 'ACTIVE', platformFeePercent: 20 }, + ]); + mockPrisma.courseReview.aggregate.mockResolvedValue({ + _avg: { rating: null }, + _count: { _all: 0 }, + }); + + await service.getReport( + 'inst-1', + query({ from: '2026-01-01T00:00:00.000Z', to: '2026-06-30T00:00:00.000Z' }), + ); + + const call = mockPrisma.enrollment.findMany.mock.calls[0][0]; + expect(call.where.enrolledAt).toEqual({ + gte: new Date('2026-01-01T00:00:00.000Z'), + lte: new Date('2026-06-30T00:00:00.000Z'), + }); + }); + }); + + describe('rating, revenue & completion aggregates', () => { + beforeEach(() => { + mockPrisma.user.findUnique.mockResolvedValue(instructorUser); + mockPrisma.course.findMany.mockResolvedValue([ + { id: 'c1', status: 'ACTIVE', platformFeePercent: 20 }, + { id: 'c2', status: 'PAUSED', platformFeePercent: 10 }, + ]); + mockPrisma.enrollment.findMany.mockResolvedValue([ + { courseId: 'c1', status: 'COMPLETED', amountPaid: 100 }, + { courseId: 'c1', status: 'ACTIVE', amountPaid: 100 }, + { courseId: 'c2', status: 'COMPLETED', amountPaid: 50 }, + ]); + // instructor-scoped review query carries a courseId filter; the + // platform-average query does not. + mockPrisma.courseReview.aggregate.mockImplementation((args: any) => + Promise.resolve( + args?.where?.courseId + ? { _avg: { rating: 4.5 }, _count: { _all: 8 } } + : { _avg: { rating: 4 }, _count: { _all: 20 } }, + ), + ); + }); + + it('computes per-instructor rating, revenue (with per-course fees) and completion rate', async () => { + const report = await controller.getReport('inst-1', query()); + + expect(report.metrics.courses).toEqual({ total: 2, active: 1 }); + expect(report.metrics.ratings).toEqual({ average: 4.5, totalReviews: 8 }); + expect(report.metrics.students).toEqual({ + enrollments: 3, + completions: 2, + completionRate: 0.6667, + }); + // gross = 250; fees = 100*0.2 + 100*0.2 + 50*0.1 = 45; net = 205 + expect(report.metrics.revenue).toEqual({ + gross: 250, + platformFees: 45, + net: 205, + }); + }); + + it('compares the instructor against platform averages', async () => { + const report = await controller.getReport('inst-1', query()); + + // platform: 4 instructors, gross 4000 => 1000/instructor; 80 enrollments => 20/instructor + expect(report.platformAverages).toEqual( + expect.objectContaining({ + instructorCount: 4, + rating: 4, + completionRate: 0.5, + grossRevenuePerInstructor: 1000, + enrollmentsPerInstructor: 20, + }), + ); + + expect(report.comparison.rating).toEqual( + expect.objectContaining({ + instructor: 4.5, + platformAverage: 4, + delta: 0.5, + verdict: 'above', + }), + ); + expect(report.comparison.grossRevenue).toEqual( + expect.objectContaining({ + instructor: 250, + platformAverage: 1000, + delta: -750, + verdict: 'below', + }), + ); + }); + }); + + it('handles an instructor with no courses without dividing by zero', async () => { + mockPrisma.user.findUnique.mockResolvedValue(instructorUser); + mockPrisma.course.findMany.mockResolvedValue([]); + mockPrisma.courseReview.aggregate.mockResolvedValue({ + _avg: { rating: null }, + _count: { _all: 0 }, + }); + + const report = await service.getReport('inst-1', query()); + + expect(report.metrics.students.completionRate).toBe(0); + expect(report.metrics.revenue).toEqual({ gross: 0, platformFees: 0, net: 0 }); + expect(report.metrics.ratings.average).toBe(0); + }); +}); diff --git a/src/modules/reports/instructor-report.service.ts b/src/modules/reports/instructor-report.service.ts new file mode 100644 index 0000000..a2b0bed --- /dev/null +++ b/src/modules/reports/instructor-report.service.ts @@ -0,0 +1,245 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { InstructorReportQueryDto } from './dto/instructor-report-query.dto'; + +interface ResolvedPeriod { + from: Date | null; + to: Date | null; +} + +export interface InstructorPerformanceReport { + instructor: { id: string; name: string | null; stellarAddress: string | null }; + period: { from: string | null; to: string | null }; + generatedAt: string; + metrics: { + courses: { total: number; active: number }; + ratings: { average: number; totalReviews: number }; + students: { enrollments: number; completions: number; completionRate: number }; + revenue: { gross: number; platformFees: number; net: number }; + }; + platformAverages: { + instructorCount: number; + rating: number; + completionRate: number; + grossRevenuePerInstructor: number; + enrollmentsPerInstructor: number; + }; + comparison: { + rating: ComparisonPoint; + completionRate: ComparisonPoint; + grossRevenue: ComparisonPoint; + enrollments: ComparisonPoint; + }; +} + +interface ComparisonPoint { + instructor: number; + platformAverage: number; + delta: number; + /** Percent difference vs the platform average; null when the average is 0. */ + percentDiff: number | null; + verdict: 'above' | 'below' | 'on_par'; +} + +@Injectable() +export class InstructorReportService { + constructor(private readonly prisma: PrismaService) {} + + async getReport( + instructorId: string, + dto: InstructorReportQueryDto, + ): Promise { + const instructor = await this.prisma.user.findUnique({ + where: { id: instructorId }, + select: { id: true, name: true, role: true, stellarAddress: true }, + }); + if (!instructor) throw new NotFoundException('Instructor not found'); + if (instructor.role !== 'INSTRUCTOR') { + throw new BadRequestException('User is not an instructor'); + } + + const period = this.resolvePeriod(dto); + + const courses = await this.prisma.course.findMany({ + where: { instructorAddress: instructor.stellarAddress ?? '__none__' }, + select: { id: true, status: true, platformFeePercent: true }, + }); + const courseIds = courses.map((c) => c.id); + const feeByCourse = new Map( + courses.map((c) => [c.id, c.platformFeePercent] as const), + ); + + const [enrollments, reviewAgg, platform] = await Promise.all([ + this.prisma.enrollment.findMany({ + where: { + courseId: { in: courseIds }, + ...this.dateFilter('enrolledAt', period), + }, + select: { courseId: true, status: true, amountPaid: true }, + }), + this.prisma.courseReview.aggregate({ + where: { + courseId: { in: courseIds }, + ...this.dateFilter('createdAt', period), + }, + _avg: { rating: true }, + _count: { _all: true }, + }), + this.computePlatformAverages(period), + ]); + + const gross = enrollments.reduce( + (sum, e) => sum + this.toNumber(e.amountPaid), + 0, + ); + const platformFees = enrollments.reduce((sum, e) => { + const feePercent = feeByCourse.get(e.courseId) ?? 0; + return sum + (this.toNumber(e.amountPaid) * feePercent) / 100; + }, 0); + const completions = enrollments.filter((e) => e.status === 'COMPLETED').length; + + const metrics = { + courses: { + total: courses.length, + active: courses.filter((c) => c.status === 'ACTIVE').length, + }, + ratings: { + average: this.round(reviewAgg._avg.rating ?? 0), + totalReviews: reviewAgg._count._all, + }, + students: { + enrollments: enrollments.length, + completions, + completionRate: this.rate(completions, enrollments.length), + }, + revenue: { + gross: this.round(gross), + platformFees: this.round(platformFees), + net: this.round(gross - platformFees), + }, + }; + + return { + instructor: { + id: instructor.id, + name: instructor.name, + stellarAddress: instructor.stellarAddress, + }, + period: { + from: period.from ? period.from.toISOString() : null, + to: period.to ? period.to.toISOString() : null, + }, + generatedAt: new Date().toISOString(), + metrics, + platformAverages: platform, + comparison: { + rating: this.compare(metrics.ratings.average, platform.rating), + completionRate: this.compare( + metrics.students.completionRate, + platform.completionRate, + ), + grossRevenue: this.compare( + metrics.revenue.gross, + platform.grossRevenuePerInstructor, + ), + enrollments: this.compare( + metrics.students.enrollments, + platform.enrollmentsPerInstructor, + ), + }, + }; + } + + // ---------------------------------------------------------- + // PLATFORM AVERAGES + // ---------------------------------------------------------- + + private async computePlatformAverages(period: ResolvedPeriod) { + const [instructorCount, enrollmentAgg, completedCount, reviewAgg] = + await Promise.all([ + this.prisma.user.count({ where: { role: 'INSTRUCTOR' } }), + this.prisma.enrollment.aggregate({ + where: this.dateFilter('enrolledAt', period), + _sum: { amountPaid: true }, + _count: { _all: true }, + }), + this.prisma.enrollment.count({ + where: { status: 'COMPLETED', ...this.dateFilter('enrolledAt', period) }, + }), + this.prisma.courseReview.aggregate({ + where: this.dateFilter('createdAt', period), + _avg: { rating: true }, + }), + ]); + + const totalEnrollments = enrollmentAgg._count._all; + const gross = this.toNumber(enrollmentAgg._sum.amountPaid); + const divisor = instructorCount || 1; + + return { + instructorCount, + rating: this.round(reviewAgg._avg.rating ?? 0), + completionRate: this.rate(completedCount, totalEnrollments), + grossRevenuePerInstructor: this.round(gross / divisor), + enrollmentsPerInstructor: this.round(totalEnrollments / divisor), + }; + } + + // ---------------------------------------------------------- + // HELPERS + // ---------------------------------------------------------- + + private resolvePeriod(dto: InstructorReportQueryDto): ResolvedPeriod { + const from = dto.from ? new Date(dto.from) : null; + const to = dto.to ? new Date(dto.to) : null; + + if (from && Number.isNaN(from.getTime())) { + throw new BadRequestException('from is not a valid date'); + } + if (to && Number.isNaN(to.getTime())) { + throw new BadRequestException('to is not a valid date'); + } + if (from && to && from > to) { + throw new BadRequestException('from must be on or before to'); + } + return { from, to }; + } + + private dateFilter( + field: 'enrolledAt' | 'createdAt', + period: ResolvedPeriod, + ): Record | Record { + if (!period.from && !period.to) return {}; + const range: Prisma.DateTimeFilter = {}; + if (period.from) range.gte = period.from; + if (period.to) range.lte = period.to; + return { [field]: range }; + } + + private compare(instructor: number, platformAverage: number): ComparisonPoint { + const delta = this.round(instructor - platformAverage); + const percentDiff = + platformAverage === 0 + ? null + : this.round((delta / platformAverage) * 100); + let verdict: ComparisonPoint['verdict'] = 'on_par'; + if (delta > 0) verdict = 'above'; + else if (delta < 0) verdict = 'below'; + return { instructor, platformAverage, delta, percentDiff, verdict }; + } + + private toNumber(value: Prisma.Decimal | number | null | undefined): number { + if (value === null || value === undefined) return 0; + return typeof value === 'number' ? value : Number(value); + } + + private round(value: number): number { + return Math.round(value * 100) / 100; + } + + private rate(numerator: number, denominator: number): number { + if (!denominator) return 0; + return Math.round((numerator / denominator) * 10000) / 10000; + } +} diff --git a/src/modules/reports/reports.module.ts b/src/modules/reports/reports.module.ts index 4ae20c0..146fd32 100644 --- a/src/modules/reports/reports.module.ts +++ b/src/modules/reports/reports.module.ts @@ -2,10 +2,12 @@ import { Module } from '@nestjs/common'; import { ReportsController } from './reports.controller'; import { ReportsService } from './reports.service'; +import { InstructorReportController } from './instructor-report.controller'; +import { InstructorReportService } from './instructor-report.service'; @Module({ - controllers: [ReportsController], - providers: [ReportsService], - exports: [ReportsService], + controllers: [ReportsController, InstructorReportController], + providers: [ReportsService, InstructorReportService], + exports: [ReportsService, InstructorReportService], }) export class ReportsModule {}