Skip to content
Open
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
4 changes: 2 additions & 2 deletions src/courses/courses.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
4 changes: 3 additions & 1 deletion src/courses/courses.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,13 +10,15 @@ 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';

@Module({
imports: [
TypeOrmModule.forFeature([Course, Enrollment, CourseReview, CourseModule, BulkOperation]),
CachingModule,
forwardRef(() => AnalyticsModule),
],
providers: [CoursesService, EnrollmentsService, PaginationService],
controllers: [CoursesController, EnrollmentsController],
Expand Down
19 changes: 17 additions & 2 deletions src/courses/courses.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -70,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 ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -143,14 +148,24 @@ export class CoursesService {
/**
* Returns a single course by ID.
*/
async findOne(id: string): Promise<Course> {
async findOne(id: string, requestingUser?: User): Promise<Course> {
const course = await this.courseRepo.findOne({
where: { id },
relations: ['instructor', 'reviews', 'reviews.reviewer', 'prerequisite'],
});
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;
}

Expand Down
64 changes: 63 additions & 1 deletion src/dashboard/dashboard.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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>(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(),
Expand Down
36 changes: 25 additions & 11 deletions src/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
109 changes: 64 additions & 45 deletions src/recommendations/collaborative-filtering.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,6 @@ 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);
Expand All @@ -27,45 +17,74 @@ export class CollaborativeFilteringService {
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 targetEnrollments = await this.enrollmentRepo.find({
select: ['courseId'],
where: [
{ userId, status: 'active' },
{ userId, 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);
}
if (targetEnrollments.length === 0) return [];

const targetCourses = userCourses.get(userId) ?? new Set<string>();
const courseScores = new Map<string, number>();
const excludeArray = [...excludeCourseIds];
const maxNeighbors = Math.max(topN * 3, 50);

for (const [otherUserId, otherCourses] of userCourses) {
if (otherUserId === userId) continue;
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) 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 course_id AS "courseId", score FROM candidate_courses
`,
[userId, 'active', 'completed', maxNeighbors, excludeArray, topN],
);

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;
return rows;
}
}
14 changes: 14 additions & 0 deletions src/recommendations/recommendation-engine.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,6 +49,18 @@ export class RecommendationEngineService {
await this.caching.deleteByPattern(`recommendations:${userId}:*`);
}

@OnEvent(CACHE_EVENTS.ENROLLMENT_CREATED)
async onEnrollmentCreated(payload: { id: string }): Promise<void> {
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,
Expand Down
Loading