From 4c273ff861a5f89781fb2398f72be004d77bfbc2 Mon Sep 17 00:00:00 2001 From: CNduka001 Date: Mon, 1 Jun 2026 20:17:12 +0100 Subject: [PATCH] feat(dashboard): add instructor analytics dashboard (#562) --- src/dashboard/dashboard.controller.ts | 11 +- src/dashboard/dashboard.module.ts | 3 +- src/dashboard/dashboard.service.spec.ts | 106 +++++++++++++ src/dashboard/dashboard.service.ts | 203 ++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 3 deletions(-) diff --git a/src/dashboard/dashboard.controller.ts b/src/dashboard/dashboard.controller.ts index ef968aa9..09eabbd4 100644 --- a/src/dashboard/dashboard.controller.ts +++ b/src/dashboard/dashboard.controller.ts @@ -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') @@ -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') diff --git a/src/dashboard/dashboard.module.ts b/src/dashboard/dashboard.module.ts index 9e4e513a..abfc69e2 100644 --- a/src/dashboard/dashboard.module.ts +++ b/src/dashboard/dashboard.module.ts @@ -4,6 +4,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 { ReportingModule } from '../payments/reporting/reporting.module'; import { DashboardController } from './dashboard.controller'; import { DashboardService } from './dashboard.service'; @@ -11,7 +12,7 @@ import { DashboardReportScheduler } from './dashboard-report.scheduler'; @Module({ imports: [ - TypeOrmModule.forFeature([Payment, User, Enrollment, Course]), + TypeOrmModule.forFeature([Payment, User, Enrollment, Course, AnalyticsEvent]), ReportingModule, ], controllers: [DashboardController], diff --git a/src/dashboard/dashboard.service.spec.ts b/src/dashboard/dashboard.service.spec.ts index f144e508..b17d7119 100644 --- a/src/dashboard/dashboard.service.spec.ts +++ b/src/dashboard/dashboard.service.spec.ts @@ -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', () => { @@ -30,6 +31,10 @@ describe('DashboardService', () => { provide: getRepositoryToken(Course), useValue: { find: jest.fn().mockResolvedValue([]) }, }, + { + provide: getRepositoryToken(AnalyticsEvent), + useValue: { createQueryBuilder: jest.fn() }, + }, { provide: ReportingService, useValue: { @@ -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); + 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); + }); }); diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts index ea10652d..8dc1552d 100644 --- a/src/dashboard/dashboard.service.ts +++ b/src/dashboard/dashboard.service.ts @@ -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'; @@ -22,6 +23,8 @@ export class DashboardService { private readonly enrollmentRepository: Repository, @InjectRepository(Course) private readonly courseRepository: Repository, + @InjectRepository(AnalyticsEvent) + private readonly analyticsEventRepository: Repository, private readonly reportingService: ReportingService, ) {} @@ -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(); + 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(); + 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( + 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 { const overview = await this.getOverview(); const escapeCsv = (value: string | number) => {