From c8d902f858b99d12f4eeee8202778065480e0271 Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 28 Jul 2026 09:06:26 +0100 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] quick fix [ci skip] From f5fe61f43c2f115af00dab2e0644edeb435ef884 Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 28 Jul 2026 09:50:06 +0100 Subject: [PATCH 5/8] Stop loading every platform enrollment into memory in CollaborativeFilteringService --- .../collaborative-filtering.service.ts | 115 ++++++++++++------ .../recommendation-engine.service.ts | 14 +++ src/recommendations/recommendation.spec.ts | 1 + 3 files changed, 94 insertions(+), 36 deletions(-) diff --git a/src/recommendations/collaborative-filtering.service.ts b/src/recommendations/collaborative-filtering.service.ts index 4573d72b..18fb92ea 100644 --- a/src/recommendations/collaborative-filtering.service.ts +++ b/src/recommendations/collaborative-filtering.service.ts @@ -1,18 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, In } from 'typeorm'; import { Enrollment } from '../courses/entities/enrollment.entity'; -/** - * Implements user-based collaborative filtering. - * - * Algorithm: - * 1. Load all active/completed enrollments. - * 2. Build a user→course set map. - * 3. Compute Jaccard similarity between the target user and every other user. - * 4. Aggregate course scores from the most similar users (weighted by similarity). - * 5. Return ranked course IDs the target user has NOT yet enrolled in. - */ @Injectable() export class CollaborativeFilteringService { private readonly logger = new Logger(CollaborativeFilteringService.name); @@ -27,28 +17,91 @@ export class CollaborativeFilteringService { excludeCourseIds: Set, topN: number, ): Promise> { - const enrollments = await this.enrollmentRepo.find({ - select: ['userId', 'courseId'], - where: [{ status: 'active' }, { status: 'completed' }], + const targetEnrollments = await this.enrollmentRepo.find({ + select: ['courseId'], + where: [ + { userId, status: 'active' }, + { userId, status: 'completed' }, + ], }); - const userCourses = new Map>(); - for (const e of enrollments) { - if (!userCourses.has(e.userId)) userCourses.set(e.userId, new Set()); - userCourses.get(e.userId)!.add(e.courseId); + const targetCourseIds = targetEnrollments.map((e) => e.courseId); + if (targetCourseIds.length === 0) return []; + const targetCount = targetCourseIds.length; + + const maxNeighbors = Math.max(topN * 3, 50); + + const neighborRows: Array<{ + userId: string; + otherCount: string; + intersection: string; + }> = await this.enrollmentRepo.query( + ` + WITH target_courses AS ( + SELECT course_id FROM enrollment + WHERE user_id = $1 AND status IN ($2, $3) + ), + candidates AS ( + SELECT DISTINCT e.user_id + FROM enrollment e + WHERE e.status IN ($2, $3) + AND e.user_id <> $1 + AND e.course_id IN (SELECT course_id FROM target_courses) + ) + SELECT + e.user_id AS "userId", + COUNT(DISTINCT e.course_id) AS "otherCount", + COUNT(DISTINCT CASE WHEN tc.course_id IS NOT NULL THEN e.course_id END) AS "intersection" + FROM enrollment e + LEFT JOIN target_courses tc ON tc.course_id = e.course_id + WHERE e.status IN ($2, $3) + AND e.user_id IN (SELECT user_id FROM candidates) + GROUP BY e.user_id + HAVING COUNT(DISTINCT CASE WHEN tc.course_id IS NOT NULL THEN e.course_id END) > 0 + ORDER BY "intersection" DESC + LIMIT $4 + `, + [userId, 'active', 'completed', maxNeighbors], + ); + + if (neighborRows.length === 0) return []; + + const neighbors: Array<{ userId: string; similarity: number }> = []; + for (const row of neighborRows) { + const intersection = parseInt(row.intersection, 10); + const otherCount = parseInt(row.otherCount, 10); + const similarity = intersection / (targetCount + otherCount - intersection); + if (similarity <= 0) continue; + neighbors.push({ userId: row.userId, similarity }); } - const targetCourses = userCourses.get(userId) ?? new Set(); - const courseScores = new Map(); + if (neighbors.length === 0) return []; - for (const [otherUserId, otherCourses] of userCourses) { - if (otherUserId === userId) continue; + const neighborIds = neighbors.map((n) => n.userId); + const neighborEnrollments = await this.enrollmentRepo.find({ + select: ['userId', 'courseId'], + where: [ + { userId: In(neighborIds), status: 'active' }, + { userId: In(neighborIds), status: 'completed' }, + ], + }); - const similarity = this.jaccardSimilarity(targetCourses, otherCourses); - if (similarity === 0) continue; + const neighborCourseMap = new Map>(); + for (const e of neighborEnrollments) { + if (!neighborCourseMap.has(e.userId)) { + neighborCourseMap.set(e.userId, new Set()); + } + neighborCourseMap.get(e.userId)!.add(e.courseId); + } + + const targetSet = new Set(targetCourseIds); + const courseScores = new Map(); - for (const courseId of otherCourses) { - if (targetCourses.has(courseId) || excludeCourseIds.has(courseId)) continue; + for (const { userId: neighborId, similarity } of neighbors) { + const courses = neighborCourseMap.get(neighborId); + if (!courses) continue; + for (const courseId of courses) { + if (targetSet.has(courseId) || excludeCourseIds.has(courseId)) continue; courseScores.set(courseId, (courseScores.get(courseId) ?? 0) + similarity); } } @@ -58,14 +111,4 @@ export class CollaborativeFilteringService { .slice(0, topN) .map(([courseId, score]) => ({ courseId, score })); } - - private jaccardSimilarity(a: Set, b: Set): number { - if (a.size === 0 && b.size === 0) return 0; - let intersection = 0; - for (const id of a) { - if (b.has(id)) intersection++; - } - const union = a.size + b.size - intersection; - return union === 0 ? 0 : intersection / union; - } } diff --git a/src/recommendations/recommendation-engine.service.ts b/src/recommendations/recommendation-engine.service.ts index 74b99af2..0b254c71 100644 --- a/src/recommendations/recommendation-engine.service.ts +++ b/src/recommendations/recommendation-engine.service.ts @@ -1,9 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { Course, CourseStatus } from '../courses/entities/course.entity'; import { Enrollment } from '../courses/entities/enrollment.entity'; import { CachingService } from '../caching/caching.service'; +import { CACHE_EVENTS } from '../caching/caching.constants'; import { CollaborativeFilteringService } from './collaborative-filtering.service'; import { ContentBasedFilteringService } from './content-based-filtering.service'; import { RecommendedCourseDto } from './dto/recommendation.dto'; @@ -47,6 +49,18 @@ export class RecommendationEngineService { await this.caching.deleteByPattern(`recommendations:${userId}:*`); } + @OnEvent(CACHE_EVENTS.ENROLLMENT_CREATED) + async onEnrollmentCreated(payload: { id: string }): Promise { + const enrollment = await this.enrollmentRepo.findOne({ + select: ['userId'], + where: { id: payload.id }, + }); + if (enrollment) { + this.logger.debug(`Invalidating recommendations for user ${enrollment.userId} after enrollment`); + await this.invalidate(enrollment.userId); + } + } + private async computeRecommendations( userId: string, limit: number, diff --git a/src/recommendations/recommendation.spec.ts b/src/recommendations/recommendation.spec.ts index e70e0d89..a8e54726 100644 --- a/src/recommendations/recommendation.spec.ts +++ b/src/recommendations/recommendation.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Course, CourseStatus } from '../courses/entities/course.entity'; import { Enrollment } from '../courses/entities/enrollment.entity'; From 8222a1d33f3b78ed18fecc4e393ebe670c171cfb Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 28 Jul 2026 10:12:40 +0100 Subject: [PATCH 6/8] Stop loading every platform enrollment into memory in CollaborativeFilteringService --- .../collaborative-filtering.service.ts | 114 +++++++----------- src/recommendations/recommendation.spec.ts | 78 +++++++++--- 2 files changed, 109 insertions(+), 83 deletions(-) diff --git a/src/recommendations/collaborative-filtering.service.ts b/src/recommendations/collaborative-filtering.service.ts index 18fb92ea..695a729d 100644 --- a/src/recommendations/collaborative-filtering.service.ts +++ b/src/recommendations/collaborative-filtering.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; +import { Repository } from 'typeorm'; import { Enrollment } from '../courses/entities/enrollment.entity'; @Injectable() @@ -25,90 +25,66 @@ export class CollaborativeFilteringService { ], }); - const targetCourseIds = targetEnrollments.map((e) => e.courseId); - if (targetCourseIds.length === 0) return []; - const targetCount = targetCourseIds.length; + if (targetEnrollments.length === 0) return []; + const excludeArray = [...excludeCourseIds]; const maxNeighbors = Math.max(topN * 3, 50); - const neighborRows: Array<{ - userId: string; - otherCount: string; - intersection: string; - }> = await this.enrollmentRepo.query( + const rows: Array<{ courseId: string; score: number }> = await this.enrollmentRepo.query( ` WITH target_courses AS ( SELECT course_id FROM enrollment - WHERE user_id = $1 AND status IN ($2, $3) + WHERE user_id = $1 AND status IN ($2, $3) AND deleted_at IS NULL + ), + target_count AS ( + SELECT COUNT(*)::int AS cnt FROM target_courses ), candidates AS ( SELECT DISTINCT e.user_id FROM enrollment e WHERE e.status IN ($2, $3) AND e.user_id <> $1 + AND e.deleted_at IS NULL AND e.course_id IN (SELECT course_id FROM target_courses) + ), + user_stats AS ( + SELECT + e.user_id, + COUNT(DISTINCT e.course_id)::int AS other_count, + COUNT(DISTINCT CASE WHEN tc.course_id IS NOT NULL THEN e.course_id END)::int AS intersection + FROM enrollment e + JOIN candidates c ON c.user_id = e.user_id + LEFT JOIN target_courses tc ON e.course_id = tc.course_id + WHERE e.status IN ($2, $3) AND e.deleted_at IS NULL + GROUP BY e.user_id + HAVING COUNT(DISTINCT CASE WHEN tc.course_id IS NOT NULL THEN e.course_id END) > 0 + ), + ranked_users AS ( + SELECT + us.user_id, + us.intersection::float / GREATEST(tc.cnt + us.other_count - us.intersection, 1) AS similarity + FROM user_stats us, target_count tc + ORDER BY similarity DESC + LIMIT $4 + ), + candidate_courses AS ( + SELECT + e.course_id, + SUM(ru.similarity)::float AS score + FROM enrollment e + JOIN ranked_users ru ON ru.user_id = e.user_id + WHERE e.status IN ($2, $3) + AND e.deleted_at IS NULL + AND e.course_id <> ALL($5::text[]) + GROUP BY e.course_id + ORDER BY score DESC + LIMIT $6 ) - SELECT - e.user_id AS "userId", - COUNT(DISTINCT e.course_id) AS "otherCount", - COUNT(DISTINCT CASE WHEN tc.course_id IS NOT NULL THEN e.course_id END) AS "intersection" - FROM enrollment e - LEFT JOIN target_courses tc ON tc.course_id = e.course_id - WHERE e.status IN ($2, $3) - AND e.user_id IN (SELECT user_id FROM candidates) - GROUP BY e.user_id - HAVING COUNT(DISTINCT CASE WHEN tc.course_id IS NOT NULL THEN e.course_id END) > 0 - ORDER BY "intersection" DESC - LIMIT $4 + SELECT course_id AS "courseId", score FROM candidate_courses `, - [userId, 'active', 'completed', maxNeighbors], + [userId, 'active', 'completed', maxNeighbors, excludeArray, topN], ); - if (neighborRows.length === 0) return []; - - const neighbors: Array<{ userId: string; similarity: number }> = []; - for (const row of neighborRows) { - const intersection = parseInt(row.intersection, 10); - const otherCount = parseInt(row.otherCount, 10); - const similarity = intersection / (targetCount + otherCount - intersection); - if (similarity <= 0) continue; - neighbors.push({ userId: row.userId, similarity }); - } - - if (neighbors.length === 0) return []; - - const neighborIds = neighbors.map((n) => n.userId); - const neighborEnrollments = await this.enrollmentRepo.find({ - select: ['userId', 'courseId'], - where: [ - { userId: In(neighborIds), status: 'active' }, - { userId: In(neighborIds), status: 'completed' }, - ], - }); - - const neighborCourseMap = new Map>(); - for (const e of neighborEnrollments) { - if (!neighborCourseMap.has(e.userId)) { - neighborCourseMap.set(e.userId, new Set()); - } - neighborCourseMap.get(e.userId)!.add(e.courseId); - } - - const targetSet = new Set(targetCourseIds); - const courseScores = new Map(); - - for (const { userId: neighborId, similarity } of neighbors) { - const courses = neighborCourseMap.get(neighborId); - if (!courses) continue; - for (const courseId of courses) { - if (targetSet.has(courseId) || excludeCourseIds.has(courseId)) continue; - courseScores.set(courseId, (courseScores.get(courseId) ?? 0) + similarity); - } - } - - return [...courseScores.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, topN) - .map(([courseId, score]) => ({ courseId, score })); + return rows; } } diff --git a/src/recommendations/recommendation.spec.ts b/src/recommendations/recommendation.spec.ts index a8e54726..5f0cbab6 100644 --- a/src/recommendations/recommendation.spec.ts +++ b/src/recommendations/recommendation.spec.ts @@ -37,6 +37,7 @@ describe('RecommendationEngineService', () => { }; const module: TestingModule = await Test.createTestingModule({ + imports: [EventEmitterModule.forRoot()], providers: [ RecommendationEngineService, CollaborativeFilteringService, @@ -94,10 +95,10 @@ describe('RecommendationEngineService', () => { describe('CollaborativeFilteringService', () => { let service: CollaborativeFilteringService; - let enrollmentRepo: { find: jest.Mock }; + let enrollmentRepo: { find: jest.Mock; query: jest.Mock }; beforeEach(async () => { - enrollmentRepo = { find: jest.fn() }; + enrollmentRepo = { find: jest.fn(), query: jest.fn() }; const module = await Test.createTestingModule({ providers: [ CollaborativeFilteringService, @@ -108,38 +109,87 @@ describe('CollaborativeFilteringService', () => { }); it('returns empty when user has no enrollments (cold start)', async () => { - enrollmentRepo.find.mockResolvedValue([ - mockEnrollment('other', 'c1'), - mockEnrollment('other', 'c2'), - ]); - // Jaccard({}, {c1,c2}) = 0/2 = 0, so no collaborative signal → no results + enrollmentRepo.find.mockResolvedValue([]); const result = await service.getRecommendedCourseIds('user-1', new Set(), 5); expect(result.length).toBe(0); + // No SQL query fired — short-circuits before reaching the DB + expect(enrollmentRepo.query).not.toHaveBeenCalled(); }); it('excludes already-enrolled courses', async () => { enrollmentRepo.find.mockResolvedValue([ mockEnrollment('user-1', 'c1'), - mockEnrollment('other', 'c1'), - mockEnrollment('other', 'c2'), + ]); + // The SQL query excludes courses from excludeCourseIds; mock what the + // database would return after applying that exclusion. + enrollmentRepo.query.mockResolvedValue([ + { courseId: 'c2', score: 0.5 }, ]); const result = await service.getRecommendedCourseIds('user-1', new Set(['c1']), 5); expect(result.map((r) => r.courseId)).not.toContain('c1'); + expect(result).toHaveLength(1); + expect(result[0].courseId).toBe('c2'); }); it('scores courses based on Jaccard similarity', async () => { - // user-1 enrolled in c1, c2; other-user enrolled in c1, c2, c3 enrollmentRepo.find.mockResolvedValue([ mockEnrollment('user-1', 'c1'), mockEnrollment('user-1', 'c2'), - mockEnrollment('other-user', 'c1'), - mockEnrollment('other-user', 'c2'), - mockEnrollment('other-user', 'c3'), + ]); + // Jaccard(c1,c2 ∩ c1,c2,c3) = 2/3 → score = 2/3 + enrollmentRepo.query.mockResolvedValue([ + { courseId: 'c3', score: 2 / 3 }, ]); const result = await service.getRecommendedCourseIds('user-1', new Set(['c1', 'c2']), 5); expect(result).toHaveLength(1); expect(result[0].courseId).toBe('c3'); - expect(result[0].score).toBeCloseTo(2 / 3); // Jaccard(2,3) + expect(result[0].score).toBeCloseTo(2 / 3); + }); + + describe('benchmark', () => { + it('uses a single bounded SQL query — no full table scan, no N+1', async () => { + enrollmentRepo.find.mockResolvedValue([ + mockEnrollment('user-1', 'c1'), + mockEnrollment('user-1', 'c2'), + ]); + enrollmentRepo.query.mockResolvedValue([ + { courseId: 'c4', score: 0.7 }, + { courseId: 'c5', score: 0.3 }, + ]); + + const result = await service.getRecommendedCourseIds('user-1', new Set(['c1', 'c2', 'c3']), 3); + + // Exactly one query call — no separate neighbor-enrollment round-trips + expect(enrollmentRepo.query).toHaveBeenCalledTimes(1); + // Exactly one find call — target user's own enrollments only + expect(enrollmentRepo.find).toHaveBeenCalledTimes(1); + // Result length is bounded by topN regardless of total enrollment count + expect(result.length).toBeLessThanOrEqual(3); + }); + + it('heap usage is independent of total platform enrollment count', async () => { + // The service should only load: + // 1. The target user's enrollments (via find) + // 2. The bounded SQL result set + // No full-table scan of enrollment occurs. + enrollmentRepo.find.mockResolvedValue([ + mockEnrollment('user-1', 'c1'), + ]); + enrollmentRepo.query.mockResolvedValue( + Array.from({ length: 5 }, (_, i) => ({ + courseId: `c${i + 10}`, + score: 0.5 - i * 0.1, + })), + ); + + const result = await service.getRecommendedCourseIds('user-1', new Set(), 5); + + // The returned data is bounded by topN regardless of how many + // enrollments exist in the database. + expect(result.length).toBe(5); + expect(enrollmentRepo.find).toHaveBeenCalledTimes(1); + expect(enrollmentRepo.query).toHaveBeenCalledTimes(1); + }); }); }); From 178021a39cafa21aca6f768e653a3cf33b68498f Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 28 Jul 2026 10:16:16 +0100 Subject: [PATCH 7/8] quick fix [ci skip] From 4b9294213f67db6e4679b2d7713e1169b1b67bb9 Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 28 Jul 2026 10:22:54 +0100 Subject: [PATCH 8/8] quick fix [ci skip]