diff --git a/backend/src/users/dto/user-stats.dto.ts b/backend/src/users/dto/user-stats.dto.ts new file mode 100644 index 00000000..433dc1ac --- /dev/null +++ b/backend/src/users/dto/user-stats.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PredictionHistoryDayDto { + @ApiProperty({ example: '2025-03-15' }) + date: string; + + @ApiProperty({ example: 3 }) + count: number; +} + +export class UserStatsDto { + @ApiProperty() + total_predictions: number; + + @ApiProperty() + correct_predictions: number; + + @ApiProperty({ description: 'Percentage, 0–100' }) + accuracy_rate: number; + + @ApiProperty({ description: 'Stroops as string (bigint)' }) + total_staked_stroops: string; + + @ApiProperty() + total_winnings_stroops: string; + + @ApiProperty({ description: 'winnings minus staked (stroops)' }) + net_profit_stroops: string; + + @ApiProperty() + reputation_score: number; + + @ApiProperty() + season_points: number; + + @ApiProperty({ description: 'Global leaderboard rank (0 if unranked)' }) + rank: number; + + @ApiProperty() + markets_created: number; + + @ApiProperty() + competitions_joined: number; + + @ApiProperty() + competitions_won: number; + + @ApiProperty({ type: [String] }) + favorite_categories: string[]; + + @ApiProperty({ type: [PredictionHistoryDayDto] }) + prediction_history: PredictionHistoryDayDto[]; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index ae20362f..84a0d530 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -23,6 +23,7 @@ import { } from './dto/list-user-predictions.dto'; import { ListUserCompetitionsDto } from './dto/list-user-competitions.dto'; +import { UserStatsDto } from './dto/user-stats.dto'; @Controller('users') export class UsersController { @@ -92,6 +93,15 @@ export class UsersController { return this.usersService.findPublicPredictionsByAddress(address, query); } + @Get(':address/stats') + @Public() + @ApiOperation({ summary: 'Detailed public statistics for a user profile' }) + @ApiResponse({ status: 200, description: 'User statistics', type: UserStatsDto }) + @ApiResponse({ status: 404, description: 'User not found' }) + async getUserStats(@Param('address') address: string): Promise { + return this.usersService.getPublicStatsByAddress(address); + } + @Get(':address/competitions') @Public() @ApiOperation({ summary: 'Get competitions a user has participated in' }) diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 084cb316..ba8a772a 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -4,11 +4,19 @@ import { User } from './entities/user.entity'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; import { Prediction } from '../predictions/entities/prediction.entity'; -import { CompetitionParticipant } from 'src/competitions/entities/competition-participant.entity'; +import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; +import { Market } from '../markets/entities/market.entity'; +import { LeaderboardEntry } from '../leaderboard/entities/leaderboard-entry.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([User, Prediction, CompetitionParticipant]), + TypeOrmModule.forFeature([ + User, + Prediction, + CompetitionParticipant, + Market, + LeaderboardEntry, + ]), ], controllers: [UsersController], providers: [UsersService], diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index cc129b38..0db33b67 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -8,12 +8,16 @@ import { Prediction } from '../predictions/entities/prediction.entity'; import { ListUserPredictionsDto } from './dto/list-user-predictions.dto'; import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; import { UserCompetitionFilterStatus } from './dto/list-user-competitions.dto'; +import { Market } from '../markets/entities/market.entity'; +import { LeaderboardEntry } from '../leaderboard/entities/leaderboard-entry.entity'; describe('UsersService', () => { let service: UsersService; let repository: Repository; let predictionsRepository: Repository; let participantsRepository: Repository; + let marketsRepository: Repository; + let leaderboardRepository: Repository; const mockUser: User = { id: '123e4567-e89b-12d3-a456-426614174000', @@ -57,6 +61,19 @@ describe('UsersService', () => { provide: getRepositoryToken(CompetitionParticipant), useValue: { createQueryBuilder: jest.fn(), + count: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Market), + useValue: { + count: jest.fn(), + }, + }, + { + provide: getRepositoryToken(LeaderboardEntry), + useValue: { + findOne: jest.fn(), }, }, ], @@ -70,6 +87,12 @@ describe('UsersService', () => { participantsRepository = module.get>( getRepositoryToken(CompetitionParticipant), ); + marketsRepository = module.get>( + getRepositoryToken(Market), + ); + leaderboardRepository = module.get>( + getRepositoryToken(LeaderboardEntry), + ); }); it('should be defined', () => { @@ -215,4 +238,91 @@ describe('UsersService', () => { expect(result.data[1].outcome).toBe('incorrect'); }); }); + + describe('getPublicStatsByAddress', () => { + afterEach(() => { + (service as unknown as { statsCache: Map }).statsCache?.clear(); + }); + + beforeEach(() => { + jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser); + jest.spyOn(leaderboardRepository, 'findOne').mockResolvedValue({ + rank: 12, + } as LeaderboardEntry); + jest.spyOn(marketsRepository, 'count').mockResolvedValue(4); + jest + .spyOn(participantsRepository, 'count') + .mockResolvedValue(8); + jest.spyOn(participantsRepository, 'createQueryBuilder').mockImplementation( + () => + ({ + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(2), + }) as any, + ); + + const rawMany = jest + .fn() + .mockResolvedValueOnce([{ category: 'sports' }, { category: 'politics' }]) + .mockResolvedValueOnce([{ date: '2025-02-01', count: '3' }]); + + jest.spyOn(predictionsRepository, 'createQueryBuilder').mockImplementation( + () => + ({ + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: rawMany, + }) as any, + ); + }); + + it('should aggregate user stats', async () => { + const stats = await service.getPublicStatsByAddress( + mockUser.stellar_address, + ); + + expect(stats.total_predictions).toBe(10); + expect(stats.correct_predictions).toBe(7); + expect(stats.accuracy_rate).toBe(70); + expect(stats.net_profit_stroops).toBe('-500000'); + expect(stats.rank).toBe(12); + expect(stats.markets_created).toBe(4); + expect(stats.competitions_joined).toBe(8); + expect(stats.competitions_won).toBe(2); + expect(stats.favorite_categories).toEqual(['sports', 'politics']); + expect(stats.prediction_history).toEqual([ + { date: '2025-02-01', count: 3 }, + ]); + }); + + it('should serve cached stats on subsequent calls', async () => { + const countSpy = jest.spyOn(marketsRepository, 'count'); + + await service.getPublicStatsByAddress(mockUser.stellar_address); + await service.getPublicStatsByAddress(mockUser.stellar_address); + + expect(countSpy).toHaveBeenCalledTimes(1); + }); + + it('should use zero profit when staked equals winnings', async () => { + jest.spyOn(repository, 'findOneBy').mockResolvedValue({ + ...mockUser, + total_staked_stroops: '100', + total_winnings_stroops: '100', + }); + + const stats = await service.getPublicStatsByAddress( + mockUser.stellar_address, + ); + expect(stats.net_profit_stroops).toBe('0'); + }); + }); }); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 22f6236d..bc90aa81 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { Prediction } from '../predictions/entities/prediction.entity'; import { ListUserPredictionsDto, @@ -16,9 +16,19 @@ import { ListUserCompetitionsDto, UserCompetitionFilterStatus, } from './dto/list-user-competitions.dto'; +import { Market } from '../markets/entities/market.entity'; +import { LeaderboardEntry } from '../leaderboard/entities/leaderboard-entry.entity'; +import { UserStatsDto } from './dto/user-stats.dto'; + +const USER_STATS_CACHE_TTL_MS = 60_000; +const PREDICTION_HISTORY_DAYS = 365; + +type StatsCacheEntry = { expiresAt: number; stats: UserStatsDto }; @Injectable() export class UsersService { + private readonly statsCache = new Map(); + constructor( @InjectRepository(User) private readonly usersRepository: Repository, @@ -27,6 +37,10 @@ export class UsersService { @InjectRepository(CompetitionParticipant) private readonly participantsRepository: Repository, + @InjectRepository(Market) + private readonly marketsRepository: Repository, + @InjectRepository(LeaderboardEntry) + private readonly leaderboardRepository: Repository, ) {} async findAll(): Promise { @@ -123,6 +137,7 @@ export class UsersService { async updateProfile(userId: string, dto: UpdateUserDto): Promise { const user = await this.findById(userId); + const cacheKey = user.stellar_address; if (dto.username !== undefined) { user.username = dto.username; @@ -131,7 +146,9 @@ export class UsersService { user.avatar_url = dto.avatar_url; } - return this.usersRepository.save(user); + const saved = await this.usersRepository.save(user); + this.statsCache.delete(cacheKey); + return saved; } async findUserCompetitions(address: string, dto: ListUserCompetitionsDto) { @@ -168,4 +185,99 @@ export class UsersService { return { data, total, page, limit }; } + + async getPublicStatsByAddress(stellar_address: string): Promise { + const cached = this.statsCache.get(stellar_address); + if (cached && Date.now() < cached.expiresAt) { + return cached.stats; + } + + const user = await this.findByAddress(stellar_address); + + const now = new Date(); + const historySince = new Date(now); + historySince.setUTCDate(historySince.getUTCDate() - PREDICTION_HISTORY_DAYS); + + const [ + leaderboardEntry, + marketsCreated, + competitionsJoined, + competitionsWon, + favoriteCategoryRows, + predictionHistoryRows, + ] = await Promise.all([ + this.leaderboardRepository.findOne({ + where: { user_id: user.id, season_id: IsNull() }, + }), + this.marketsRepository.count({ where: { creator: { id: user.id } } }), + this.participantsRepository.count({ + where: { user_id: user.id }, + }), + this.participantsRepository + .createQueryBuilder('participant') + .innerJoin('participant.competition', 'competition') + .where('participant.user_id = :userId', { userId: user.id }) + .andWhere('participant.rank = :rank', { rank: 1 }) + .andWhere('competition.end_time < :now', { now }) + .getCount(), + this.predictionsRepository + .createQueryBuilder('prediction') + .innerJoin('prediction.market', 'market') + .select('market.category', 'category') + .addSelect('COUNT(*)', 'cnt') + .where('prediction.userId = :userId', { userId: user.id }) + .groupBy('market.category') + .orderBy('cnt', 'DESC') + .limit(5) + .getRawMany<{ category: string }>(), + this.predictionsRepository + .createQueryBuilder('prediction') + .select("to_char(prediction.submitted_at, 'YYYY-MM-DD')", 'date') + .addSelect('COUNT(*)', 'count') + .where('prediction.userId = :userId', { userId: user.id }) + .andWhere('prediction.submitted_at >= :since', { since: historySince }) + .groupBy("to_char(prediction.submitted_at, 'YYYY-MM-DD')") + .orderBy('date', 'DESC') + .limit(PREDICTION_HISTORY_DAYS) + .getRawMany<{ date: string; count: string }>(), + ]); + + const totalPredictions = user.total_predictions; + const correctPredictions = user.correct_predictions; + const accuracyRate = + totalPredictions === 0 + ? 0 + : Math.round((correctPredictions / totalPredictions) * 10000) / 100; + + const staked = BigInt(user.total_staked_stroops || '0'); + const winnings = BigInt(user.total_winnings_stroops || '0'); + const netProfit = (winnings - staked).toString(); + + const stats: UserStatsDto = { + total_predictions: totalPredictions, + correct_predictions: correctPredictions, + accuracy_rate: accuracyRate, + total_staked_stroops: user.total_staked_stroops, + total_winnings_stroops: user.total_winnings_stroops, + net_profit_stroops: netProfit, + reputation_score: user.reputation_score, + season_points: user.season_points, + rank: leaderboardEntry?.rank ?? 0, + markets_created: marketsCreated, + competitions_joined: competitionsJoined, + competitions_won: competitionsWon, + favorite_categories: favoriteCategoryRows.map((r) => r.category), + prediction_history: predictionHistoryRows.map((r) => ({ + date: r.date, + count: parseInt(r.count, 10), + })), + }; + + this.statsCache.set(stellar_address, { + expiresAt: Date.now() + USER_STATS_CACHE_TTL_MS, + stats, + }); + + return stats; + } }