From 565301abd8ec6bcb7da40155bd449c209e224059 Mon Sep 17 00:00:00 2001 From: Mekjah Date: Tue, 28 Jul 2026 16:03:28 +0100 Subject: [PATCH] perf(dashboard): optimize user growth metrics with SQL aggregation --- .kilo/kilo.jsonc | 3 + src/dashboard/dashboard.service.spec.ts | 139 ++++++++++++++++++ src/dashboard/dashboard.service.ts | 36 +++-- .../1783000000005-add-user-createdat-index.ts | 54 +++++++ src/users/entities/user.entity.ts | 1 + 5 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 .kilo/kilo.jsonc create mode 100644 src/migrations/1783000000005-add-user-createdat-index.ts diff --git a/.kilo/kilo.jsonc b/.kilo/kilo.jsonc new file mode 100644 index 00000000..d3e1b2d9 --- /dev/null +++ b/.kilo/kilo.jsonc @@ -0,0 +1,3 @@ +{ + "snapshot": false +} \ No newline at end of file diff --git a/src/dashboard/dashboard.service.spec.ts b/src/dashboard/dashboard.service.spec.ts index befafce4..da57cbf7 100644 --- a/src/dashboard/dashboard.service.spec.ts +++ b/src/dashboard/dashboard.service.spec.ts @@ -27,6 +27,13 @@ describe('DashboardService', () => { useValue: { find: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(10), + createQueryBuilder: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }), }, }, { @@ -64,6 +71,138 @@ describe('DashboardService', () => { expect(funnel.stages[0].name).toBe('signup'); }); + it('should compute user growth metrics from database aggregation', async () => { + const userQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { period: '2025-01', newUsers: '5' }, + { period: '2025-02', newUsers: '3' }, + { period: '2025-03', newUsers: '8' }, + ]), + }; + + 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(16), + createQueryBuilder: jest.fn().mockReturnValue(userQueryBuilder), + }, + }, + { + provide: getRepositoryToken(Enrollment), + useValue: { count: jest.fn().mockResolvedValue(5) }, + }, + { + provide: getRepositoryToken(Course), + useValue: { find: jest.fn().mockResolvedValue([]) }, + }, + { + 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); + const result = await localService.getUserGrowthMetrics(); + + expect(result.totalUsers).toBe(16); + expect(result.monthlySignups).toEqual([ + { period: '2025-01', newUsers: 5, totalUsers: 5 }, + { period: '2025-02', newUsers: 3, totalUsers: 8 }, + { period: '2025-03', newUsers: 8, totalUsers: 16 }, + ]); + expect(userQueryBuilder.select).toHaveBeenCalled(); + expect(userQueryBuilder.addSelect).toHaveBeenCalledWith('COUNT(*)', 'newUsers'); + expect(userQueryBuilder.groupBy).toHaveBeenCalled(); + expect(userQueryBuilder.orderBy).toHaveBeenCalledWith('period', 'ASC'); + }); + + it('should return empty monthly signups when no users exist', async () => { + const userQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + 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(0), + createQueryBuilder: jest.fn().mockReturnValue(userQueryBuilder), + }, + }, + { + provide: getRepositoryToken(Enrollment), + useValue: { count: jest.fn().mockResolvedValue(5) }, + }, + { + provide: getRepositoryToken(Course), + useValue: { find: jest.fn().mockResolvedValue([]) }, + }, + { + 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); + const result = await localService.getUserGrowthMetrics(); + + expect(result.totalUsers).toBe(0); + expect(result.monthlySignups).toEqual([]); + }); + it('should export CSV with headers', async () => { const csv = await service.exportToCsv(); expect(csv).toContain('section,metric,value'); diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts index 7a7c01c9..2668866f 100644 --- a/src/dashboard/dashboard.service.ts +++ b/src/dashboard/dashboard.service.ts @@ -62,21 +62,31 @@ export class DashboardService { } async getUserGrowthMetrics() { - const users = await this.userRepository.find({ select: ['id', 'createdAt'] }); - const byMonth = new Map(); - for (const user of users) { - const key = user.createdAt.toISOString().slice(0, 7); - byMonth.set(key, (byMonth.get(key) ?? 0) + 1); - } - const cumulative: { period: string; newUsers: number; totalUsers: number }[] = []; - let total = 0; - for (const [period, count] of [...byMonth.entries()].sort()) { - total += count; - cumulative.push({ period, newUsers: count, totalUsers: total }); + const monthlyRows = await this.userRepository + .createQueryBuilder('user') + .select("to_char(date_trunc('month', user.createdAt), 'YYYY-MM')", 'period') + .addSelect('COUNT(*)', 'newUsers') + .groupBy("to_char(date_trunc('month', user.createdAt), 'YYYY-MM')") + .orderBy('period', 'ASC') + .getRawMany(); + + const totalUsers = await this.userRepository.count(); + + const monthlySignups: { + period: string; + newUsers: number; + totalUsers: number; + }[] = []; + let cumulative = 0; + for (const row of monthlyRows) { + const count = Number(row.newUsers); + cumulative += count; + monthlySignups.push({ period: row.period, newUsers: count, totalUsers: cumulative }); } + return { - totalUsers: users.length, - monthlySignups: cumulative, + totalUsers, + monthlySignups, }; } diff --git a/src/migrations/1783000000005-add-user-createdat-index.ts b/src/migrations/1783000000005-add-user-createdat-index.ts new file mode 100644 index 00000000..176a5e37 --- /dev/null +++ b/src/migrations/1783000000005-add-user-createdat-index.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Issue # — Add database index on `users.createdAt`. + * + * ## Context + * + * The dashboard user-growth endpoint (`DashboardService.getUserGrowthMetrics`) + * aggregates signups by month: + * + * ```ts + * this.userRepository.createQueryBuilder('user') + * .select(`to_char(date_trunc('month', user.createdAt), 'YYYY-MM')`, 'period') + * .addSelect('COUNT(*)', 'newUsers') + * .groupBy(`to_char(date_trunc('month', user.createdAt), 'YYYY-MM')`) + * ``` + * + * Without an index on `createdAt`, PostgreSQL performs a sequential scan of + * the entire `users` table for this aggregation. + * + * ## Design Notes + * + * - **B-tree** index, the PostgreSQL default, is optimal for the range scan + * and sort required by `date_trunc(...)` grouped/monthly aggregation. + * - The `@Index()` decorator added to the `User` entity covers development + * environments where `synchronize: true`. This migration covers production + * deployments where schema changes are applied via migrations. + * + * ## Verification + * + * After applying the migration, run: + * ```sql + * EXPLAIN ANALYZE + * SELECT + * to_char(date_trunc('month', "createdAt"), 'YYYY-MM') AS period, + * COUNT(*) AS newUsers + * FROM users + * GROUP BY to_char(date_trunc('month', "createdAt"), 'YYYY-MM') + * ORDER BY period; + * ``` + * + * Expected: `Index Scan` (not `Seq Scan`). + */ +export class AddUserCreatedAtIndex1783000000005 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX "IDX_users_createdAt" ON users ("createdAt") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "IDX_users_createdAt"'); + } +} diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index 73e27c90..ebc93f17 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -157,6 +157,7 @@ export class User { enrollments: Enrollment[]; @CreateDateColumn() + @Index() createdAt: Date; @UpdateDateColumn()