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
63 changes: 63 additions & 0 deletions backend/src/competitions/competitions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Controller,
Post,
Get,
Delete,
Param,
Body,
Query,
Expand All @@ -28,6 +29,8 @@ import {
PaginatedParticipantsResponse,
} from './dto/list-participants.dto';
import { UserRankResponseDto } from './dto/user-rank-response.dto';
import { JoinCompetitionResponseDto } from './dto/join-competition.dto';
import { LeaveCompetitionResponseDto } from './dto/leave-competition.dto';
import { Competition } from './entities/competition.entity';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { Public } from '../common/decorators/public.decorator';
Expand Down Expand Up @@ -115,4 +118,64 @@ export class CompetitionsController {
): Promise<UserRankResponseDto> {
return this.competitionsService.getMyRank(id, user.id);
}

@Post(':id/join')
@UseGuards(BanGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Join a competition' })
@ApiResponse({
status: 200,
description: 'Successfully joined competition',
type: JoinCompetitionResponseDto,
})
@ApiResponse({ status: 404, description: 'Competition not found' })
@ApiResponse({
status: 400,
description: 'Competition ended or full',
})
@ApiResponse({
status: 409,
description: 'Already joined',
})
async joinCompetition(
@Param('id') id: string,
@CurrentUser() user: User,
): Promise<JoinCompetitionResponseDto> {
const participant = await this.competitionsService.joinCompetition(
id,
user,
);
return {
message: 'Successfully joined competition',
competition_id: id,
participant_id: participant.id,
};
}

@Delete(':id/leave')
@UseGuards(BanGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Leave a competition before it starts' })
@ApiResponse({
status: 200,
description: 'Successfully left competition',
type: LeaveCompetitionResponseDto,
})
@ApiResponse({ status: 404, description: 'Competition not found' })
@ApiResponse({
status: 400,
description: 'Competition already started',
})
async leaveCompetition(
@Param('id') id: string,
@CurrentUser() user: User,
): Promise<LeaveCompetitionResponseDto> {
await this.competitionsService.leaveCompetition(id, user);
return {
message: 'Successfully left competition',
competition_id: id,
};
}
}
113 changes: 112 additions & 1 deletion backend/src/competitions/competitions.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import {
Expand Down Expand Up @@ -266,4 +271,110 @@ export class CompetitionsService {
this.rankCache.set(cacheKey, { data: result, timestamp: Date.now() });
return result;
}

async joinCompetition(
competitionId: string,
user: User,
): Promise<CompetitionParticipant> {
const competition = await this.competitionsRepository.findOne({
where: { id: competitionId },
});

if (!competition) {
throw new NotFoundException(
`Competition with ID "${competitionId}" not found`,
);
}

// Check if competition is active
const now = new Date();
if (now >= competition.end_time) {
throw new BadRequestException('Competition has already ended');
}

// Check if user already joined
const existing = await this.participantsRepository.findOne({
where: {
user_id: user.id,
competition_id: competitionId,
},
});

if (existing) {
throw new ConflictException('You have already joined this competition');
}

// Check max participants
if (competition.max_participants > 0) {
const currentCount = await this.participantsRepository.count({
where: { competition_id: competitionId },
});

if (currentCount >= competition.max_participants) {
throw new BadRequestException('Competition is full');
}
}

// Create participant
const participant = this.participantsRepository.create({
user_id: user.id,
competition_id: competitionId,
score: 0,
});

const saved = await this.participantsRepository.save(participant);

// Update participant count
await this.competitionsRepository.increment(
{ id: competitionId },
'participant_count',
1,
);

return saved;
}

async leaveCompetition(competitionId: string, user: User): Promise<void> {
const competition = await this.competitionsRepository.findOne({
where: { id: competitionId },
});

if (!competition) {
throw new NotFoundException(
`Competition with ID "${competitionId}" not found`,
);
}

// Check if competition has started
const now = new Date();
if (now >= competition.start_time) {
throw new BadRequestException(
'Cannot leave competition after it has started',
);
}

// Find participant
const participant = await this.participantsRepository.findOne({
where: {
user_id: user.id,
competition_id: competitionId,
},
});

if (!participant) {
throw new NotFoundException(
'You are not a participant in this competition',
);
}

// Remove participant
await this.participantsRepository.remove(participant);

// Update participant count
await this.competitionsRepository.decrement(
{ id: competitionId },
'participant_count',
1,
);
}
}
12 changes: 12 additions & 0 deletions backend/src/competitions/dto/join-competition.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';

export class JoinCompetitionResponseDto {
@ApiProperty()
message: string;

@ApiProperty()
competition_id: string;

@ApiProperty()
participant_id: string;
}
9 changes: 9 additions & 0 deletions backend/src/competitions/dto/leave-competition.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';

export class LeaveCompetitionResponseDto {
@ApiProperty()
message: string;

@ApiProperty()
competition_id: string;
}
80 changes: 80 additions & 0 deletions backend/src/leaderboard/dto/leaderboard-history.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { IsOptional, IsDateString, IsUUID, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class LeaderboardHistoryQueryDto {
@ApiPropertyOptional({ description: 'Filter by specific date (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
date?: string;

@ApiPropertyOptional({ description: 'Filter by season ID' })
@IsOptional()
@IsUUID()
season_id?: string;

@ApiPropertyOptional({ description: 'Filter by user ID' })
@IsOptional()
@IsUUID()
user_id?: string;

@ApiPropertyOptional({ description: 'Page number', default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;

@ApiPropertyOptional({ description: 'Items per page', default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number;
}

export class LeaderboardHistoryEntryResponse {
@ApiProperty()
rank: number;

@ApiProperty()
user_id: string;

@ApiProperty({ nullable: true })
username: string | null;

@ApiProperty()
stellar_address: string;

@ApiProperty()
reputation_score: number;

@ApiProperty()
accuracy_rate: string;

@ApiProperty()
total_winnings_stroops: string;

@ApiProperty()
season_points: number;

@ApiProperty()
snapshot_date: Date;

@ApiProperty({ nullable: true })
rank_change?: number | null;
}

export class PaginatedLeaderboardHistoryResponse {
@ApiProperty({ type: [LeaderboardHistoryEntryResponse] })
data: LeaderboardHistoryEntryResponse[];

@ApiProperty()
total: number;

@ApiProperty()
page: number;

@ApiProperty()
limit: number;
}
59 changes: 59 additions & 0 deletions backend/src/leaderboard/entities/leaderboard-history.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
Unique,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';

@Entity('leaderboard_history')
@Index(['snapshot_date'])
@Index(['user_id'])
@Index(['season_id'])
@Unique('UQ_leaderboard_history_user_date_season', [
'user_id',
'snapshot_date',
'season_id',
])
export class LeaderboardHistory {
@PrimaryGeneratedColumn('uuid')
id: string;

@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'user_id' })
user: User;

@Column({ name: 'user_id' })
user_id: string;

@Column({ type: 'date' })
snapshot_date: Date;

@Column({ default: 0 })
rank: number;

@Column({ default: 0 })
reputation_score: number;

@Column({ default: 0 })
season_points: number;

@Column({ default: 0 })
total_predictions: number;

@Column({ default: 0 })
correct_predictions: number;

@Column({ type: 'bigint', default: 0 })
total_winnings_stroops: string;

@Column({ nullable: true })
season_id: string;

@CreateDateColumn()
created_at: Date;
}
Loading
Loading