From c8d902f858b99d12f4eeee8202778065480e0271 Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 28 Jul 2026 09:06:26 +0100 Subject: [PATCH 1/4] Stop publishing fabricated enrollment conversion metrics generated with Math.random --- src/courses/courses.controller.ts | 4 ++-- src/courses/courses.module.ts | 4 +++- src/courses/courses.service.ts | 4 +++- src/routing/interceptors/routing.interceptor.ts | 1 - src/utils/masking/kpi.service.ts | 12 ++++++------ 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/courses/courses.controller.ts b/src/courses/courses.controller.ts index cb381c04..e5ae6c5d 100644 --- a/src/courses/courses.controller.ts +++ b/src/courses/courses.controller.ts @@ -57,8 +57,8 @@ export class CoursesController { @ApiOperation({ summary: 'Get a specific course by ID' }) @ApiResponse({ status: 200, description: 'Returns course details' }) @ApiResponse({ status: 404, description: 'Course not found' }) - async findOne(@Param('id') id: string) { - return this.coursesService.findOne(id); + async findOne(@Param('id') id: string, @Request() req) { + return this.coursesService.findOne(id, req.user); } @Put(':id') diff --git a/src/courses/courses.module.ts b/src/courses/courses.module.ts index 5c1354f5..34df4fa7 100644 --- a/src/courses/courses.module.ts +++ b/src/courses/courses.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CoursesService } from './courses.service'; import { EnrollmentsService } from './enrollments.service'; @@ -10,6 +10,7 @@ import { CourseReview } from './entities/course-review.entity'; import { CourseModule } from './entities/course-module.entity'; import { BulkOperation } from './entities/bulk-operation.entity'; import { CachingModule } from '../caching/caching.module'; +import { AnalyticsModule } from '../analytics/analytics.module'; import { PaginationService } from '../common/services/pagination.service'; @@ -17,6 +18,7 @@ import { PaginationService } from '../common/services/pagination.service'; imports: [ TypeOrmModule.forFeature([Course, Enrollment, CourseReview, CourseModule, BulkOperation]), CachingModule, + forwardRef(() => AnalyticsModule), ], providers: [CoursesService, EnrollmentsService, PaginationService], controllers: [CoursesController, EnrollmentsController], diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index 4afcfa63..cb2a99fd 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Optional } from '@nestjs/common'; +import { Injectable, Optional, Inject, forwardRef } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; @@ -31,6 +31,8 @@ import { PaginationQueryDto } from '../common/dto/pagination.dto'; import { OffsetPaginatedResponse } from '../common/interfaces/pagination.interface'; import { PaginationService } from '../common/services/pagination.service'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { EventType } from '../analytics/entities/event.entity'; function checkUserRole(user?: User, ...roleNames: UserRole[]): boolean { if (!user) return false; diff --git a/src/routing/interceptors/routing.interceptor.ts b/src/routing/interceptors/routing.interceptor.ts index cce0e4a2..7a56fb46 100644 --- a/src/routing/interceptors/routing.interceptor.ts +++ b/src/routing/interceptors/routing.interceptor.ts @@ -233,7 +233,6 @@ export class RoutingInterceptor implements NestInterceptor { // Include experimental data if (transformed.analytics) { transformed.analytics.experimental = { - predictiveScores: Math.random(), behaviorInsights: 'beta-feature-data', }; } diff --git a/src/utils/masking/kpi.service.ts b/src/utils/masking/kpi.service.ts index 9abcbe53..7ee9b24c 100644 --- a/src/utils/masking/kpi.service.ts +++ b/src/utils/masking/kpi.service.ts @@ -8,7 +8,7 @@ import { MetricsService } from './metrics.service'; import { User } from '../../users/entities/user.entity'; import { Course } from '../../courses/entities/course.entity'; import { Enrollment } from '../../courses/entities/enrollment.entity'; -import { AnalyticsEvent } from '../../analytics/entities/event.entity'; +import { AnalyticsEvent, EventType } from '../../analytics/entities/event.entity'; import { Payment, PaymentStatus } from '../../payments/entities/payment.entity'; @Injectable() @@ -111,16 +111,16 @@ export class KpiService { } async calculateEnrollmentConversionRate(): Promise { - // This is a simplified version. A real-world scenario would track views vs enrollments. - // Here we'll simulate it by looking at enrollments vs total users. - // For a more accurate metric, you'd need an event tracking system for 'course_viewed'. const courses = await this.courseRepository.find(); this.metricsService.enrollmentConversionGauge.reset(); for (const course of courses) { const enrollments = await this.enrollmentRepository.count({ where: { courseId: course.id } }); - // Placeholder for views. In a real system, you'd query an analytics table. - const views = enrollments * 5 + Math.floor(Math.random() * 100); // Simulate views + const views = await this.eventRepository + .createQueryBuilder('event') + .where('event.eventType = :eventType', { eventType: EventType.COURSE_VIEW }) + .andWhere("event.properties->>'courseId' = :courseId", { courseId: course.id }) + .getCount(); const conversionRate = views > 0 ? (enrollments / views) * 100 : 0; this.metricsService.enrollmentConversionGauge.labels(course.id).set(conversionRate); From 7a08b97ce95c0ff09ee59f9e1838d1fa748cf86e Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 28 Jul 2026 09:09:37 +0100 Subject: [PATCH 2/4] quick fix [ci skip] From 748591ee72c872e889620124865a58f47552e82a Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 28 Jul 2026 09:25:52 +0100 Subject: [PATCH 3/4] Aggregate course enrollment counts in SQL instead of eager-loading every enrollment in the dashboard --- src/courses/courses.service.ts | 15 +++++- src/dashboard/dashboard.service.spec.ts | 64 ++++++++++++++++++++++++- src/dashboard/dashboard.service.ts | 36 +++++++++----- 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index cb2a99fd..7c72990a 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -72,6 +72,9 @@ export class CoursesService { private readonly eventEmitter: EventEmitter2, @Optional() private readonly paginationService: PaginationService = new PaginationService(), + @Inject(forwardRef(() => AnalyticsService)) + @Optional() + private readonly analyticsService?: AnalyticsService, ) {} // ─── CRUD ──────────────────────────────────────────────────────────────────── @@ -145,7 +148,7 @@ export class CoursesService { /** * Returns a single course by ID. */ - async findOne(id: string): Promise { + async findOne(id: string, requestingUser?: User): Promise { const course = await this.courseRepo.findOne({ where: { id }, relations: ['instructor', 'reviews', 'reviews.reviewer', 'prerequisite'], @@ -153,6 +156,16 @@ export class CoursesService { if (!course) { throw new ResourceNotFoundException('Course', id); } + if (this.analyticsService) { + this.analyticsService.trackEvent({ + eventType: EventType.COURSE_VIEW, + category: 'course', + action: 'view', + label: course.title, + properties: { courseId: course.id }, + userId: requestingUser?.id, + }).catch(() => {}); + } return course; } diff --git a/src/dashboard/dashboard.service.spec.ts b/src/dashboard/dashboard.service.spec.ts index befafce4..5762f9f6 100644 --- a/src/dashboard/dashboard.service.spec.ts +++ b/src/dashboard/dashboard.service.spec.ts @@ -35,7 +35,19 @@ describe('DashboardService', () => { }, { provide: getRepositoryToken(Course), - useValue: { find: jest.fn().mockResolvedValue([]) }, + useValue: { + find: jest.fn().mockResolvedValue([]), + createQueryBuilder: jest.fn().mockReturnValue({ + leftJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + addGroupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }), + }, }, { provide: getRepositoryToken(AnalyticsEvent), @@ -69,6 +81,56 @@ describe('DashboardService', () => { expect(csv).toContain('section,metric,value'); }); + it('should compute course performance using aggregate query without hydrating enrollments', async () => { + const courseQueryBuilder = { + leftJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + addGroupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { course_id: 'c-1', course_title: 'Math', course_price: '49.99', course_status: 'published', enrollmentCount: '100' }, + { course_id: 'c-2', course_title: 'Science', course_price: '39.99', course_status: 'published', enrollmentCount: '50' }, + { course_id: 'c-3', course_title: 'History', course_price: '29.99', course_status: 'draft', enrollmentCount: '0' }, + ]), + }; + + const createQueryBuilderSpy = jest.fn().mockReturnValue(courseQueryBuilder); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DashboardService, + { provide: getRepositoryToken(Payment), useValue: { find: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0) } }, + { provide: getRepositoryToken(User), useValue: { find: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(10) } }, + { provide: getRepositoryToken(Enrollment), useValue: { count: jest.fn().mockResolvedValue(5) } }, + { provide: getRepositoryToken(Course), useValue: { createQueryBuilder: createQueryBuilderSpy } }, + { provide: getRepositoryToken(AnalyticsEvent), useValue: { createQueryBuilder: jest.fn() } }, + { provide: ReportingService, useValue: { generateRevenueRecognitionReport: jest.fn().mockResolvedValue({ grossRevenue: 100, netRevenue: 90, totalRefunds: 10, currency: 'USD' }) } }, + ], + }).compile(); + + const localService = module.get(DashboardService); + const result = await localService.getCoursePerformanceMetrics(); + + expect(createQueryBuilderSpy).toHaveBeenCalledWith('course'); + expect(courseQueryBuilder.leftJoin).toHaveBeenCalledWith('course.enrollments', 'enrollment'); + expect(courseQueryBuilder.addSelect).toHaveBeenCalledWith('COUNT(enrollment.id)', 'enrollmentCount'); + expect(courseQueryBuilder.take).toHaveBeenCalledWith(20); + expect(courseQueryBuilder.orderBy).toHaveBeenCalledWith('enrollmentCount', 'DESC'); + expect(courseQueryBuilder.getRawMany).toHaveBeenCalled(); + + expect(result).toHaveLength(3); + expect(result[0].courseId).toBe('c-1'); + expect(result[0].enrollments).toBe(100); + expect(result[1].courseId).toBe('c-2'); + expect(result[1].enrollments).toBe(50); + expect(result[2].courseId).toBe('c-3'); + expect(result[2].enrollments).toBe(0); + expect(result[0].price).toBe(49.99); + }); + it('should generate instructor dashboard analytics', async () => { const paymentQueryBuilder = { leftJoin: jest.fn().mockReturnThis(), diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts index 7a7c01c9..b5bc1a46 100644 --- a/src/dashboard/dashboard.service.ts +++ b/src/dashboard/dashboard.service.ts @@ -81,17 +81,31 @@ export class DashboardService { } async getCoursePerformanceMetrics() { - const courses = await this.courseRepository.find({ relations: ['enrollments'] }); - return courses - .map((course) => ({ - courseId: course.id, - title: course.title, - enrollments: course.enrollments?.length ?? 0, - price: course.price, - status: course.status, - })) - .sort((a, b) => b.enrollments - a.enrollments) - .slice(0, 20); + const results = await this.courseRepository + .createQueryBuilder('course') + .leftJoin('course.enrollments', 'enrollment') + .select([ + 'course.id', + 'course.title', + 'course.price', + 'course.status', + ]) + .addSelect('COUNT(enrollment.id)', 'enrollmentCount') + .groupBy('course.id') + .addGroupBy('course.title') + .addGroupBy('course.price') + .addGroupBy('course.status') + .orderBy('enrollmentCount', 'DESC') + .take(20) + .getRawMany(); + + return results.map((row) => ({ + courseId: row.course_id, + title: row.course_title, + enrollments: parseInt(row.enrollmentCount, 10), + price: parseFloat(row.course_price), + status: row.course_status, + })); } async getConversionFunnel() { From 4f40e7c833f8f3b8f863e002c9290a7b0d806083 Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 28 Jul 2026 09:33:25 +0100 Subject: [PATCH 4/4] quick fix [ci skip]