diff --git a/apps/backend/package.json b/apps/backend/package.json index a60f08b0..6819b6c0 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -92,10 +92,8 @@ "helmet": "^8.0.0", "@nestjs/graphql": "^10.0.0", "graphql": "^16.7.1", - "apollo-server-express": "^3.10.4", "dataloader": "^2.2.2", "graphql-depth-limit": "^1.1.0", - "graphql-query-complexity": "^0.8.0", "compression": "^1.7.4" }, "devDependencies": { diff --git a/apps/backend/src/admin/admin.module.ts b/apps/backend/src/admin/admin.module.ts index 67a4e4f3..72d8e0ed 100644 --- a/apps/backend/src/admin/admin.module.ts +++ b/apps/backend/src/admin/admin.module.ts @@ -3,13 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Dispute } from './dispute.entity'; import { AdminService } from './admin.service'; import { AdminController } from './admin.controller'; +import { DisputeResolutionService } from './dispute-resolution.service'; import { AuditModule } from '../audit/audit.module'; import { UsersModule } from '../users/users.module'; @Module({ imports: [TypeOrmModule.forFeature([Dispute]), AuditModule, UsersModule], - providers: [AdminService], + providers: [DisputeResolutionService, AdminService], controllers: [AdminController], - exports: [AdminService], + exports: [AdminService, DisputeResolutionService], }) export class AdminModule {} diff --git a/apps/backend/src/admin/admin.service.ts b/apps/backend/src/admin/admin.service.ts index 2cad97d9..e7a743c1 100644 --- a/apps/backend/src/admin/admin.service.ts +++ b/apps/backend/src/admin/admin.service.ts @@ -1,19 +1,28 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Dispute, DisputeStatus } from './dispute.entity'; +/** + * AdminService (#813) + * + * Responsibility: Platform-level user management (ban, suspend, role change). + * Dispute-resolution logic has been extracted into DisputeResolutionService + * to reduce cyclomatic complexity and give each concern a focused boundary. + * + * Dispute methods are thin delegators kept here for backwards-compatibility + * with AdminController — the real logic lives in DisputeResolutionService. + */ + +import { Injectable, NotFoundException } from '@nestjs/common'; import { AuditService } from '../audit/audit.service'; import { UsersService } from '../users/users.service'; import { CreateDisputeDto, ResolveDisputeDto, SuspendUserDto } from './admin.dto'; import { AuditAction } from '../audit/audit-log.entity'; +import { Dispute, DisputeStatus } from './dispute.entity'; +import { DisputeResolutionService } from './dispute-resolution.service'; @Injectable() export class AdminService { constructor( - @InjectRepository(Dispute) - private readonly disputeRepo: Repository, private readonly auditService: AuditService, - private readonly usersService: UsersService + private readonly usersService: UsersService, + private readonly disputeResolutionService: DisputeResolutionService, ) {} // ── User management ─────────────────────────────────────────────────────── @@ -24,7 +33,7 @@ export class AdminService { isBanned ? AuditAction.USER_BANNED : 'admin.user_unbanned', adminId, true, - { targetUserId: targetId } + { targetUserId: targetId }, ); return user; } @@ -33,7 +42,6 @@ export class AdminService { const user = await this.usersService.findById(targetId); if (!user) throw new NotFoundException('User not found'); - // Store suspension as ban with metadata in audit log (no separate field needed) const updated = await this.usersService.update(targetId, { isBanned: true }); await this.auditService.log('admin.user_suspended', adminId, true, { targetUserId: targetId, @@ -52,45 +60,21 @@ export class AdminService { return user; } - // ── Dispute management ──────────────────────────────────────────────────── + // ── Dispute management (delegated) ──────────────────────────────────────── async createDispute(dto: CreateDisputeDto, userId: string): Promise { - const dispute = this.disputeRepo.create({ - ...dto, - submittedByUserId: userId, - status: DisputeStatus.OPEN, - }); - const saved = await this.disputeRepo.save(dispute); - await this.auditService.log('admin.dispute_created', userId, true, { disputeId: saved.id }); - return saved; + return this.disputeResolutionService.createDispute(dto, userId); } async listDisputes(status?: DisputeStatus): Promise { - const where = status ? { status } : {}; - return this.disputeRepo.find({ where, order: { createdAt: 'DESC' } }); + return this.disputeResolutionService.listDisputes(status); } async resolveDispute(id: string, dto: ResolveDisputeDto, adminId: string): Promise { - const dispute = await this.getDisputeOrThrow(id); - if (dispute.status === DisputeStatus.RESOLVED || dispute.status === DisputeStatus.CLOSED) { - throw new BadRequestException('Dispute already resolved or closed'); - } - - dispute.status = dto.status; - dispute.resolution = dto.resolution; - dispute.resolvedByUserId = adminId; - const saved = await this.disputeRepo.save(dispute); - - await this.auditService.log('admin.dispute_resolved', adminId, true, { - disputeId: id, - status: dto.status, - }); - return saved; + return this.disputeResolutionService.resolveDispute(id, dto, adminId); } async getDisputeOrThrow(id: string): Promise { - const dispute = await this.disputeRepo.findOne({ where: { id } }); - if (!dispute) throw new NotFoundException('Dispute not found'); - return dispute; + return this.disputeResolutionService.getDisputeOrThrow(id); } } diff --git a/apps/backend/src/admin/dispute-resolution.service.spec.ts b/apps/backend/src/admin/dispute-resolution.service.spec.ts new file mode 100644 index 00000000..7eedd25d --- /dev/null +++ b/apps/backend/src/admin/dispute-resolution.service.spec.ts @@ -0,0 +1,257 @@ +/** + * Unit tests for DisputeResolutionService (#813). + * + * Verifies all dispute lifecycle operations — creation, listing, lookup, + * and resolution — in isolation with jest mocks (no DB or audit service). + */ + +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { DisputeResolutionService } from './dispute-resolution.service'; +import { Dispute, DisputeStatus, DisputeType } from './dispute.entity'; +import { CreateDisputeDto, ResolveDisputeDto } from './admin.dto'; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeDispute(overrides: Partial = {}): Dispute { + return { + id: 'dispute-1', + type: DisputeType.OTHER, + status: DisputeStatus.OPEN, + submittedByUserId: 'user-1', + description: 'Test dispute', + targetEntityId: null, + targetEntityType: null, + resolvedByUserId: null, + resolution: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeRepo(overrides: Partial> = {}) { + return { + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn().mockResolvedValue([]), + create: jest.fn().mockImplementation((data: any) => ({ ...data })), + save: jest.fn().mockImplementation(async (r: any) => r), + ...overrides, + }; +} + +function makeAuditService() { + return { log: jest.fn().mockResolvedValue(undefined) }; +} + +function makeService(repoOverrides: Partial> = {}) { + const repo = makeRepo(repoOverrides); + const auditService = makeAuditService(); + return { + service: new DisputeResolutionService(repo as any, auditService as any), + repo, + auditService, + }; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// createDispute +// ═════════════════════════════════════════════════════════════════════════════ + +describe('DisputeResolutionService.createDispute', () => { + it('creates a dispute with OPEN status and logs an audit event', async () => { + const savedDispute = makeDispute({ id: 'new-dispute' }); + const { service, repo, auditService } = makeService({ + save: jest.fn().mockResolvedValue(savedDispute), + }); + + const dto: CreateDisputeDto = { + type: DisputeType.BILLING, + description: 'Incorrect charge', + }; + + const result = await service.createDispute(dto, 'user-1'); + + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ + status: DisputeStatus.OPEN, + submittedByUserId: 'user-1', + }), + ); + expect(repo.save).toHaveBeenCalledTimes(1); + expect(result.id).toBe('new-dispute'); + expect(auditService.log).toHaveBeenCalledWith( + 'admin.dispute_created', + 'user-1', + true, + expect.objectContaining({ disputeId: 'new-dispute' }), + ); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// listDisputes +// ═════════════════════════════════════════════════════════════════════════════ + +describe('DisputeResolutionService.listDisputes', () => { + it('returns all disputes when no status filter is provided', async () => { + const disputes = [makeDispute(), makeDispute({ id: 'd2', status: DisputeStatus.RESOLVED })]; + const { service, repo } = makeService({ + find: jest.fn().mockResolvedValue(disputes), + }); + + const result = await service.listDisputes(); + + expect(repo.find).toHaveBeenCalledWith({ where: {}, order: { createdAt: 'DESC' } }); + expect(result).toHaveLength(2); + }); + + it('filters disputes by status when provided', async () => { + const openDisputes = [makeDispute()]; + const { service, repo } = makeService({ + find: jest.fn().mockResolvedValue(openDisputes), + }); + + const result = await service.listDisputes(DisputeStatus.OPEN); + + expect(repo.find).toHaveBeenCalledWith({ + where: { status: DisputeStatus.OPEN }, + order: { createdAt: 'DESC' }, + }); + expect(result).toHaveLength(1); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// getDisputeOrThrow +// ═════════════════════════════════════════════════════════════════════════════ + +describe('DisputeResolutionService.getDisputeOrThrow', () => { + it('returns the dispute when it exists', async () => { + const dispute = makeDispute({ id: 'exists' }); + const { service } = makeService({ + findOne: jest.fn().mockResolvedValue(dispute), + }); + + const result = await service.getDisputeOrThrow('exists'); + expect(result.id).toBe('exists'); + }); + + it('throws NotFoundException when the dispute does not exist', async () => { + const { service } = makeService({ + findOne: jest.fn().mockResolvedValue(null), + }); + + await expect(service.getDisputeOrThrow('missing')).rejects.toThrow(NotFoundException); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// resolveDispute +// ═════════════════════════════════════════════════════════════════════════════ + +describe('DisputeResolutionService.resolveDispute', () => { + it('resolves an open dispute and logs an audit event', async () => { + const dispute = makeDispute({ status: DisputeStatus.OPEN }); + const { service, auditService } = makeService({ + findOne: jest.fn().mockResolvedValue(dispute), + save: jest.fn().mockImplementation(async (r: any) => r), + }); + + const dto: ResolveDisputeDto = { + status: DisputeStatus.RESOLVED, + resolution: 'Refund issued', + }; + + const result = await service.resolveDispute('dispute-1', dto, 'admin-1'); + + expect(result.status).toBe(DisputeStatus.RESOLVED); + expect(result.resolution).toBe('Refund issued'); + expect(result.resolvedByUserId).toBe('admin-1'); + expect(auditService.log).toHaveBeenCalledWith( + 'admin.dispute_resolved', + 'admin-1', + true, + expect.objectContaining({ status: DisputeStatus.RESOLVED }), + ); + }); + + it('resolves an under_review dispute (not just open)', async () => { + const dispute = makeDispute({ status: DisputeStatus.UNDER_REVIEW }); + const { service } = makeService({ + findOne: jest.fn().mockResolvedValue(dispute), + save: jest.fn().mockImplementation(async (r: any) => r), + }); + + const dto: ResolveDisputeDto = { + status: DisputeStatus.CLOSED, + resolution: 'No action needed', + }; + + const result = await service.resolveDispute('dispute-1', dto, 'admin-1'); + expect(result.status).toBe(DisputeStatus.CLOSED); + }); + + it('throws BadRequestException when dispute is already resolved', async () => { + const dispute = makeDispute({ status: DisputeStatus.RESOLVED }); + const { service } = makeService({ + findOne: jest.fn().mockResolvedValue(dispute), + }); + + const dto: ResolveDisputeDto = { + status: DisputeStatus.CLOSED, + resolution: 'Another attempt', + }; + + await expect(service.resolveDispute('dispute-1', dto, 'admin-1')).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws BadRequestException when dispute is already closed', async () => { + const dispute = makeDispute({ status: DisputeStatus.CLOSED }); + const { service } = makeService({ + findOne: jest.fn().mockResolvedValue(dispute), + }); + + const dto: ResolveDisputeDto = { + status: DisputeStatus.RESOLVED, + resolution: 'Too late', + }; + + await expect(service.resolveDispute('dispute-1', dto, 'admin-1')).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws BadRequestException when resolution status is not terminal', async () => { + const dispute = makeDispute({ status: DisputeStatus.OPEN }); + const { service } = makeService({ + findOne: jest.fn().mockResolvedValue(dispute), + }); + + // Trying to "resolve" to UNDER_REVIEW is not allowed + const dto = { + status: DisputeStatus.UNDER_REVIEW, + resolution: 'This should fail', + } as unknown as ResolveDisputeDto; + + await expect(service.resolveDispute('dispute-1', dto, 'admin-1')).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws NotFoundException when the dispute does not exist', async () => { + const { service } = makeService({ + findOne: jest.fn().mockResolvedValue(null), + }); + + const dto: ResolveDisputeDto = { + status: DisputeStatus.RESOLVED, + resolution: 'Whatever', + }; + + await expect(service.resolveDispute('ghost', dto, 'admin-1')).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/apps/backend/src/admin/dispute-resolution.service.ts b/apps/backend/src/admin/dispute-resolution.service.ts new file mode 100644 index 00000000..f9dfe549 --- /dev/null +++ b/apps/backend/src/admin/dispute-resolution.service.ts @@ -0,0 +1,128 @@ +/** + * DisputeResolutionService (#813) + * + * Responsibility: Manages the full lifecycle of dispute records — creation, + * querying, and resolution. Extracted from AdminService to give dispute + * management a clear, focused boundary and reduce the cyclomatic complexity + * of the original monolithic service. + * + * Responsibilities owned by this service: + * • Creating new disputes (any authenticated user) + * • Listing disputes with optional status filter (admin) + * • Fetching a single dispute by id (admin) + * • Resolving or closing a dispute (admin) + * + * Explicitly NOT owned here (remains in AdminService): + * • User management (ban, suspend, role change) + */ + +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Dispute, DisputeStatus } from './dispute.entity'; +import { AuditService } from '../audit/audit.service'; +import { CreateDisputeDto, ResolveDisputeDto } from './admin.dto'; + +/** Terminal statuses — a dispute in these states cannot be resolved again. */ +const TERMINAL_STATUSES: DisputeStatus[] = [ + DisputeStatus.RESOLVED, + DisputeStatus.CLOSED, +]; + +@Injectable() +export class DisputeResolutionService { + constructor( + @InjectRepository(Dispute) + private readonly disputeRepo: Repository, + private readonly auditService: AuditService, + ) {} + + /** + * Open a new dispute on behalf of the requesting user. + */ + async createDispute(dto: CreateDisputeDto, userId: string): Promise { + const dispute = this.disputeRepo.create({ + ...dto, + submittedByUserId: userId, + status: DisputeStatus.OPEN, + }); + + const saved = await this.disputeRepo.save(dispute); + + await this.auditService.log('admin.dispute_created', userId, true, { + disputeId: saved.id, + }); + + return saved; + } + + /** + * Return all disputes, optionally filtered by status. + */ + async listDisputes(status?: DisputeStatus): Promise { + const where = status ? { status } : {}; + return this.disputeRepo.find({ where, order: { createdAt: 'DESC' } }); + } + + /** + * Return a single dispute or throw NotFoundException. + */ + async getDisputeOrThrow(id: string): Promise { + const dispute = await this.disputeRepo.findOne({ where: { id } }); + if (!dispute) { + throw new NotFoundException(`Dispute ${id} not found`); + } + return dispute; + } + + /** + * Resolve or close an open dispute. + * + * Guards: + * - Dispute must exist (NotFoundException) + * - Dispute must not already be in a terminal state (BadRequestException) + * - Incoming status must be a terminal state (resolved / closed) + */ + async resolveDispute( + id: string, + dto: ResolveDisputeDto, + adminId: string, + ): Promise { + const dispute = await this.getDisputeOrThrow(id); + + if (this.isTerminal(dispute.status)) { + throw new BadRequestException( + `Dispute ${id} is already ${dispute.status} and cannot be updated`, + ); + } + + if (!this.isTerminal(dto.status)) { + throw new BadRequestException( + `Resolution status must be '${DisputeStatus.RESOLVED}' or '${DisputeStatus.CLOSED}'`, + ); + } + + dispute.status = dto.status; + dispute.resolution = dto.resolution; + dispute.resolvedByUserId = adminId; + + const saved = await this.disputeRepo.save(dispute); + + await this.auditService.log('admin.dispute_resolved', adminId, true, { + disputeId: id, + status: dto.status, + }); + + return saved; + } + + // ─── private helpers ──────────────────────────────────────────────────────── + + private isTerminal(status: DisputeStatus): boolean { + return TERMINAL_STATUSES.includes(status); + } +} diff --git a/apps/backend/src/analytics/analytics.module.ts b/apps/backend/src/analytics/analytics.module.ts index e833bf90..9b015e9c 100644 --- a/apps/backend/src/analytics/analytics.module.ts +++ b/apps/backend/src/analytics/analytics.module.ts @@ -11,6 +11,7 @@ import { Progress } from '../progress/progress.entity'; import { Review } from '../courses/review.entity'; import { Course } from '../courses/course.entity'; import { User } from '../users/user.entity'; + import { AnalyticsService } from './analytics.service'; import { AnalyticsController } from './analytics.controller'; import { EventsService } from './events.service'; @@ -23,6 +24,13 @@ import { AdminAnalyticsController } from './admin-analytics.controller'; import { ProtocolMetricsService } from './protocol-metrics.service'; import { ProtocolMetricsController } from './protocol-metrics.controller'; +// Pipeline stages +import { DataCollectionStage } from './pipeline/data-collection.stage'; +import { AggregationStage } from './pipeline/aggregation.stage'; +import { PersistenceStage } from './pipeline/persistence.stage'; +import { CacheStage } from './pipeline/cache.stage'; +import { AnalyticsPipeline } from './pipeline/analytics.pipeline'; + @Module({ imports: [ ScheduleModule.forRoot(), @@ -39,8 +47,36 @@ import { ProtocolMetricsController } from './protocol-metrics.controller'; User, ]), ], - providers: [AnalyticsService, EventsService, PlatformAnalyticsService, InstructorAnalyticsService, AdminAnalyticsService, ProtocolMetricsService], - controllers: [AnalyticsController, PlatformAnalyticsController, InstructorAnalyticsController, AdminAnalyticsController, ProtocolMetricsController], - exports: [AnalyticsService, EventsService, PlatformAnalyticsService, InstructorAnalyticsService, AdminAnalyticsService, ProtocolMetricsService], + providers: [ + // Pipeline stages + DataCollectionStage, + AggregationStage, + PersistenceStage, + CacheStage, + AnalyticsPipeline, + // Feature services + AnalyticsService, + EventsService, + PlatformAnalyticsService, + InstructorAnalyticsService, + AdminAnalyticsService, + ProtocolMetricsService, + ], + controllers: [ + AnalyticsController, + PlatformAnalyticsController, + InstructorAnalyticsController, + AdminAnalyticsController, + ProtocolMetricsController, + ], + exports: [ + AnalyticsPipeline, + AnalyticsService, + EventsService, + PlatformAnalyticsService, + InstructorAnalyticsService, + AdminAnalyticsService, + ProtocolMetricsService, + ], }) export class AnalyticsModule {} diff --git a/apps/backend/src/analytics/analytics.service.ts b/apps/backend/src/analytics/analytics.service.ts index 496371fd..23831938 100644 --- a/apps/backend/src/analytics/analytics.service.ts +++ b/apps/backend/src/analytics/analytics.service.ts @@ -6,8 +6,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { CourseAnalytics } from './course-analytics.entity'; import { Enrollment } from '../enrollments/enrollment.entity'; -import { Progress } from '../progress/progress.entity'; -import { Review } from '../courses/review.entity'; +import { AnalyticsPipeline } from './pipeline/analytics.pipeline'; @Injectable() export class AnalyticsService { @@ -15,11 +14,13 @@ export class AnalyticsService { private readonly CACHE_TTL = 3600; constructor( - @InjectRepository(CourseAnalytics) private analyticsRepo: Repository, - @InjectRepository(Enrollment) private enrollmentRepo: Repository, - @InjectRepository(Progress) private progressRepo: Repository, - @InjectRepository(Review) private reviewRepo: Repository, - @Inject(CACHE_MANAGER) private cache: Cache, + @InjectRepository(CourseAnalytics) + private readonly analyticsRepo: Repository, + @InjectRepository(Enrollment) + private readonly enrollmentRepo: Repository, + @Inject(CACHE_MANAGER) + private readonly cache: Cache, + private readonly pipeline: AnalyticsPipeline, ) {} async getAnalytics(courseId: string): Promise { @@ -36,61 +37,16 @@ export class AnalyticsService { return analytics; } + /** Run the full ingestion pipeline for a single course. */ async aggregateCourse(courseId: string): Promise { - const [totalEnrollments, totalCompletions, reviewStats, progressStats, activeCount] = - await Promise.all([ - this.enrollmentRepo.count({ where: { courseId } }), - this.enrollmentRepo.count({ where: { courseId } }).then(async () => { - const res = await this.enrollmentRepo - .createQueryBuilder('e') - .where('e.courseId = :courseId', { courseId }) - .andWhere('e.completedAt IS NOT NULL') - .getCount(); - return res; - }), - this.reviewRepo - .createQueryBuilder('r') - .select('AVG(r.rating)', 'avg') - .addSelect('COUNT(*)', 'cnt') - .where('r.courseId = :courseId', { courseId }) - .getRawOne<{ avg: string; cnt: string }>(), - this.progressRepo - .createQueryBuilder('p') - .select('AVG(p.progressPct)', 'avg') - .where('p.courseId = :courseId', { courseId }) - .getRawOne<{ avg: string }>(), - this.progressRepo - .createQueryBuilder('p') - .where('p.courseId = :courseId', { courseId }) - .andWhere('p.updatedAt > :since', { since: new Date(Date.now() - 30 * 86400_000) }) - .select('COUNT(DISTINCT p.userId)', 'cnt') - .getRawOne<{ cnt: string }>(), - ]); - - const completionRate = totalEnrollments > 0 ? (totalCompletions / totalEnrollments) * 100 : 0; - - const existing = await this.analyticsRepo.findOne({ where: { courseId } }); - const record = existing ?? this.analyticsRepo.create({ courseId }); - - Object.assign(record, { - totalEnrollments, - totalCompletions, - completionRate: Math.round(completionRate * 100) / 100, - averageRating: Math.round(Number(reviewStats?.avg ?? 0) * 100) / 100, - totalReviews: Number(reviewStats?.cnt ?? 0), - averageProgressPct: Math.round(Number(progressStats?.avg ?? 0) * 100) / 100, - activeLearnersLast30Days: Number(activeCount?.cnt ?? 0), - }); - - const saved = await this.analyticsRepo.save(record); - await this.cache.del(`analytics:${courseId}`); - return saved; + return this.pipeline.run(courseId); } - /** Hourly: aggregate all courses */ + /** Hourly: aggregate all courses that have at least one enrollment. */ @Cron(CronExpression.EVERY_HOUR) async aggregateAll(): Promise { this.logger.log('Running hourly analytics aggregation'); + const courseIds = await this.enrollmentRepo .createQueryBuilder('e') .select('DISTINCT e.courseId', 'courseId') diff --git a/apps/backend/src/analytics/pipeline/aggregation.stage.ts b/apps/backend/src/analytics/pipeline/aggregation.stage.ts new file mode 100644 index 00000000..466a9627 --- /dev/null +++ b/apps/backend/src/analytics/pipeline/aggregation.stage.ts @@ -0,0 +1,49 @@ +/** + * AggregationStage — Stage 2 of the analytics ingestion pipeline. + * + * Responsibility: Transform `ctx.rawData` into `ctx.metrics` by applying + * business-logic calculations (completion rate, rounding, defaults). + * This stage does NO I/O — it is purely computational. + */ + +import { Injectable } from '@nestjs/common'; +import { AggregatedMetrics, PipelineContext, PipelineStage } from './pipeline.types'; + +@Injectable() +export class AggregationStage implements PipelineStage { + async execute(ctx: PipelineContext): Promise { + if (!ctx.rawData) { + throw new Error('AggregationStage requires rawData from DataCollectionStage'); + } + + const { + courseId, + totalEnrollments, + totalCompletions, + reviewStats, + progressStats, + activeLearnersLast30Days: activeLearners, + } = ctx.rawData; + + const completionRate = + totalEnrollments > 0 ? (totalCompletions / totalEnrollments) * 100 : 0; + + const metrics: AggregatedMetrics = { + courseId, + totalEnrollments, + totalCompletions, + completionRate: this.round2(completionRate), + averageRating: this.round2(Number(reviewStats?.avg ?? 0)), + totalReviews: Number(reviewStats?.cnt ?? 0), + averageProgressPct: this.round2(Number(progressStats?.avg ?? 0)), + activeLearnersLast30Days: Number(activeLearners?.cnt ?? 0), + }; + + ctx.metrics = metrics; + } + + /** Round a number to two decimal places. */ + private round2(value: number): number { + return Math.round(value * 100) / 100; + } +} diff --git a/apps/backend/src/analytics/pipeline/analytics.pipeline.spec.ts b/apps/backend/src/analytics/pipeline/analytics.pipeline.spec.ts new file mode 100644 index 00000000..f816d709 --- /dev/null +++ b/apps/backend/src/analytics/pipeline/analytics.pipeline.spec.ts @@ -0,0 +1,278 @@ +/** + * Unit tests for the analytics ingestion pipeline (#821). + * + * Covers each discrete stage independently and the orchestrating pipeline. + * + * All I/O is replaced with jest mocks — no database or cache required. + */ + +import { AggregationStage } from './aggregation.stage'; +import { CacheStage } from './cache.stage'; +import { PersistenceStage } from './persistence.stage'; +import { DataCollectionStage } from './data-collection.stage'; +import { AnalyticsPipeline } from './analytics.pipeline'; +import { PipelineContext } from './pipeline.types'; +import { CourseAnalytics } from '../course-analytics.entity'; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeRepo(overrides: Partial> = {}) { + const qb: any = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(0), + getRawOne: jest.fn().mockResolvedValue(null), + getRawMany: jest.fn().mockResolvedValue([]), + }; + return { + count: jest.fn().mockResolvedValue(0), + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockImplementation((data: any) => ({ ...data })), + save: jest.fn().mockImplementation(async (r: any) => r), + createQueryBuilder: jest.fn().mockReturnValue(qb), + ...overrides, + }; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// AggregationStage +// ═════════════════════════════════════════════════════════════════════════════ + +describe('AggregationStage', () => { + const stage = new AggregationStage(); + + it('calculates completionRate as 0 when there are no enrollments', async () => { + const ctx: PipelineContext = { + courseId: 'c1', + rawData: { + courseId: 'c1', + totalEnrollments: 0, + totalCompletions: 0, + reviewStats: null, + progressStats: null, + activeLearnersLast30Days: null, + }, + }; + await stage.execute(ctx); + expect(ctx.metrics!.completionRate).toBe(0); + }); + + it('calculates completionRate correctly', async () => { + const ctx: PipelineContext = { + courseId: 'c2', + rawData: { + courseId: 'c2', + totalEnrollments: 20, + totalCompletions: 5, + reviewStats: null, + progressStats: null, + activeLearnersLast30Days: null, + }, + }; + await stage.execute(ctx); + // 5/20 * 100 = 25.00 + expect(ctx.metrics!.completionRate).toBe(25); + }); + + it('rounds averageRating to two decimal places', async () => { + const ctx: PipelineContext = { + courseId: 'c3', + rawData: { + courseId: 'c3', + totalEnrollments: 10, + totalCompletions: 3, + reviewStats: { avg: '4.333333', cnt: '3' }, + progressStats: { avg: '55' }, + activeLearnersLast30Days: { cnt: '2' }, + }, + }; + await stage.execute(ctx); + expect(ctx.metrics!.averageRating).toBe(4.33); + expect(ctx.metrics!.totalReviews).toBe(3); + }); + + it('defaults to 0 when reviewStats is null', async () => { + const ctx: PipelineContext = { + courseId: 'c4', + rawData: { + courseId: 'c4', + totalEnrollments: 5, + totalCompletions: 0, + reviewStats: null, + progressStats: null, + activeLearnersLast30Days: null, + }, + }; + await stage.execute(ctx); + expect(ctx.metrics!.averageRating).toBe(0); + expect(ctx.metrics!.totalReviews).toBe(0); + expect(ctx.metrics!.averageProgressPct).toBe(0); + expect(ctx.metrics!.activeLearnersLast30Days).toBe(0); + }); + + it('throws when rawData is missing', async () => { + const ctx: PipelineContext = { courseId: 'c5' }; + await expect(stage.execute(ctx)).rejects.toThrow('AggregationStage requires rawData'); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// PersistenceStage +// ═════════════════════════════════════════════════════════════════════════════ + +describe('PersistenceStage', () => { + it('creates a new record when no existing record is found', async () => { + const analyticsRepo = makeRepo(); + const stage = new PersistenceStage(analyticsRepo as any); + + const ctx: PipelineContext = { + courseId: 'c10', + metrics: { + courseId: 'c10', + totalEnrollments: 10, + totalCompletions: 5, + completionRate: 50, + averageRating: 4.2, + totalReviews: 8, + averageProgressPct: 60, + activeLearnersLast30Days: 3, + }, + }; + + await stage.execute(ctx); + + expect(analyticsRepo.create).toHaveBeenCalledWith({ courseId: 'c10' }); + expect(analyticsRepo.save).toHaveBeenCalledTimes(1); + expect(ctx.record).toBeDefined(); + }); + + it('updates an existing record instead of creating a new one', async () => { + const existing = { courseId: 'c11', totalEnrollments: 1 } as CourseAnalytics; + const saveMock = jest.fn().mockImplementation(async (r: any) => r); + const analyticsRepo = makeRepo({ + findOne: jest.fn().mockResolvedValue(existing), + save: saveMock, + }); + const stage = new PersistenceStage(analyticsRepo as any); + + const ctx: PipelineContext = { + courseId: 'c11', + metrics: { + courseId: 'c11', + totalEnrollments: 20, + totalCompletions: 10, + completionRate: 50, + averageRating: 4.5, + totalReviews: 12, + averageProgressPct: 70, + activeLearnersLast30Days: 5, + }, + }; + + await stage.execute(ctx); + + // create() must NOT have been called — we're updating an existing record + expect(analyticsRepo.create).not.toHaveBeenCalled(); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(ctx.record!.totalEnrollments).toBe(20); + }); + + it('throws when metrics are missing', async () => { + const analyticsRepo = makeRepo(); + const stage = new PersistenceStage(analyticsRepo as any); + const ctx: PipelineContext = { courseId: 'c12' }; + await expect(stage.execute(ctx)).rejects.toThrow('PersistenceStage requires metrics'); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// CacheStage +// ═════════════════════════════════════════════════════════════════════════════ + +describe('CacheStage', () => { + it('deletes the cache key for the course', async () => { + const cache = { del: jest.fn().mockResolvedValue(undefined) }; + const stage = new CacheStage(cache as any); + const ctx: PipelineContext = { courseId: 'c20' }; + + await stage.execute(ctx); + + expect(cache.del).toHaveBeenCalledWith('analytics:c20'); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// AnalyticsPipeline (orchestrator) +// ═════════════════════════════════════════════════════════════════════════════ + +describe('AnalyticsPipeline', () => { + it('runs all four stages in order and returns ctx.record', async () => { + const order: string[] = []; + const expectedRecord = { courseId: 'pipeline-test' } as CourseAnalytics; + + const dataCollectionStage = { + execute: jest.fn().mockImplementation(async (ctx: PipelineContext) => { + order.push('collection'); + ctx.rawData = { + courseId: ctx.courseId, + totalEnrollments: 10, + totalCompletions: 5, + reviewStats: { avg: '4', cnt: '8' }, + progressStats: { avg: '60' }, + activeLearnersLast30Days: { cnt: '3' }, + }; + }), + }; + + const aggregationStage = { + execute: jest.fn().mockImplementation(async (ctx: PipelineContext) => { + order.push('aggregation'); + ctx.metrics = { + courseId: ctx.courseId, + totalEnrollments: 10, + totalCompletions: 5, + completionRate: 50, + averageRating: 4, + totalReviews: 8, + averageProgressPct: 60, + activeLearnersLast30Days: 3, + }; + }), + }; + + const persistenceStage = { + execute: jest.fn().mockImplementation(async (ctx: PipelineContext) => { + order.push('persistence'); + ctx.record = expectedRecord; + }), + }; + + const cacheStage = { + execute: jest.fn().mockImplementation(async () => { + order.push('cache'); + }), + }; + + const pipeline = new AnalyticsPipeline( + dataCollectionStage as any, + aggregationStage as any, + persistenceStage as any, + cacheStage as any, + ); + + const result = await pipeline.run('pipeline-test'); + + expect(order).toEqual(['collection', 'aggregation', 'persistence', 'cache']); + expect(result).toEqual(expectedRecord); + }); + + it('throws when no record is produced after all stages', async () => { + const noop = { execute: jest.fn().mockResolvedValue(undefined) }; + const pipeline = new AnalyticsPipeline(noop as any, noop as any, noop as any, noop as any); + await expect(pipeline.run('no-record')).rejects.toThrow( + 'Pipeline did not produce a record for course no-record', + ); + }); +}); diff --git a/apps/backend/src/analytics/pipeline/analytics.pipeline.ts b/apps/backend/src/analytics/pipeline/analytics.pipeline.ts new file mode 100644 index 00000000..05b61b50 --- /dev/null +++ b/apps/backend/src/analytics/pipeline/analytics.pipeline.ts @@ -0,0 +1,44 @@ +/** + * AnalyticsPipeline — Orchestrates the four ingestion stages for a course. + * + * Stages run in sequence: + * 1. DataCollectionStage — parallel DB queries + * 2. AggregationStage — pure calculation / transformation + * 3. PersistenceStage — upsert to the database + * 4. CacheStage — invalidate stale cache entry + * + * The pipeline returns the persisted CourseAnalytics record. + */ + +import { Injectable } from '@nestjs/common'; +import { CourseAnalytics } from '../course-analytics.entity'; +import { DataCollectionStage } from './data-collection.stage'; +import { AggregationStage } from './aggregation.stage'; +import { PersistenceStage } from './persistence.stage'; +import { CacheStage } from './cache.stage'; +import { PipelineContext } from './pipeline.types'; + +@Injectable() +export class AnalyticsPipeline { + constructor( + private readonly dataCollectionStage: DataCollectionStage, + private readonly aggregationStage: AggregationStage, + private readonly persistenceStage: PersistenceStage, + private readonly cacheStage: CacheStage, + ) {} + + async run(courseId: string): Promise { + const ctx: PipelineContext = { courseId }; + + await this.dataCollectionStage.execute(ctx); + await this.aggregationStage.execute(ctx); + await this.persistenceStage.execute(ctx); + await this.cacheStage.execute(ctx); + + if (!ctx.record) { + throw new Error(`Pipeline did not produce a record for course ${courseId}`); + } + + return ctx.record; + } +} diff --git a/apps/backend/src/analytics/pipeline/cache.stage.ts b/apps/backend/src/analytics/pipeline/cache.stage.ts new file mode 100644 index 00000000..bb3f1d62 --- /dev/null +++ b/apps/backend/src/analytics/pipeline/cache.stage.ts @@ -0,0 +1,21 @@ +/** + * CacheStage — Stage 4 of the analytics ingestion pipeline. + * + * Responsibility: Invalidate the stale cache entry for the course so that + * the next call to `getAnalytics` fetches the freshly persisted record. + * This stage performs exactly one cache operation per course. + */ + +import { Injectable, Inject } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { PipelineContext, PipelineStage } from './pipeline.types'; + +@Injectable() +export class CacheStage implements PipelineStage { + constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {} + + async execute(ctx: PipelineContext): Promise { + await this.cache.del(`analytics:${ctx.courseId}`); + } +} diff --git a/apps/backend/src/analytics/pipeline/data-collection.stage.ts b/apps/backend/src/analytics/pipeline/data-collection.stage.ts new file mode 100644 index 00000000..24f73283 --- /dev/null +++ b/apps/backend/src/analytics/pipeline/data-collection.stage.ts @@ -0,0 +1,100 @@ +/** + * DataCollectionStage — Stage 1 of the analytics ingestion pipeline. + * + * Responsibility: Execute all raw database queries for a given course in + * parallel and populate `ctx.rawData` with the results. This stage does + * NO computation — it is purely I/O. + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Enrollment } from '../../enrollments/enrollment.entity'; +import { Progress } from '../../progress/progress.entity'; +import { Review } from '../../courses/review.entity'; +import { PipelineContext, PipelineStage, RawCourseData } from './pipeline.types'; + +@Injectable() +export class DataCollectionStage implements PipelineStage { + constructor( + @InjectRepository(Enrollment) + private readonly enrollmentRepo: Repository, + @InjectRepository(Progress) + private readonly progressRepo: Repository, + @InjectRepository(Review) + private readonly reviewRepo: Repository, + ) {} + + async execute(ctx: PipelineContext): Promise { + const { courseId } = ctx; + + const [ + totalEnrollments, + totalCompletions, + reviewStats, + progressStats, + activeLearnersLast30Days, + ] = await Promise.all([ + this.countTotalEnrollments(courseId), + this.countTotalCompletions(courseId), + this.fetchReviewStats(courseId), + this.fetchProgressStats(courseId), + this.fetchActiveLearners(courseId), + ]); + + const rawData: RawCourseData = { + courseId, + totalEnrollments, + totalCompletions, + reviewStats, + progressStats, + activeLearnersLast30Days, + }; + + ctx.rawData = rawData; + } + + private countTotalEnrollments(courseId: string): Promise { + return this.enrollmentRepo.count({ where: { courseId } }); + } + + private async countTotalCompletions(courseId: string): Promise { + return this.enrollmentRepo + .createQueryBuilder('e') + .where('e.courseId = :courseId', { courseId }) + .andWhere('e.completedAt IS NOT NULL') + .getCount(); + } + + private async fetchReviewStats( + courseId: string, + ): Promise<{ avg: string; cnt: string } | null> { + return this.reviewRepo + .createQueryBuilder('r') + .select('AVG(r.rating)', 'avg') + .addSelect('COUNT(*)', 'cnt') + .where('r.courseId = :courseId', { courseId }) + .getRawOne<{ avg: string; cnt: string }>(); + } + + private async fetchProgressStats(courseId: string): Promise<{ avg: string } | null> { + return this.progressRepo + .createQueryBuilder('p') + .select('AVG(p.progressPct)', 'avg') + .where('p.courseId = :courseId', { courseId }) + .getRawOne<{ avg: string }>(); + } + + private async fetchActiveLearners( + courseId: string, + ): Promise<{ cnt: string } | null> { + return this.progressRepo + .createQueryBuilder('p') + .where('p.courseId = :courseId', { courseId }) + .andWhere('p.updatedAt > :since', { + since: new Date(Date.now() - 30 * 86_400_000), + }) + .select('COUNT(DISTINCT p.userId)', 'cnt') + .getRawOne<{ cnt: string }>(); + } +} diff --git a/apps/backend/src/analytics/pipeline/index.ts b/apps/backend/src/analytics/pipeline/index.ts new file mode 100644 index 00000000..b3ea5d78 --- /dev/null +++ b/apps/backend/src/analytics/pipeline/index.ts @@ -0,0 +1,6 @@ +export * from './pipeline.types'; +export * from './data-collection.stage'; +export * from './aggregation.stage'; +export * from './persistence.stage'; +export * from './cache.stage'; +export * from './analytics.pipeline'; diff --git a/apps/backend/src/analytics/pipeline/persistence.stage.ts b/apps/backend/src/analytics/pipeline/persistence.stage.ts new file mode 100644 index 00000000..b7aa4cb9 --- /dev/null +++ b/apps/backend/src/analytics/pipeline/persistence.stage.ts @@ -0,0 +1,36 @@ +/** + * PersistenceStage — Stage 3 of the analytics ingestion pipeline. + * + * Responsibility: Upsert the computed `ctx.metrics` into the + * `course_analytics` table and store the resulting entity in `ctx.record`. + * This stage performs exactly one database write per course. + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CourseAnalytics } from '../course-analytics.entity'; +import { PipelineContext, PipelineStage } from './pipeline.types'; + +@Injectable() +export class PersistenceStage implements PipelineStage { + constructor( + @InjectRepository(CourseAnalytics) + private readonly analyticsRepo: Repository, + ) {} + + async execute(ctx: PipelineContext): Promise { + if (!ctx.metrics) { + throw new Error('PersistenceStage requires metrics from AggregationStage'); + } + + const { courseId, ...metrics } = ctx.metrics; + + const existing = await this.analyticsRepo.findOne({ where: { courseId } }); + const record = existing ?? this.analyticsRepo.create({ courseId }); + + Object.assign(record, metrics); + + ctx.record = await this.analyticsRepo.save(record); + } +} diff --git a/apps/backend/src/analytics/pipeline/pipeline.types.ts b/apps/backend/src/analytics/pipeline/pipeline.types.ts new file mode 100644 index 00000000..78cc2ce5 --- /dev/null +++ b/apps/backend/src/analytics/pipeline/pipeline.types.ts @@ -0,0 +1,43 @@ +/** + * Shared types and interfaces for the analytics ingestion pipeline. + * + * The pipeline processes a single course through four discrete stages: + * DataCollectionStage → AggregationStage → PersistenceStage → CacheStage + */ + +import { CourseAnalytics } from '../course-analytics.entity'; + +/** Raw query results gathered by the DataCollectionStage. */ +export interface RawCourseData { + courseId: string; + totalEnrollments: number; + totalCompletions: number; + reviewStats: { avg: string; cnt: string } | null; + progressStats: { avg: string } | null; + activeLearnersLast30Days: { cnt: string } | null; +} + +/** Calculated metrics produced by the AggregationStage. */ +export interface AggregatedMetrics { + courseId: string; + totalEnrollments: number; + totalCompletions: number; + completionRate: number; + averageRating: number; + totalReviews: number; + averageProgressPct: number; + activeLearnersLast30Days: number; +} + +/** Pipeline context passed between stages. */ +export interface PipelineContext { + courseId: string; + rawData?: RawCourseData; + metrics?: AggregatedMetrics; + record?: CourseAnalytics; +} + +/** A single stage in the analytics ingestion pipeline. */ +export interface PipelineStage { + execute(ctx: PipelineContext): Promise; +} diff --git a/apps/backend/src/payouts/payouts.module.ts b/apps/backend/src/payouts/payouts.module.ts index 5d863959..79578302 100644 --- a/apps/backend/src/payouts/payouts.module.ts +++ b/apps/backend/src/payouts/payouts.module.ts @@ -5,11 +5,12 @@ import { PayoutsController } from './payouts.controller'; import { Payout } from './payout.entity'; import { Enrollment } from '../enrollments/enrollment.entity'; import { Course } from '../courses/course.entity'; +import { RoyaltyCalculationService } from './royalty-calculation.service'; @Module({ imports: [TypeOrmModule.forFeature([Payout, Enrollment, Course])], - providers: [PayoutsService], + providers: [RoyaltyCalculationService, PayoutsService], controllers: [PayoutsController], - exports: [PayoutsService], + exports: [PayoutsService, RoyaltyCalculationService], }) export class PayoutsModule {} diff --git a/apps/backend/src/payouts/payouts.service.ts b/apps/backend/src/payouts/payouts.service.ts index b09d601e..8a58e8b9 100644 --- a/apps/backend/src/payouts/payouts.service.ts +++ b/apps/backend/src/payouts/payouts.service.ts @@ -4,7 +4,7 @@ import { Repository, Between } from 'typeorm'; import { Payout } from './payout.entity'; import { Enrollment } from '../enrollments/enrollment.entity'; import { Course } from '../courses/course.entity'; -import { ConfigService } from '@nestjs/config'; +import { RoyaltyCalculationService } from './royalty-calculation.service'; @Injectable() export class PayoutsService { @@ -12,17 +12,15 @@ export class PayoutsService { constructor( @InjectRepository(Payout) - private payoutsRepository: Repository, + private readonly payoutsRepository: Repository, @InjectRepository(Enrollment) - private enrollmentsRepository: Repository, + private readonly enrollmentsRepository: Repository, @InjectRepository(Course) - private coursesRepository: Repository, - private configService: ConfigService, + private readonly coursesRepository: Repository, + private readonly royaltyCalculationService: RoyaltyCalculationService, ) {} async calculatePayouts(startDate: Date, endDate: Date): Promise { - const platformFeePercent = this.configService.get('PLATFORM_FEE_PERCENT', 20); - const courses = await this.coursesRepository.find({ where: { instructorId: null }, relations: ['instructor'], @@ -42,17 +40,20 @@ export class PayoutsService { if (completions === 0) continue; - const coursePrice = this.configService.get(`COURSE_PRICE_${course.id}`, 0); - const totalRevenue = completions * coursePrice; - const platformFee = (totalRevenue * platformFeePercent) / 100; - const instructorShare = totalRevenue - platformFee; + const coursePrice = this.royaltyCalculationService.getCoursePrice(course.id); + const result = this.royaltyCalculationService.calculate({ + completions, + coursePrice, + courseId: course.id, + instructorId: course.instructor.id, + }); const payout = this.payoutsRepository.create({ - instructorId: course.instructor.id, - courseId: course.id, - totalRevenue, - platformFee, - instructorShare, + instructorId: result.instructorId, + courseId: result.courseId, + totalRevenue: result.totalRevenue, + platformFee: result.platformFee, + instructorShare: result.instructorShare, status: 'pending', payoutDate: new Date(), }); @@ -76,7 +77,9 @@ export class PayoutsService { try { payout.status = 'processed'; payout.transactionId = `TXN-${Date.now()}`; - this.logger.log(`Payout processed for instructor ${payout.instructor.email}: $${payout.instructorShare}`); + this.logger.log( + `Payout processed for instructor ${payout.instructor.email}: $${payout.instructorShare}`, + ); } catch (error) { payout.status = 'failed'; this.logger.error(`Payout failed: ${error.message}`); @@ -98,9 +101,7 @@ export class PayoutsService { pendingPayouts: number; processedPayouts: number; }> { - const payouts = await this.payoutsRepository.find({ - where: { instructorId }, - }); + const payouts = await this.payoutsRepository.find({ where: { instructorId } }); const totalEarnings = payouts.reduce((sum, p) => sum + Number(p.instructorShare), 0); const pendingPayouts = payouts.filter((p) => p.status === 'pending').length; diff --git a/apps/backend/src/payouts/royalty-calculation.service.spec.ts b/apps/backend/src/payouts/royalty-calculation.service.spec.ts new file mode 100644 index 00000000..11933dc8 --- /dev/null +++ b/apps/backend/src/payouts/royalty-calculation.service.spec.ts @@ -0,0 +1,170 @@ +/** + * Unit tests for RoyaltyCalculationService (#815). + * + * Verifies the royalty calculation logic in isolation — no I/O required. + */ + +import { RoyaltyCalculationService, RoyaltyInput } from './royalty-calculation.service'; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeConfigService(overrides: Record = {}) { + const defaults: Record = { + PLATFORM_FEE_PERCENT: 20, + }; + return { + get: jest.fn().mockImplementation((key: string, fallback: any) => { + return key in { ...defaults, ...overrides } + ? ({ ...defaults, ...overrides })[key] + : fallback; + }), + }; +} + +function makeService(configOverrides: Record = {}) { + const configService = makeConfigService(configOverrides); + return new RoyaltyCalculationService(configService as any); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// calculate +// ═════════════════════════════════════════════════════════════════════════════ + +describe('RoyaltyCalculationService.calculate', () => { + it('correctly computes totalRevenue, platformFee, and instructorShare', () => { + const service = makeService({ PLATFORM_FEE_PERCENT: 20 }); + const input: RoyaltyInput = { + completions: 10, + coursePrice: 100, + courseId: 'course-1', + instructorId: 'inst-1', + }; + + const result = service.calculate(input); + + expect(result.totalRevenue).toBe(1000); // 10 × 100 + expect(result.platformFee).toBe(200); // 1000 × 20% + expect(result.instructorShare).toBe(800); // 1000 – 200 + expect(result.platformFeePercent).toBe(20); + expect(result.courseId).toBe('course-1'); + expect(result.instructorId).toBe('inst-1'); + }); + + it('returns zero values when completions are zero', () => { + const service = makeService(); + const result = service.calculate({ + completions: 0, + coursePrice: 100, + courseId: 'c', + instructorId: 'i', + }); + expect(result.totalRevenue).toBe(0); + expect(result.platformFee).toBe(0); + expect(result.instructorShare).toBe(0); + }); + + it('returns zero values when coursePrice is zero', () => { + const service = makeService(); + const result = service.calculate({ + completions: 50, + coursePrice: 0, + courseId: 'c', + instructorId: 'i', + }); + expect(result.totalRevenue).toBe(0); + expect(result.platformFee).toBe(0); + expect(result.instructorShare).toBe(0); + }); + + it('respects a custom platform fee percentage', () => { + const service = makeService({ PLATFORM_FEE_PERCENT: 30 }); + const result = service.calculate({ + completions: 10, + coursePrice: 200, + courseId: 'c', + instructorId: 'i', + }); + // totalRevenue = 2000, platformFee = 600 (30%), instructorShare = 1400 + expect(result.totalRevenue).toBe(2000); + expect(result.platformFee).toBe(600); + expect(result.instructorShare).toBe(1400); + }); + + it('falls back to 20% fee when PLATFORM_FEE_PERCENT is not configured', () => { + const configService = { + get: jest.fn().mockImplementation((_key: string, fallback: any) => fallback), + }; + const service = new RoyaltyCalculationService(configService as any); + const result = service.calculate({ + completions: 10, + coursePrice: 50, + courseId: 'c', + instructorId: 'i', + }); + expect(result.platformFeePercent).toBe(20); + expect(result.platformFee).toBe(100); // 500 × 20% + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// calculateBatch +// ═════════════════════════════════════════════════════════════════════════════ + +describe('RoyaltyCalculationService.calculateBatch', () => { + it('excludes courses with zero completions', () => { + const service = makeService(); + const inputs: RoyaltyInput[] = [ + { completions: 5, coursePrice: 100, courseId: 'c1', instructorId: 'i1' }, + { completions: 0, coursePrice: 100, courseId: 'c2', instructorId: 'i2' }, + { completions: 3, coursePrice: 200, courseId: 'c3', instructorId: 'i3' }, + ]; + + const results = service.calculateBatch(inputs); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.courseId)).toEqual(['c1', 'c3']); + }); + + it('returns an empty array when all completions are zero', () => { + const service = makeService(); + const results = service.calculateBatch([ + { completions: 0, coursePrice: 100, courseId: 'c1', instructorId: 'i1' }, + ]); + expect(results).toHaveLength(0); + }); + + it('calculates correct values for each course in the batch', () => { + const service = makeService({ PLATFORM_FEE_PERCENT: 25 }); + const results = service.calculateBatch([ + { completions: 4, coursePrice: 100, courseId: 'c1', instructorId: 'i1' }, + { completions: 2, coursePrice: 50, courseId: 'c2', instructorId: 'i2' }, + ]); + + // c1: 4×100=400, fee=100 (25%), share=300 + expect(results[0].totalRevenue).toBe(400); + expect(results[0].instructorShare).toBe(300); + + // c2: 2×50=100, fee=25 (25%), share=75 + expect(results[1].totalRevenue).toBe(100); + expect(results[1].instructorShare).toBe(75); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// getCoursePrice +// ═════════════════════════════════════════════════════════════════════════════ + +describe('RoyaltyCalculationService.getCoursePrice', () => { + it('returns the configured price for a course', () => { + const service = makeService({ 'COURSE_PRICE_course-abc': 149 }); + expect(service.getCoursePrice('course-abc')).toBe(149); + }); + + it('falls back to 0 when no price is configured', () => { + const configService = { + get: jest.fn().mockImplementation((_key: string, fallback: any) => fallback), + }; + const service = new RoyaltyCalculationService(configService as any); + expect(service.getCoursePrice('unknown-course')).toBe(0); + }); +}); diff --git a/apps/backend/src/payouts/royalty-calculation.service.ts b/apps/backend/src/payouts/royalty-calculation.service.ts new file mode 100644 index 00000000..4f41893d --- /dev/null +++ b/apps/backend/src/payouts/royalty-calculation.service.ts @@ -0,0 +1,96 @@ +/** + * RoyaltyCalculationService (#815) + * + * Responsibility: Encapsulates all royalty / revenue-share calculation logic + * for instructor payouts. This service is pure computation — it receives the + * inputs it needs and returns a structured result without performing any I/O. + * + * Seams extracted from PayoutsService.calculatePayouts: + * • Platform-fee percentage retrieval + * • Per-course revenue calculation (completions × price) + * • Platform-fee deduction + * • Instructor-share computation + */ + +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface RoyaltyInput { + /** Number of completed enrollments within the reporting window. */ + completions: number; + /** Unit price for the course (e.g. from config). */ + coursePrice: number; + /** Course identifier — passed through for traceability. */ + courseId: string; + /** Instructor identifier — passed through for traceability. */ + instructorId: string; +} + +export interface RoyaltyResult { + courseId: string; + instructorId: string; + /** Gross revenue = completions × coursePrice */ + totalRevenue: number; + /** Platform's share = totalRevenue × (platformFeePercent / 100) */ + platformFee: number; + /** Instructor's net share = totalRevenue − platformFee */ + instructorShare: number; + /** Platform fee percentage used for this calculation. */ + platformFeePercent: number; +} + +@Injectable() +export class RoyaltyCalculationService { + constructor(private readonly configService: ConfigService) {} + + /** + * Return the configured platform fee percentage. + * Falls back to 20 % if the environment variable is not set. + */ + getPlatformFeePercent(): number { + return this.configService.get('PLATFORM_FEE_PERCENT', 20); + } + + /** + * Return the configured price for a given course. + * Falls back to 0 if no price is configured for this course. + */ + getCoursePrice(courseId: string): number { + return this.configService.get(`COURSE_PRICE_${courseId}`, 0); + } + + /** + * Calculate royalty figures for a single course/instructor combination. + * + * @param input The raw data needed for the calculation. + * @returns A RoyaltyResult with all derived values. + */ + calculate(input: RoyaltyInput): RoyaltyResult { + const platformFeePercent = this.getPlatformFeePercent(); + const totalRevenue = input.completions * input.coursePrice; + const platformFee = (totalRevenue * platformFeePercent) / 100; + const instructorShare = totalRevenue - platformFee; + + return { + courseId: input.courseId, + instructorId: input.instructorId, + totalRevenue, + platformFee, + instructorShare, + platformFeePercent, + }; + } + + /** + * Calculate royalties for multiple courses in one call. + * Courses with zero completions are excluded from the result. + * + * @param inputs Array of per-course royalty inputs. + * @returns Array of RoyaltyResults (only for courses with completions > 0). + */ + calculateBatch(inputs: RoyaltyInput[]): RoyaltyResult[] { + return inputs + .filter((input) => input.completions > 0) + .map((input) => this.calculate(input)); + } +}