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
11 changes: 9 additions & 2 deletions src/dashboard/dashboard.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Controller, Get, Header, Query, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { Controller, Get, Header, Query, BadRequestException, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiParam } from '@nestjs/swagger';
import { DashboardService, RevenuePeriod } from './dashboard.service';

@ApiTags('dashboard')
Expand Down Expand Up @@ -42,6 +42,13 @@ export class DashboardController {
return this.dashboardService.getConversionFunnel();
}

@Get('instructors/:instructorId')
@ApiOperation({ summary: 'Instructor course analytics dashboard' })
@ApiParam({ name: 'instructorId', description: 'UUID of the instructor' })
async getInstructorDashboard(@Param('instructorId') instructorId: string) {
return this.dashboardService.getInstructorDashboard(instructorId);
}

@Get('export/csv')
@ApiOperation({ summary: 'Export dashboard metrics to CSV' })
@Header('Content-Type', 'text/csv')
Expand Down
3 changes: 2 additions & 1 deletion src/dashboard/dashboard.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { Payment } from '../payments/entities/payment.entity';
import { User } from '../users/entities/user.entity';
import { Enrollment } from '../courses/entities/enrollment.entity';
import { Course } from '../courses/entities/course.entity';
import { AnalyticsEvent } from '../analytics/entities/event.entity';
import { ReportingModule } from '../payments/reporting/reporting.module';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
import { DashboardReportScheduler } from './dashboard-report.scheduler';

