Skip to content

Commit e8623b4

Browse files
authored
Merge pull request #494 from Georgechisom/feat/competitions-leaderboard-liquidity-tests
feat: Competition Join/Leave, Leaderboard History, and Liquidity Tests
2 parents c3b1d61 + 72bcf67 commit e8623b4

13 files changed

Lines changed: 1021 additions & 36 deletions

backend/src/competitions/competitions.controller.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Controller,
33
Post,
44
Get,
5+
Delete,
56
Param,
67
Body,
78
Query,
@@ -28,6 +29,8 @@ import {
2829
PaginatedParticipantsResponse,
2930
} from './dto/list-participants.dto';
3031
import { UserRankResponseDto } from './dto/user-rank-response.dto';
32+
import { JoinCompetitionResponseDto } from './dto/join-competition.dto';
33+
import { LeaveCompetitionResponseDto } from './dto/leave-competition.dto';
3134
import { Competition } from './entities/competition.entity';
3235
import { CurrentUser } from '../common/decorators/current-user.decorator';
3336
import { Public } from '../common/decorators/public.decorator';
@@ -115,4 +118,64 @@ export class CompetitionsController {
115118
): Promise<UserRankResponseDto> {
116119
return this.competitionsService.getMyRank(id, user.id);
117120
}
121+
122+
@Post(':id/join')
123+
@UseGuards(BanGuard)
124+
@HttpCode(HttpStatus.OK)
125+
@ApiBearerAuth()
126+
@ApiOperation({ summary: 'Join a competition' })
127+
@ApiResponse({
128+
status: 200,
129+
description: 'Successfully joined competition',
130+
type: JoinCompetitionResponseDto,
131+
})
132+
@ApiResponse({ status: 404, description: 'Competition not found' })
133+
@ApiResponse({
134+
status: 400,
135+
description: 'Competition ended or full',
136+
})
137+
@ApiResponse({
138+
status: 409,
139+
description: 'Already joined',
140+
})
141+
async joinCompetition(
142+
@Param('id') id: string,
143+
@CurrentUser() user: User,
144+
): Promise<JoinCompetitionResponseDto> {
145+
const participant = await this.competitionsService.joinCompetition(
146+
id,
147+
user,
148+
);
149+
return {
150+
message: 'Successfully joined competition',
151+
competition_id: id,
152+
participant_id: participant.id,
153+
};
154+
}
155+
156+
@Delete(':id/leave')
157+
@UseGuards(BanGuard)
158+
@HttpCode(HttpStatus.OK)
159+
@ApiBearerAuth()
160+
@ApiOperation({ summary: 'Leave a competition before it starts' })
161+
@ApiResponse({
162+
status: 200,
163+
description: 'Successfully left competition',
164+
type: LeaveCompetitionResponseDto,
165+
})
166+
@ApiResponse({ status: 404, description: 'Competition not found' })
167+
@ApiResponse({
168+
status: 400,
169+
description: 'Competition already started',
170+
})
171+
async leaveCompetition(
172+
@Param('id') id: string,
173+
@CurrentUser() user: User,
174+
): Promise<LeaveCompetitionResponseDto> {
175+
await this.competitionsService.leaveCompetition(id, user);
176+
return {
177+
message: 'Successfully left competition',
178+
competition_id: id,
179+
};
180+
}
118181
}

