Skip to content
Open
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
53 changes: 53 additions & 0 deletions backend/src/users/dto/user-stats.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
10 changes: 10 additions & 0 deletions backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<UserStatsDto> {
return this.usersService.getPublicStatsByAddress(address);
}

@Get(':address/competitions')
@Public()
@ApiOperation({ summary: 'Get competitions a user has participated in' })
Expand Down
12 changes: 10 additions & 2 deletions backend/src/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
110 changes: 110 additions & 0 deletions backend/src/users/users.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
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<User>;
let predictionsRepository: Repository<Prediction>;
let participantsRepository: Repository<CompetitionParticipant>;
let marketsRepository: Repository<Market>;
let leaderboardRepository: Repository<LeaderboardEntry>;

const mockUser: User = {
id: '123e4567-e89b-12d3-a456-426614174000',
Expand Down Expand Up @@ -57,6 +61,19 @@
provide: getRepositoryToken(CompetitionParticipant),
useValue: {
createQueryBuilder: jest.fn(),
count: jest.fn(),
},
},
{
provide: getRepositoryToken(Market),
useValue: {
count: jest.fn(),
},
},
{
provide: getRepositoryToken(LeaderboardEntry),
useValue: {
findOne: jest.fn(),
},
},
],
Expand All @@ -70,6 +87,12 @@
participantsRepository = module.get<Repository<CompetitionParticipant>>(
getRepositoryToken(CompetitionParticipant),
);
marketsRepository = module.get<Repository<Market>>(
getRepositoryToken(Market),
);
leaderboardRepository = module.get<Repository<LeaderboardEntry>>(
getRepositoryToken(LeaderboardEntry),
);
});

it('should be defined', () => {
Expand Down Expand Up @@ -128,7 +151,7 @@

jest
.spyOn(participantsRepository, 'createQueryBuilder')
.mockReturnValue(queryBuilder as any);

Check warning on line 154 in backend/src/users/users.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `SelectQueryBuilder<CompetitionParticipant>`

const result = await service.findUserCompetitions(
mockUser.stellar_address,
Expand Down Expand Up @@ -204,7 +227,7 @@

jest
.spyOn(predictionsRepository, 'createQueryBuilder')
.mockReturnValue(queryBuilder as any);

Check warning on line 230 in backend/src/users/users.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `SelectQueryBuilder<Prediction>`

const result = await service.findPublicPredictionsByAddress(
mockUser.stellar_address,
Expand All @@ -215,4 +238,91 @@
expect(result.data[1].outcome).toBe('incorrect');
});
});

describe('getPublicStatsByAddress', () => {
afterEach(() => {
(service as unknown as { statsCache: Map<string, unknown> }).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(),

Check failure on line 260 in backend/src/users/users.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe return of a value of type `any`
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(),

Check failure on line 280 in backend/src/users/users.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe return of a value of type `any`
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');
});
});
});
116 changes: 114 additions & 2 deletions backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string, StatsCacheEntry>();

constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
Expand All @@ -27,6 +37,10 @@ export class UsersService {

@InjectRepository(CompetitionParticipant)
private readonly participantsRepository: Repository<CompetitionParticipant>,
@InjectRepository(Market)
private readonly marketsRepository: Repository<Market>,
@InjectRepository(LeaderboardEntry)
private readonly leaderboardRepository: Repository<LeaderboardEntry>,
) {}

async findAll(): Promise<User[]> {
Expand Down Expand Up @@ -123,6 +137,7 @@ export class UsersService {

async updateProfile(userId: string, dto: UpdateUserDto): Promise<User> {
const user = await this.findById(userId);
const cacheKey = user.stellar_address;

if (dto.username !== undefined) {
user.username = dto.username;
Expand All @@ -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) {
Expand Down Expand Up @@ -168,4 +185,99 @@ export class UsersService {

return { data, total, page, limit };
}

async getPublicStatsByAddress(stellar_address: string): Promise<UserStatsDto> {
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;
}
}
Loading