@Module({
imports: [
TypeOrmModule.forFeature([Payment, User, Enrollment, Course]),
TypeOrmModule.forFeature([Payment, User, Enrollment, Course, AnalyticsEvent]),
ReportingModule,
],
controllers: [DashboardController],
Expand Down
106 changes: 106 additions & 0 deletions src/dashboard/dashboard.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Payment } from '../payments/entities/payment.entity';
import { User } from '../users/entities/user.entity';
import { Enrollment } from '../courses/entities/enrollment.entity';
import { Course } from '../courses/entities/course.entity';
import { AnalyticsEvent } from '../analytics/entities/event.entity';
import { ReportingService } from '../payments/reporting/reporting.service';

describe('DashboardService', () => {
Expand All @@ -30,6 +31,10 @@ describe('DashboardService', () => {
provide: getRepositoryToken(Course),
useValue: { find: jest.fn().mockResolvedValue([]) },
},
{
provide: getRepositoryToken(AnalyticsEvent),
useValue: { createQueryBuilder: jest.fn() },
},
{
provide: ReportingService,
useValue: {
Expand Down Expand Up @@ -57,4 +62,105 @@ describe('DashboardService', () => {
const csv = await service.exportToCsv();
expect(csv).toContain('section,metric,value');
});

it('should generate instructor dashboard analytics', async () => {
const paymentQueryBuilder = {
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
addGroupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
clone: jest.fn().mockReturnThis(),
getRawMany: jest
.fn()
.mockResolvedValueOnce([{ totalRevenue: '120', currency: 'USD' }])
.mockResolvedValueOnce([
{ courseId: 'course-1', courseTitle: 'Course 1', revenue: '100' },
])
.mockResolvedValueOnce([{ paymentMethod: 'credit_card', revenue: '120' }]),
};

const analyticsQueryBuilder = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([{ courseId: 'course-1', watchSeconds: '200' }]),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
DashboardService,
{
provide: getRepositoryToken(Payment),
useValue: { find: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), createQueryBuilder: jest.fn().mockReturnValue(paymentQueryBuilder) },
},
{
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: {
find: jest.fn().mockResolvedValue([
{
id: 'course-1',
title: 'Course 1',
price: 100,
status: 'published',
instructorId: 'instr-1',
enrollments: [
{
id: 'enrollment-1',
progress: 50,
status: 'active',
enrolledAt: new Date('2026-05-20T00:00:00Z'),
},
],
modules: [
{
lessons: [
{ videoUrl: 'https://video.example.com/1', durationSeconds: 600 },
],
},
],
},
]),
},
},
{
provide: getRepositoryToken(AnalyticsEvent),
useValue: { createQueryBuilder: jest.fn().mockReturnValue(analyticsQueryBuilder) },
},
{
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.getInstructorDashboard('instr-1');

expect(result.instructorId).toBe('instr-1');
expect(result.revenue.totalRevenue).toBe(120);
expect(result.videoWatchTime.totalWatchSeconds).toBe(200);
expect(result.completionRate.totalEnrollments).toBe(1);
expect(result.enrollmentTrends).toHaveLength(30);
});
});
203 changes: 203 additions & 0 deletions src/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Payment, PaymentStatus } from '../payments/entities/payment.entity';
import { User } from '../users/entities/user.entity';
import { Enrollment } from '../courses/entities/enrollment.entity';
import { Course } from '../courses/entities/course.entity';
import { AnalyticsEvent } from '../analytics/entities/event.entity';
import { ReportingService } from '../payments/reporting/reporting.service';

export type RevenuePeriod = 'daily' | 'weekly' | 'monthly';
Expand All @@ -22,6 +23,8 @@ export class DashboardService {
private readonly enrollmentRepository: Repository<Enrollment>,
@InjectRepository(Course)
private readonly courseRepository: Repository<Course>,
@InjectRepository(AnalyticsEvent)
private readonly analyticsEventRepository: Repository<AnalyticsEvent>,
private readonly reportingService: ReportingService,
) {}

Expand Down Expand Up @@ -115,6 +118,206 @@ export class DashboardService {
};
}

async getInstructorDashboard(instructorId: string) {
const courses = await this.courseRepository.find({
where: { instructorId },
relations: ['enrollments', 'modules', 'modules.lessons'],
});

const [revenue, videoWatchTime] = await Promise.all([
this.getInstructorRevenueBreakdown(instructorId),
this.getInstructorVideoWatchTime(courses),
]);

return {
instructorId,
revenue,
enrollmentTrends: this.calculateEnrollmentTrends(courses),
completionRate: this.calculateCompletionRate(courses),
videoWatchTime,
courseSummary: 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),
generatedAt: new Date().toISOString(),
};
}

async getInstructorRevenueBreakdown(instructorId: string) {
const baseQuery = this.paymentRepository
.createQueryBuilder('payment')
.leftJoin('payment.course', 'course')
.where('course.instructorId = :instructorId', { instructorId })
.andWhere('payment.status = :status', { status: PaymentStatus.COMPLETED });

const totals = await baseQuery
.clone()
.select('COALESCE(SUM(payment.amount), 0)', 'totalRevenue')
.addSelect('payment.currency', 'currency')
.groupBy('payment.currency')
.getRawMany();

const byCourse = await baseQuery
.clone()
.select('payment.courseId', 'courseId')
.addSelect('course.title', 'courseTitle')
.addSelect('COALESCE(SUM(payment.amount), 0)', 'revenue')
.groupBy('payment.courseId')
.addGroupBy('course.title')
.orderBy('revenue', 'DESC')
.getRawMany();

const byMethod = await baseQuery
.clone()
.select('payment.method', 'paymentMethod')
.addSelect('COALESCE(SUM(payment.amount), 0)', 'revenue')
.groupBy('payment.method')
.orderBy('revenue', 'DESC')
.getRawMany();

return {
totalRevenue: totals.reduce((sum, row) => sum + Number(row.totalRevenue), 0),
currency: totals.length ? totals[0].currency : 'USD',
byCourse: byCourse.map((row) => ({
courseId: row.courseId,
title: row.courseTitle,
revenue: Number(row.revenue),
})),
byMethod: byMethod.map((row) => ({
method: row.paymentMethod,
revenue: Number(row.revenue),
})),
};
}

private calculateEnrollmentTrends(courses: Course[], days = 30) {
const now = new Date();
const start = new Date(now);
start.setUTCDate(start.getUTCDate() - (days - 1));
start.setUTCHours(0, 0, 0, 0);

const buckets = new Map<string, number>();
for (let i = 0; i < days; i += 1) {
const date = new Date(start);
date.setUTCDate(start.getUTCDate() + i);
buckets.set(date.toISOString().slice(0, 10), 0);
}

const enrollments = courses.flatMap((course) => course.enrollments ?? []);
for (const enrollment of enrollments) {
if (!enrollment.enrolledAt || enrollment.enrolledAt < start) {
continue;
}
const period = enrollment.enrolledAt.toISOString().slice(0, 10);
buckets.set(period, (buckets.get(period) ?? 0) + 1);
}

return [...buckets.entries()].map(([period, count]) => ({ period, count }));
}

private calculateCompletionRate(courses: Course[]) {
const enrollments = courses.flatMap((course) => course.enrollments ?? []);
const totalEnrollments = enrollments.length;
const completedEnrollments = enrollments.filter((enrollment) => enrollment.status === 'completed').length;

const monthlyBuckets = new Map<string, { total: number; completed: number }>();
const now = new Date();

for (let offset = 5; offset >= 0; offset -= 1) {
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - offset, 1));
const key = `${month.getUTCFullYear()}-${String(month.getUTCMonth() + 1).padStart(2, '0')}`;
monthlyBuckets.set(key, { total: 0, completed: 0 });
}

