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
3 changes: 3 additions & 0 deletions .kilo/kilo.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"snapshot": false
}
139 changes: 139 additions & 0 deletions src/dashboard/dashboard.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
}),
},
},
{
Expand Down Expand Up @@ -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>(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>(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');
Expand Down
36 changes: 23 additions & 13 deletions src/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,31 @@ export class DashboardService {
}

async getUserGrowthMetrics() {
const users = await this.userRepository.find({ select: ['id', 'createdAt'] });
const byMonth = new Map<string, number>();
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,
};
}

Expand Down
54 changes: 54 additions & 0 deletions src/migrations/1783000000005-add-user-createdat-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Issue #<issue-number> — 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<void> {
await queryRunner.query(`
CREATE INDEX "IDX_users_createdAt" ON users ("createdAt")
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_users_createdAt"');
}
}
1 change: 1 addition & 0 deletions src/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class User {
enrollments: Enrollment[];

@CreateDateColumn()
@Index()
createdAt: Date;

@UpdateDateColumn()
Expand Down
Loading