Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -92,6 +93,7 @@ const featureFlags = loadFeatureFlags();
MessagingModule,
DashboardModule,
GamificationModule,
RecommendationsModule,
],
controllers: [AppController],
providers: [
Expand Down
71 changes: 71 additions & 0 deletions src/recommendations/collaborative-filtering.service.ts
Original file line number Diff line number Diff line change
@@ -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<Enrollment>,
) {}

async getRecommendedCourseIds(
userId: string,
excludeCourseIds: Set<string>,
topN: number,
): Promise<Array<{ courseId: string; score: number }>> {
const enrollments = await this.enrollmentRepo.find({
select: ['userId', 'courseId'],
where: [{ status: 'active' }, { status: 'completed' }],
});

const userCourses = new Map<string, Set<string>>();
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<string>();
const courseScores = new Map<string, number>();

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<string>, b: Set<string>): 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;
}
}
77 changes: 77 additions & 0 deletions src/recommendations/content-based-filtering.service.ts
Original file line number Diff line number Diff line change
@@ -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<Course>,
) {}

async getRecommendedCourseIds(
enrolledCourseIds: string[],
excludeCourseIds: Set<string>,
topN: number,
): Promise<Array<{ courseId: string; score: number }>> {
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<string, number>();
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);
}
}
24 changes: 24 additions & 0 deletions src/recommendations/dto/recommendation.dto.ts
Original file line number Diff line number Diff line change
@@ -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';
}
156 changes: 156 additions & 0 deletions src/recommendations/recommendation-engine.service.ts
Original file line number Diff line number Diff line change
@@ -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<Course>,
@InjectRepository(Enrollment)
private readonly enrollmentRepo: Repository<Enrollment>,
private readonly caching: CachingService,
private readonly collaborative: CollaborativeFilteringService,
private readonly contentBased: ContentBasedFilteringService,
) {}

async getRecommendations(userId: string, limit = 10): Promise<RecommendedCourseDto[]> {
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<void> {
// 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<RecommendedCourseDto[]> {
// 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<string, { collab: number; content: number }>();

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<string>,
limit: number,
): Promise<RecommendedCourseDto[]> {
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';
}
}
Loading
Loading