for (const enrollment of enrollments) {
const date = new Date(enrollment.enrolledAt);
const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
const bucket = monthlyBuckets.get(key);
if (!bucket) {
continue;
}
bucket.total += 1;
if (enrollment.status === 'completed') {
bucket.completed += 1;
}
}

return {
overallRate: totalEnrollments ? completedEnrollments / totalEnrollments : 0,
totalEnrollments,
completedEnrollments,
monthlyTrend: [...monthlyBuckets.entries()].map(([period, bucket]) => ({
period,
completionRate: bucket.total ? bucket.completed / bucket.total : 0,
totalEnrollments: bucket.total,
completedEnrollments: bucket.completed,
})),
};
}

private async getInstructorVideoWatchTime(courses: Course[]) {
const courseIds = courses.map((course) => course.id);
const watchData = courseIds.length
? await this.analyticsEventRepository
.createQueryBuilder('event')
.select("event.properties->>'courseId'", 'courseId')
.addSelect('SUM(COALESCE(event.value, 0))', 'watchSeconds')
.where("event.category = :category", { category: 'video' })
.andWhere("event.action = :action", { action: 'watch' })
.andWhere("event.properties->>'courseId' IN (:...courseIds)", { courseIds })
.groupBy("event.properties->>'courseId'")
.getRawMany()
: [];

const recordedWatchMap = new Map<string, number>(
watchData.map((row) => [row.courseId, Number(row.watchSeconds)]),
);

const courseStats = courses.map((course) => {
const totalVideoSeconds = (course.modules ?? []).reduce((courseSum, module) => {
return courseSum + (module.lessons ?? []).reduce((lessonSum, lesson) => {
return lessonSum + (lesson.videoUrl ? Number(lesson.durationSeconds || 0) : 0);
}, 0);
}, 0);

const enrollmentCount = course.enrollments?.length ?? 0;
const progressSeconds = (course.enrollments ?? []).reduce((sum, enrollment) => {
return sum + (Math.min(Math.max(enrollment.progress ?? 0, 0), 100) / 100) * totalVideoSeconds;
}, 0);

const recordedSeconds = recordedWatchMap.get(course.id) ?? 0;
const watchSeconds = recordedSeconds || progressSeconds;

return {
courseId: course.id,
title: course.title,
totalVideoSeconds,
watchSeconds,
averageWatchSecondsPerEnrollment: enrollmentCount ? watchSeconds / enrollmentCount : 0,
enrollmentCount,
hasRecordedWatchEvents: recordedSeconds > 0,
};
});

const totalWatchSeconds = courseStats.reduce((sum, item) => sum + item.watchSeconds, 0);
const totalVideoSeconds = courseStats.reduce((sum, item) => sum + item.totalVideoSeconds * item.enrollmentCount, 0);
const totalEnrollments = courseStats.reduce((sum, item) => sum + item.enrollmentCount, 0);

return {
totalWatchSeconds,
totalVideoSeconds,
averageWatchSecondsPerEnrollment: totalEnrollments ? totalWatchSeconds / totalEnrollments : 0,
byCourse: courseStats,
hasRecordedEvents: watchData.length > 0,
};
}

async exportToCsv(): Promise<string> {
const overview = await this.getOverview();
const escapeCsv = (value: string | number) => {
Expand Down
Loading