backend/src/competitions/competitions.service.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { Injectable, NotFoundException } from '@nestjs/common';
1+
import {
2+
Injectable,
3+
NotFoundException,
4+
BadRequestException,
5+
ConflictException,
6+
} from '@nestjs/common';
27
import { InjectRepository } from '@nestjs/typeorm';
38
import { Repository, SelectQueryBuilder } from 'typeorm';
49
import {
@@ -281,4 +286,110 @@ export class CompetitionsService {
281286
this.rankCache.set(cacheKey, { data: result, timestamp: Date.now() });
282287
return result;
283288
}
289+
290+
async joinCompetition(
291+
competitionId: string,
292+
user: User,
293+
): Promise<CompetitionParticipant> {
294+
const competition = await this.competitionsRepository.findOne({
295+
where: { id: competitionId },
296+
});
297+
298+
if (!competition) {
299+
throw new NotFoundException(
300+
`Competition with ID "${competitionId}" not found`,
301+
);
302+
}
303+
304+
// Check if competition is active
305+
const now = new Date();
306+
if (now >= competition.end_time) {
307+
throw new BadRequestException('Competition has already ended');
308+
}
309+
310+
// Check if user already joined
311+
const existing = await this.participantsRepository.findOne({
312+
where: {
313+
user_id: user.id,
314+
competition_id: competitionId,
315+
},
316+
});
317+
318+
if (existing) {
319+
throw new ConflictException('You have already joined this competition');
320+
}
321+
322+
// Check max participants
323+
if (competition.max_participants > 0) {
324+
const currentCount = await this.participantsRepository.count({
325+
where: { competition_id: competitionId },
326+
});
327+
328+
if (currentCount >= competition.max_participants) {
329+
throw new BadRequestException('Competition is full');
330+
}
331+
}
332+
333+
// Create participant
334+
const participant = this.participantsRepository.create({
335+
user_id: user.id,
336+
competition_id: competitionId,
337+
score: 0,
338+
});
339+
340+
const saved = await this.participantsRepository.save(participant);
341+
342+
// Update participant count
343+
await this.competitionsRepository.increment(
344+
{ id: competitionId },
345+
'participant_count',
346+
1,
347+
);
348+
349+
return saved;
350+
}
351+
352+
async leaveCompetition(competitionId: string, user: User): Promise<void> {
353+
const competition = await this.competitionsRepository.findOne({
354+
where: { id: competitionId },
355+
});
356+
357+
if (!competition) {
358+
throw new NotFoundException(
359+
`Competition with ID "${competitionId}" not found`,
360+
);
361+
}
362+
363+
// Check if competition has started
364+
const now = new Date();
365+
if (now >= competition.start_time) {
366+
throw new BadRequestException(
367+
'Cannot leave competition after it has started',
368+
);
369+
}
370+
371+
// Find participant
372+
const participant = await this.participantsRepository.findOne({
373+
where: {
374+
user_id: user.id,
375+
competition_id: competitionId,
376+
},
377+
});
378+
379+
if (!participant) {
380+
throw new NotFoundException(
381+
'You are not a participant in this competition',
382+
);
383+
}
384+
385+
// Remove participant
386+
await this.participantsRepository.remove(participant);
387+
388+
// Update participant count
389+
await this.competitionsRepository.decrement(
390+
{ id: competitionId },
391+
'participant_count',
392+
1,
393+
);
394+
}
284395
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export class JoinCompetitionResponseDto {
4+
@ApiProperty()
5+
message: string;
6+
7+
@ApiProperty()
8+
competition_id: string;
9+
10+
@ApiProperty()
11+
participant_id: string;
12+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export class LeaveCompetitionResponseDto {
4+
@ApiProperty()
5+
message: string;
6+
7+
@ApiProperty()
8+
competition_id: string;
9+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { IsOptional, IsDateString, IsUUID, IsInt, Min } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
4+
5+
export class LeaderboardHistoryQueryDto {
6+
@ApiPropertyOptional({ description: 'Filter by specific date (YYYY-MM-DD)' })
7+
@IsOptional()
8+
@IsDateString()
9+
date?: string;
10+
11+
@ApiPropertyOptional({ description: 'Filter by season ID' })
12+
@IsOptional()
13+
@IsUUID()
14+
season_id?: string;
15+
16+
@ApiPropertyOptional({ description: 'Filter by user ID' })
17+
@IsOptional()
18+
@IsUUID()
19+
user_id?: string;
20+
21+
@ApiPropertyOptional({ description: 'Page number', default: 1 })
22+
@IsOptional()
23+
@Type(() => Number)
24+
@IsInt()
25+
@Min(1)
26+
page?: number;
27+
28+
@ApiPropertyOptional({ description: 'Items per page', default: 20 })
29+
@IsOptional()
30+
@Type(() => Number)
31+
@IsInt()
32+
@Min(1)
33+
limit?: number;
34+
}
35+
36+
export class LeaderboardHistoryEntryResponse {
37+
@ApiProperty()
38+
rank: number;
39+
40+
@ApiProperty()
41+
user_id: string;
42+
43+
@ApiProperty({ nullable: true })
44+
username: string | null;
45+
46+
@ApiProperty()
47+
stellar_address: string;
48+
49+
@ApiProperty()
50+
reputation_score: number;
51+
52+
@ApiProperty()
53+
accuracy_rate: string;
54+
55+
@ApiProperty()
56+
total_winnings_stroops: string;
57+
58+
@ApiProperty()
59+
season_points: number;
60+
61+
@ApiProperty()
62+
snapshot_date: Date;
63+
64+
@ApiProperty({ nullable: true })
65+
rank_change?: number | null;
66+
}
67+
68+
export class PaginatedLeaderboardHistoryResponse {
69+
@ApiProperty({ type: [LeaderboardHistoryEntryResponse] })
70+
data: LeaderboardHistoryEntryResponse[];
71+
72+
@ApiProperty()
73+
total: number;
74+
75+
@ApiProperty()
76+
page: number;
77+
78+
@ApiProperty()
79+
limit: number;
80+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
CreateDateColumn,
6+
ManyToOne,
7+
JoinColumn,
8+
Index,
9+
Unique,
10+
} from 'typeorm';
11+
import { User } from '../../users/entities/user.entity';
12+
13+
@Entity('leaderboard_history')
14+
@Index(['snapshot_date'])
15+
@Index(['user_id'])
16+
@Index(['season_id'])
17+
@Unique('UQ_leaderboard_history_user_date_season', [
18+
'user_id',
19+
'snapshot_date',
20+
'season_id',
21+
])
22+
export class LeaderboardHistory {
23+
@PrimaryGeneratedColumn('uuid')
24+
id: string;
25+
26+
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
27+
@JoinColumn({ name: 'user_id' })
28+
user: User;
29+
30+
@Column({ name: 'user_id' })
31+
user_id: string;
32+
33+
@Column({ type: 'date' })
34+
snapshot_date: Date;
35+
36+
@Column({ default: 0 })
37+
rank: number;
38+
39+
@Column({ default: 0 })
40+
reputation_score: number;
41+
42+
@Column({ default: 0 })
43+
season_points: number;
44+
45+
@Column({ default: 0 })
46+
total_predictions: number;
47+
48+
@Column({ default: 0 })
49+
correct_predictions: number;
50+
51+
@Column({ type: 'bigint', default: 0 })
52+
total_winnings_stroops: string;
53+
54+
@Column({ nullable: true })
55+
season_id: string;
56+
57+
@CreateDateColumn()
58+
created_at: Date;
59+
}

0 commit comments

Comments
 (0)