diff --git a/src/app.module.ts b/src/app.module.ts index 86fe5ff..c08b54d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -43,6 +43,7 @@ import { NotificationsModule } from './notifications/notifications.module'; import { MessagingModule } from './messaging/messaging.module'; import { DashboardModule } from './dashboard/dashboard.module'; import { GamificationModule } from './gamification/gamification.module'; +import { RecommendationsModule } from './recommendations/recommendations.module'; const featureFlags = loadFeatureFlags(); @@ -92,6 +93,7 @@ const featureFlags = loadFeatureFlags(); MessagingModule, DashboardModule, GamificationModule, + RecommendationsModule, ], controllers: [AppController], providers: [ diff --git a/src/recommendations/collaborative-filtering.service.ts b/src/recommendations/collaborative-filtering.service.ts new file mode 100644 index 0000000..4573d72 --- /dev/null +++ b/src/recommendations/collaborative-filtering.service.ts @@ -0,0 +1,71 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } 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); + + constructor( + @InjectRepository(Enrollment) + private readonly enrollmentRepo: Repository, + ) {} + + async getRecommendedCourseIds( + userId: string, + excludeCourseIds: Set, + topN: number, + ): Promise> { + const enrollments = await this.enrollmentRepo.find({ + select: ['userId', 'courseId'], + where: [{ status: 'active' }, { 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 targetCourses = userCourses.get(userId) ?? new Set(); + const courseScores = new Map(); + + for (const [otherUserId, otherCourses] of userCourses) { + if (otherUserId === userId) continue; + + const similarity = this.jaccardSimilarity(targetCourses, otherCourses); + if (similarity === 0) continue; + + for (const courseId of otherCourses) { + if (targetCourses.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 })); + } + + 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/content-based-filtering.service.ts b/src/recommendations/content-based-filtering.service.ts new file mode 100644 index 0000000..a770c2f --- /dev/null +++ b/src/recommendations/content-based-filtering.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { Course, CourseStatus } from '../courses/entities/course.entity'; + +/** + * Implements content-based filtering using course attributes. + * + * Strategy: build a category preference profile from the user's enrolled courses, + * then score all remaining published courses by how well their attributes match. + * + * Scoring factors (equally weighted): + * - Category match (1.0 per shared category) + * - Price proximity (0–1 based on price range similarity) + */ +@Injectable() +export class ContentBasedFilteringService { + constructor( + @InjectRepository(Course) + private readonly courseRepo: Repository, + ) {} + + async getRecommendedCourseIds( + enrolledCourseIds: string[], + excludeCourseIds: Set, + topN: number, + ): Promise> { + if (enrolledCourseIds.length === 0) return []; + + const [enrolledCourses, allCourses] = await Promise.all([ + this.courseRepo.find({ + select: ['id', 'category', 'price'], + where: { id: In(enrolledCourseIds) }, + }), + this.courseRepo.find({ + select: ['id', 'category', 'price'], + where: { status: CourseStatus.PUBLISHED }, + }), + ]); + + const categoryFreq = new Map(); + let avgPrice = 0; + + for (const c of enrolledCourses) { + if (c.category) categoryFreq.set(c.category, (categoryFreq.get(c.category) ?? 0) + 1); + avgPrice += Number(c.price); + } + avgPrice /= enrolledCourses.length; + const maxFreq = Math.max(...categoryFreq.values(), 1); + + const priceRange = avgPrice > 0 ? avgPrice : 100; + + const scores: Array<{ courseId: string; score: number }> = []; + + for (const course of allCourses) { + if ( + enrolledCourseIds.includes(course.id) || + excludeCourseIds.has(course.id) + ) continue; + + let score = 0; + + // Category score (normalised) + if (course.category && categoryFreq.has(course.category)) { + score += categoryFreq.get(course.category)! / maxFreq; + } + + // Price proximity score + const priceDiff = Math.abs(Number(course.price) - avgPrice); + score += Math.max(0, 1 - priceDiff / priceRange); + + if (score > 0) scores.push({ courseId: course.id, score }); + } + + return scores.sort((a, b) => b.score - a.score).slice(0, topN); + } +} diff --git a/src/recommendations/dto/recommendation.dto.ts b/src/recommendations/dto/recommendation.dto.ts new file mode 100644 index 0000000..f2e2dc3 --- /dev/null +++ b/src/recommendations/dto/recommendation.dto.ts @@ -0,0 +1,24 @@ +import { IsUUID, IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class GetRecommendationsDto { + @IsUUID() + userId: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + @Type(() => Number) + limit?: number = 10; +} + +export class RecommendedCourseDto { + id: string; + title: string; + description: string; + category?: string; + price: number; + score: number; + reason: 'collaborative' | 'content-based' | 'hybrid'; +} diff --git a/src/recommendations/recommendation-engine.service.ts b/src/recommendations/recommendation-engine.service.ts new file mode 100644 index 0000000..15062cf --- /dev/null +++ b/src/recommendations/recommendation-engine.service.ts @@ -0,0 +1,156 @@ +import { Injectable, Logger } from '@nestjs/common'; +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 { CollaborativeFilteringService } from './collaborative-filtering.service'; +import { ContentBasedFilteringService } from './content-based-filtering.service'; +import { RecommendedCourseDto } from './dto/recommendation.dto'; + +const CACHE_TTL_SECONDS = 300; // 5 minutes +const COLLABORATIVE_WEIGHT = 0.6; +const CONTENT_WEIGHT = 0.4; + +/** + * Hybrid recommendation engine combining collaborative and content-based filtering. + * + * Results are cached in Redis with a 5-minute TTL to achieve <100 ms response times. + */ +@Injectable() +export class RecommendationEngineService { + private readonly logger = new Logger(RecommendationEngineService.name); + + constructor( + @InjectRepository(Course) + private readonly courseRepo: Repository, + @InjectRepository(Enrollment) + private readonly enrollmentRepo: Repository, + private readonly caching: CachingService, + private readonly collaborative: CollaborativeFilteringService, + private readonly contentBased: ContentBasedFilteringService, + ) {} + + async getRecommendations(userId: string, limit = 10): Promise { + const cacheKey = `recommendations:${userId}:${limit}`; + + return this.caching.getOrSet( + cacheKey, + () => this.computeRecommendations(userId, limit), + CACHE_TTL_SECONDS, + ); + } + + /** Invalidate cached recommendations for a user (e.g., after a new enrollment). */ + async invalidate(userId: string): Promise { + // Pattern-style deletion: remove all limit variants by trying the common ones + const keys = [5, 10, 20, 50].map((l) => `recommendations:${userId}:${l}`); + await this.caching.deleteMany(keys); + } + + private async computeRecommendations( + userId: string, + limit: number, + ): Promise { + // Load user's enrollments + const userEnrollments = await this.enrollmentRepo.find({ + select: ['courseId'], + where: [ + { userId, status: 'active' }, + { userId, status: 'completed' }, + ], + }); + const enrolledIds = userEnrollments.map((e) => e.courseId); + const excludeSet = new Set(enrolledIds); + + const candidateLimit = limit * 3; // fetch more than needed before merging + + const [collaborative, contentBased] = await Promise.all([ + this.collaborative.getRecommendedCourseIds(userId, excludeSet, candidateLimit), + this.contentBased.getRecommendedCourseIds(enrolledIds, excludeSet, candidateLimit), + ]); + + // Merge & normalise scores + const scoreMap = new Map(); + + const maxCollab = collaborative[0]?.score ?? 1; + for (const { courseId, score } of collaborative) { + scoreMap.set(courseId, { collab: score / maxCollab, content: 0 }); + } + + const maxContent = contentBased[0]?.score ?? 1; + for (const { courseId, score } of contentBased) { + const existing = scoreMap.get(courseId) ?? { collab: 0, content: 0 }; + scoreMap.set(courseId, { ...existing, content: score / maxContent }); + } + + const ranked = [...scoreMap.entries()] + .map(([courseId, s]) => ({ + courseId, + hybridScore: s.collab * COLLABORATIVE_WEIGHT + s.content * CONTENT_WEIGHT, + reason: this.classifyReason(s.collab, s.content), + })) + .sort((a, b) => b.hybridScore - a.hybridScore) + .slice(0, limit); + + if (ranked.length === 0) return this.fallbackPopular(excludeSet, limit); + + const courseIds = ranked.map((r) => r.courseId); + const courses = await this.courseRepo.find({ + select: ['id', 'title', 'description', 'category', 'price'], + where: { id: In(courseIds), status: CourseStatus.PUBLISHED }, + }); + + const courseMap = new Map(courses.map((c) => [c.id, c])); + + return ranked + .filter((r) => courseMap.has(r.courseId)) + .map((r) => { + const c = courseMap.get(r.courseId)!; + return { + id: c.id, + title: c.title, + description: c.description, + category: c.category, + price: Number(c.price), + score: Math.round(r.hybridScore * 1000) / 1000, + reason: r.reason, + }; + }); + } + + /** Fall back to recently published courses when no signal exists (cold start). */ + private async fallbackPopular( + excludeSet: Set, + limit: number, + ): Promise { + const courses = await this.courseRepo.find({ + select: ['id', 'title', 'description', 'category', 'price'], + where: { status: CourseStatus.PUBLISHED }, + order: { createdAt: 'DESC' }, + take: limit + excludeSet.size, + }); + + return courses + .filter((c) => !excludeSet.has(c.id)) + .slice(0, limit) + .map((c) => ({ + id: c.id, + title: c.title, + description: c.description, + category: c.category, + price: Number(c.price), + score: 0, + reason: 'content-based' as const, + })); + } + + private classifyReason( + collab: number, + content: number, + ): 'collaborative' | 'content-based' | 'hybrid' { + if (collab > 0 && content > 0) return 'hybrid'; + if (collab > 0) return 'collaborative'; + return 'content-based'; + } +} diff --git a/src/recommendations/recommendation.spec.ts b/src/recommendations/recommendation.spec.ts new file mode 100644 index 0000000..2323a67 --- /dev/null +++ b/src/recommendations/recommendation.spec.ts @@ -0,0 +1,182 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Course, CourseStatus } from '../courses/entities/course.entity'; +import { Enrollment } from '../courses/entities/enrollment.entity'; +import { CachingService } from '../caching/caching.service'; +import { RecommendationEngineService } from './recommendation-engine.service'; +import { CollaborativeFilteringService } from './collaborative-filtering.service'; +import { ContentBasedFilteringService } from './content-based-filtering.service'; + +const mockCourse = (id: string, category = 'math', price = 50): Partial => ({ + id, + title: `Course ${id}`, + description: 'desc', + category, + price, + status: CourseStatus.PUBLISHED, + createdAt: new Date(), +}); + +const mockEnrollment = (userId: string, courseId: string, status = 'active'): Partial => + ({ userId, courseId, status } as Enrollment); + +describe('RecommendationEngineService', () => { + let service: RecommendationEngineService; + let courseRepo: { find: jest.Mock }; + let enrollmentRepo: { find: jest.Mock }; + let caching: { getOrSet: jest.Mock; deleteMany: jest.Mock }; + + beforeEach(async () => { + courseRepo = { find: jest.fn() }; + enrollmentRepo = { find: jest.fn() }; + caching = { + getOrSet: jest.fn((_, factory) => factory()), + deleteMany: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RecommendationEngineService, + CollaborativeFilteringService, + ContentBasedFilteringService, + { provide: getRepositoryToken(Course), useValue: courseRepo }, + { provide: getRepositoryToken(Enrollment), useValue: enrollmentRepo }, + { provide: CachingService, useValue: caching }, + { + provide: getRepositoryToken(Enrollment) + '_collab', + useValue: enrollmentRepo, + }, + ], + }) + .overrideProvider(CollaborativeFilteringService) + .useValue({ getRecommendedCourseIds: jest.fn().mockResolvedValue([]) }) + .overrideProvider(ContentBasedFilteringService) + .useValue({ getRecommendedCourseIds: jest.fn().mockResolvedValue([]) }) + .compile(); + + service = module.get(RecommendationEngineService); + }); + + it('returns empty list when no signal and no published courses', async () => { + enrollmentRepo.find.mockResolvedValue([]); + courseRepo.find.mockResolvedValue([]); + const result = await service.getRecommendations('user-1', 10); + expect(result).toEqual([]); + }); + + it('falls back to popular courses on cold start', async () => { + enrollmentRepo.find.mockResolvedValue([]); + courseRepo.find.mockResolvedValue([mockCourse('c1'), mockCourse('c2')]); + + const result = await service.getRecommendations('new-user', 10); + expect(result).toHaveLength(2); + expect(result[0].reason).toBe('content-based'); + }); + + it('calls caching.getOrSet with correct key', async () => { + enrollmentRepo.find.mockResolvedValue([]); + courseRepo.find.mockResolvedValue([]); + await service.getRecommendations('user-abc', 5); + expect(caching.getOrSet).toHaveBeenCalledWith( + 'recommendations:user-abc:5', + expect.any(Function), + 300, + ); + }); + + it('invalidate deletes cache keys for common limits', async () => { + await service.invalidate('user-1'); + expect(caching.deleteMany).toHaveBeenCalledWith([ + 'recommendations:user-1:5', + 'recommendations:user-1:10', + 'recommendations:user-1:20', + 'recommendations:user-1:50', + ]); + }); +}); + +describe('CollaborativeFilteringService', () => { + let service: CollaborativeFilteringService; + let enrollmentRepo: { find: jest.Mock }; + + beforeEach(async () => { + enrollmentRepo = { find: jest.fn() }; + const module = await Test.createTestingModule({ + providers: [ + CollaborativeFilteringService, + { provide: getRepositoryToken(Enrollment), useValue: enrollmentRepo }, + ], + }).compile(); + service = module.get(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 + const result = await service.getRecommendedCourseIds('user-1', new Set(), 5); + expect(result.length).toBe(0); + }); + + it('excludes already-enrolled courses', async () => { + enrollmentRepo.find.mockResolvedValue([ + mockEnrollment('user-1', 'c1'), + mockEnrollment('other', 'c1'), + mockEnrollment('other', 'c2'), + ]); + const result = await service.getRecommendedCourseIds('user-1', new Set(['c1']), 5); + expect(result.map((r) => r.courseId)).not.toContain('c1'); + }); + + 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'), + ]); + 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) + }); +}); + +describe('ContentBasedFilteringService', () => { + let service: ContentBasedFilteringService; + let courseRepo: { find: jest.Mock }; + + beforeEach(async () => { + courseRepo = { find: jest.fn() }; + const module = await Test.createTestingModule({ + providers: [ + ContentBasedFilteringService, + { provide: getRepositoryToken(Course), useValue: courseRepo }, + ], + }).compile(); + service = module.get(ContentBasedFilteringService); + }); + + it('returns empty when no enrolled courses', async () => { + const result = await service.getRecommendedCourseIds([], new Set(), 10); + expect(result).toEqual([]); + }); + + it('ranks courses by category match', async () => { + // Enrolled in a 'math' course priced at 50 + courseRepo.find + .mockResolvedValueOnce([{ id: 'e1', category: 'math', price: 50 }]) + .mockResolvedValueOnce([ + { id: 'c1', category: 'math', price: 50, status: CourseStatus.PUBLISHED }, + { id: 'c2', category: 'science', price: 200, status: CourseStatus.PUBLISHED }, + ]); + + const result = await service.getRecommendedCourseIds(['e1'], new Set(['e1']), 10); + expect(result[0].courseId).toBe('c1'); + expect(result[0].score).toBeGreaterThan(result[1]?.score ?? 0); + }); +}); diff --git a/src/recommendations/recommendations.controller.ts b/src/recommendations/recommendations.controller.ts new file mode 100644 index 0000000..e0b8e90 --- /dev/null +++ b/src/recommendations/recommendations.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Query, ParseUUIDPipe, Param } from '@nestjs/common'; +import { RecommendationEngineService } from './recommendation-engine.service'; +import { GetRecommendationsDto } from './dto/recommendation.dto'; + +@Controller('recommendations') +export class RecommendationsController { + constructor(private readonly engine: RecommendationEngineService) {} + + /** + * GET /recommendations/:userId + * + * Returns a ranked list of personalised course recommendations for the given user. + * Results are cached in Redis (TTL 5 min) to achieve <100 ms response times. + */ + @Get(':userId') + getRecommendations( + @Param('userId', ParseUUIDPipe) userId: string, + @Query() query: GetRecommendationsDto, + ) { + return this.engine.getRecommendations(userId, query.limit); + } +} diff --git a/src/recommendations/recommendations.module.ts b/src/recommendations/recommendations.module.ts new file mode 100644 index 0000000..426b385 --- /dev/null +++ b/src/recommendations/recommendations.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Course } from '../courses/entities/course.entity'; +import { Enrollment } from '../courses/entities/enrollment.entity'; +import { CachingModule } from '../caching/caching.module'; +import { RecommendationEngineService } from './recommendation-engine.service'; +import { CollaborativeFilteringService } from './collaborative-filtering.service'; +import { ContentBasedFilteringService } from './content-based-filtering.service'; +import { RecommendationsController } from './recommendations.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Course, Enrollment]), + CachingModule, + ], + providers: [ + RecommendationEngineService, + CollaborativeFilteringService, + ContentBasedFilteringService, + ], + controllers: [RecommendationsController], + exports: [RecommendationEngineService], +}) +export class RecommendationsModule {}