From 3635e95acffcf65a5579d752516e19c75b290930 Mon Sep 17 00:00:00 2001 From: Nathan Iheanyi Date: Sun, 29 Mar 2026 07:23:02 +0100 Subject: [PATCH] feat: implement public platform analytics endpoint with 15m caching --- .../analytics/analytics.controller.spec.ts | 64 ++++++++++++ backend/src/analytics/analytics.controller.ts | 43 ++++++++ backend/src/analytics/analytics.module.ts | 15 +++ .../src/analytics/analytics.service.spec.ts | 94 ++++++++++++++++++ backend/src/analytics/analytics.service.ts | 99 +++++++++++++++++++ backend/src/app.module.ts | 2 + 6 files changed, 317 insertions(+) create mode 100644 backend/src/analytics/analytics.controller.spec.ts create mode 100644 backend/src/analytics/analytics.controller.ts create mode 100644 backend/src/analytics/analytics.module.ts create mode 100644 backend/src/analytics/analytics.service.spec.ts create mode 100644 backend/src/analytics/analytics.service.ts diff --git a/backend/src/analytics/analytics.controller.spec.ts b/backend/src/analytics/analytics.controller.spec.ts new file mode 100644 index 00000000..1956485b --- /dev/null +++ b/backend/src/analytics/analytics.controller.spec.ts @@ -0,0 +1,64 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AnalyticsController } from './analytics.controller'; +import { AnalyticsService } from './analytics.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Market } from '../markets/entities/market.entity'; +import { Prediction } from '../predictions/entities/prediction.entity'; +import { User } from '../users/entities/user.entity'; + +describe('AnalyticsController', () => { + let controller: AnalyticsController; + let service: AnalyticsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AnalyticsController], + providers: [ + AnalyticsService, + { + provide: getRepositoryToken(Market), + useValue: {}, + }, + { + provide: getRepositoryToken(Prediction), + useValue: {}, + }, + { + provide: getRepositoryToken(User), + useValue: {}, + }, + ], + }).compile(); + + controller = module.get(AnalyticsController); + service = module.get(AnalyticsService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getPlatformAnalytics', () => { + it('should call the service and return result', async () => { + const stats = { + total_markets: 10, + active_markets: 5, + total_predictions: 100, + total_users: 50, + total_volume_stroops: '1000000', + markets_by_category: [ + { category: 'Sports', count: 3 }, + { category: 'Politics', count: 2 }, + ], + predictions_24h: 10, + new_users_7d: 5, + }; + + jest.spyOn(service, 'getPlatformAnalytics').mockResolvedValue(stats); + + const result = await controller.getPlatformAnalytics(); + expect(result).toEqual(stats); + expect(service.getPlatformAnalytics).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts new file mode 100644 index 00000000..ddf62223 --- /dev/null +++ b/backend/src/analytics/analytics.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { AnalyticsService } from './analytics.service'; +import { Public } from '../common/decorators/public.decorator'; + +@ApiTags('analytics') +@Controller('analytics') +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Public() + @Get('platform') + @ApiOperation({ summary: 'Get platform-wide analytics' }) + @ApiResponse({ + status: 200, + description: 'Platform statistics retrieved successfully.', + schema: { + type: 'object', + properties: { + total_markets: { type: 'number' }, + active_markets: { type: 'number' }, + total_predictions: { type: 'number' }, + total_users: { type: 'number' }, + total_volume_stroops: { type: 'string' }, + markets_by_category: { + type: 'array', + items: { + type: 'object', + properties: { + category: { type: 'string' }, + count: { type: 'number' }, + }, + }, + }, + predictions_24h: { type: 'number' }, + new_users_7d: { type: 'number' }, + }, + }, + }) + async getPlatformAnalytics() { + return this.analyticsService.getPlatformAnalytics(); + } +} diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts new file mode 100644 index 00000000..2e9920c5 --- /dev/null +++ b/backend/src/analytics/analytics.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AnalyticsService } from './analytics.service'; +import { AnalyticsController } from './analytics.controller'; +import { Market } from '../markets/entities/market.entity'; +import { Prediction } from '../predictions/entities/prediction.entity'; +import { User } from '../users/entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Market, Prediction, User])], + controllers: [AnalyticsController], + providers: [AnalyticsService], + exports: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/backend/src/analytics/analytics.service.spec.ts b/backend/src/analytics/analytics.service.spec.ts new file mode 100644 index 00000000..73740125 --- /dev/null +++ b/backend/src/analytics/analytics.service.spec.ts @@ -0,0 +1,94 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AnalyticsService } from './analytics.service'; +import { Market } from '../markets/entities/market.entity'; +import { Prediction } from '../predictions/entities/prediction.entity'; +import { User } from '../users/entities/user.entity'; + +describe('AnalyticsService', () => { + let service: AnalyticsService; + + const mockMarketRepository = { + count: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawOne: jest.fn(), + getRawMany: jest.fn(), + })), + }; + + const mockPredictionRepository = { + count: jest.fn(), + }; + + const mockUserRepository = { + count: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { + provide: getRepositoryToken(Market), + useValue: mockMarketRepository, + }, + { + provide: getRepositoryToken(Prediction), + useValue: mockPredictionRepository, + }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + ], + }).compile(); + + service = module.get(AnalyticsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getPlatformAnalytics', () => { + it('should return aggregated platform stats and cache them', async () => { + mockMarketRepository.count.mockResolvedValueOnce(10).mockResolvedValueOnce(5); + mockPredictionRepository.count.mockResolvedValueOnce(100); + mockUserRepository.count.mockResolvedValueOnce(50); + + const queryBuilder = mockMarketRepository.createQueryBuilder(); + (queryBuilder.getRawOne as jest.Mock).mockResolvedValue({ sum: '1000000' }); + (queryBuilder.getRawMany as jest.Mock).mockResolvedValue([ + { category: 'Sports', count: '3' }, + { category: 'Politics', count: '2' }, + ]); + + mockPredictionRepository.count.mockResolvedValueOnce(10); // predictions_24h + mockUserRepository.count.mockResolvedValueOnce(5); // new_users_7d + + const result = await service.getPlatformAnalytics(); + + expect(result).toEqual({ + total_markets: 10, + active_markets: 5, + total_predictions: 100, + total_users: 50, + total_volume_stroops: '1000000', + markets_by_category: [ + { category: 'Sports', count: 3 }, + { category: 'Politics', count: 2 }, + ], + predictions_24h: 10, + new_users_7d: 5, + }); + + // Second call should come from cache + const resultCached = await service.getPlatformAnalytics(); + expect(resultCached).toEqual(result); + expect(mockMarketRepository.count).toHaveBeenCalledTimes(2); // Only called for the first aggregation + }); + }); +}); diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts new file mode 100644 index 00000000..a889f3d9 --- /dev/null +++ b/backend/src/analytics/analytics.service.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThan, Brackets } from 'typeorm'; +import { Market } from '../markets/entities/market.entity'; +import { Prediction } from '../predictions/entities/prediction.entity'; +import { User } from '../users/entities/user.entity'; + +@Injectable() +export class AnalyticsService { + private readonly logger = new Logger(AnalyticsService.name); + private cache: { data: any; timestamp: number } | null = null; + private readonly CACHE_TTL = 10 * 60 * 1000; // 10 minutes in milliseconds + + constructor( + @InjectRepository(Market) + private readonly marketRepository: Repository, + @InjectRepository(Prediction) + private readonly predictionRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async getPlatformAnalytics() { + const now = Date.now(); + + if (this.cache && now - this.cache.timestamp < this.CACHE_TTL) { + this.logger.log('Returning platform analytics from cache'); + return this.cache.data; + } + + this.logger.log('Fetching platform analytics from database'); + const data = await this.aggregatePlatformStats(); + this.cache = { data, timestamp: now }; + + return data; + } + + private async aggregatePlatformStats() { + const now = new Date(); + const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const last7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + + const [ + total_markets, + active_markets, + total_predictions, + total_users, + volumeResult, + markets_by_category, + predictions_24h, + new_users_7d, + ] = await Promise.all([ + this.marketRepository.count(), + this.marketRepository.count({ + where: { + is_resolved: false, + is_cancelled: false, + end_time: MoreThan(now), + }, + }), + this.predictionRepository.count(), + this.userRepository.count(), + this.marketRepository + .createQueryBuilder('market') + .select('SUM(CAST(market.total_pool_stroops AS NUMERIC))', 'sum') + .getRawOne(), + this.marketRepository + .createQueryBuilder('market') + .select('market.category', 'category') + .addSelect('COUNT(*)', 'count') + .groupBy('market.category') + .getRawMany(), + this.predictionRepository.count({ + where: { + submitted_at: MoreThan(last24h), + }, + }), + this.userRepository.count({ + where: { + created_at: MoreThan(last7d), + }, + }), + ]); + + return { + total_markets, + active_markets, + total_predictions, + total_users, + total_volume_stroops: volumeResult?.sum?.toString() || '0', + markets_by_category: markets_by_category.map((m) => ({ + category: m.category, + count: parseInt(m.count, 10), + })), + predictions_24h, + new_users_7d, + }; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 29941d8e..a72164b8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -20,6 +20,7 @@ import { LeaderboardModule } from './leaderboard/leaderboard.module'; import { NotificationsModule } from './notifications/notifications.module'; import { SorobanModule } from './soroban/soroban.module'; import { SeasonsModule } from './seasons/seasons.module'; +import { AnalyticsModule } from './analytics/analytics.module'; @Module({ imports: [ @@ -56,6 +57,7 @@ import { SeasonsModule } from './seasons/seasons.module'; NotificationsModule, SorobanModule, CommonModule, + AnalyticsModule, ], controllers: [AppController],