From 3ac56b745c906d1bcecdf87218dbf778a14b5339 Mon Sep 17 00:00:00 2001 From: Francis6-git Date: Sun, 29 Mar 2026 14:55:24 +0100 Subject: [PATCH 1/2] Get user's competitions issue #413 completed --- .../competition-participant.entity.ts | 41 ++++++++++++++++ .../users/dto/list-user-competitions.dto.ts | 49 +++++++++++++++++++ backend/src/users/users.controller.ts | 13 +++++ backend/src/users/users.module.ts | 5 +- backend/src/users/users.service.spec.ts | 4 +- backend/src/users/users.service.ts | 48 +++++++++++++++++- 6 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 backend/src/competitions/entities/competition-participant.entity.ts create mode 100644 backend/src/users/dto/list-user-competitions.dto.ts diff --git a/backend/src/competitions/entities/competition-participant.entity.ts b/backend/src/competitions/entities/competition-participant.entity.ts new file mode 100644 index 00000000..6d06b7e3 --- /dev/null +++ b/backend/src/competitions/entities/competition-participant.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Competition } from './competition.entity'; + +@Entity('competition_participants') +@Index(['user_id', 'competition_id'], { unique: true }) +export class CompetitionParticipant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + user_id: string; + + @Column({ type: 'uuid' }) + competition_id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => Competition, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'competition_id' }) + competition: Competition; + + @Column({ default: 0 }) + score: number; + + @Column({ nullable: true }) + rank: number; + + @CreateDateColumn() + joined_at: Date; +} diff --git a/backend/src/users/dto/list-user-competitions.dto.ts b/backend/src/users/dto/list-user-competitions.dto.ts new file mode 100644 index 00000000..9a2ff54e --- /dev/null +++ b/backend/src/users/dto/list-user-competitions.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsEnum, IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export enum UserCompetitionFilterStatus { + Active = 'active', + Completed = 'completed', +} + +export class ListUserCompetitionsDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 20; + + @ApiPropertyOptional({ enum: UserCompetitionFilterStatus }) + @IsOptional() + @IsEnum(UserCompetitionFilterStatus) + status?: UserCompetitionFilterStatus; +} + +export class UserCompetitionResponseItem { + @ApiProperty() + id: string; + + @ApiProperty() + title: string; + + @ApiProperty() + rank: number | null; + + @ApiProperty() + score: number; + + @ApiProperty() + end_time: Date; + + @ApiProperty({ example: 'active' }) + status: string; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 92afd0c7..ae20362f 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -22,6 +22,8 @@ import { PaginatedPublicUserPredictionsResponse, } from './dto/list-user-predictions.dto'; +import { ListUserCompetitionsDto } from './dto/list-user-competitions.dto'; + @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @@ -89,4 +91,15 @@ export class UsersController { ): Promise { return this.usersService.findPublicPredictionsByAddress(address, query); } + + @Get(':address/competitions') + @Public() + @ApiOperation({ summary: 'Get competitions a user has participated in' }) + @ApiResponse({ status: 200, description: 'List of competitions' }) + async getUserCompetitions( + @Param('address') address: string, + @Query() query: ListUserCompetitionsDto, + ) { + return this.usersService.findUserCompetitions(address, query); + } } diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index b4193a53..084cb316 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -4,9 +4,12 @@ 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'; @Module({ - imports: [TypeOrmModule.forFeature([User, Prediction])], + imports: [ + TypeOrmModule.forFeature([User, Prediction, CompetitionParticipant]), + ], controllers: [UsersController], providers: [UsersService], exports: [UsersService], diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index da6a5e61..45f32975 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -25,9 +25,9 @@ describe('UsersService', () => { season_points: 100, role: 'user', is_banned: false, - ban_reason: "", + ban_reason: '', banned_at: null, - banned_by: "", + banned_by: '', created_at: new Date('2024-01-01'), updated_at: new Date('2024-01-01'), }; diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 1a6d1fd0..22f6236d 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -11,6 +11,12 @@ import { import { User } from './entities/user.entity'; import { UpdateUserDto } from './dto/update-user.dto'; +import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; +import { + ListUserCompetitionsDto, + UserCompetitionFilterStatus, +} from './dto/list-user-competitions.dto'; + @Injectable() export class UsersService { constructor( @@ -18,6 +24,9 @@ export class UsersService { private readonly usersRepository: Repository, @InjectRepository(Prediction) private readonly predictionsRepository: Repository, + + @InjectRepository(CompetitionParticipant) + private readonly participantsRepository: Repository, ) {} async findAll(): Promise { @@ -73,7 +82,9 @@ export class UsersService { return { data, total, page, limit }; } - private mapPublicPrediction(prediction: Prediction): PublicUserPredictionItem { + private mapPublicPrediction( + prediction: Prediction, + ): PublicUserPredictionItem { const outcome = this.computePublicOutcome(prediction); return { @@ -122,4 +133,39 @@ export class UsersService { return this.usersRepository.save(user); } + + async findUserCompetitions(address: string, dto: ListUserCompetitionsDto) { + const user = await this.findByAddress(address); + const { page = 1, limit = 20, status } = dto; + const skip = (page - 1) * limit; + const now = new Date(); + + const qb = this.participantsRepository + .createQueryBuilder('participant') + .leftJoinAndSelect('participant.competition', 'competition') + .where('participant.user_id = :userId', { userId: user.id }); + + if (status === UserCompetitionFilterStatus.Active) { + qb.andWhere('competition.end_time >= :now', { now }); + } else if (status === UserCompetitionFilterStatus.Completed) { + qb.andWhere('competition.end_time < :now', { now }); + } + + const [items, total] = await qb + .orderBy('competition.end_time', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + const data = items.map((p) => ({ + id: p.competition.id, + title: p.competition.title, + rank: p.rank, + score: p.score, + end_time: p.competition.end_time, + status: p.competition.end_time < now ? 'completed' : 'active', + })); + + return { data, total, page, limit }; + } } From 5f9e2de5f937cdbc8bcacaf585546022e9eaae9d Mon Sep 17 00:00:00 2001 From: Francis6-git Date: Sun, 29 Mar 2026 15:05:00 +0100 Subject: [PATCH 2/2] All passed: pnpm run lint, test, & build --- backend/src/users/users.service.spec.ts | 114 ++++++++++-------------- 1 file changed, 45 insertions(+), 69 deletions(-) diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 45f32975..cc129b38 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -6,11 +6,14 @@ import { UsersService } from './users.service'; import { User } from './entities/user.entity'; 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'; describe('UsersService', () => { let service: UsersService; let repository: Repository; let predictionsRepository: Repository; + let participantsRepository: Repository; const mockUser: User = { id: '123e4567-e89b-12d3-a456-426614174000', @@ -30,7 +33,7 @@ describe('UsersService', () => { banned_by: '', created_at: new Date('2024-01-01'), updated_at: new Date('2024-01-01'), - }; + } as User; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -40,6 +43,8 @@ describe('UsersService', () => { provide: getRepositoryToken(User), useValue: { findOneBy: jest.fn(), + save: jest.fn(), + find: jest.fn(), }, }, { @@ -48,6 +53,12 @@ describe('UsersService', () => { createQueryBuilder: jest.fn(), }, }, + { + provide: getRepositoryToken(CompetitionParticipant), + useValue: { + createQueryBuilder: jest.fn(), + }, + }, ], }).compile(); @@ -56,6 +67,9 @@ describe('UsersService', () => { predictionsRepository = module.get>( getRepositoryToken(Prediction), ); + participantsRepository = module.get>( + getRepositoryToken(CompetitionParticipant), + ); }); it('should be defined', () => { @@ -83,22 +97,12 @@ describe('UsersService', () => { service.findByAddress('NONEXISTENT_ADDRESS'), ).rejects.toThrow(NotFoundException); }); - - it('should throw NotFoundException with descriptive message', async () => { - jest.spyOn(repository, 'findOneBy').mockResolvedValue(null); - const address = 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XNZFXNRBF7XNRBF7XN'; - - await expect(service.findByAddress(address)).rejects.toThrow( - new NotFoundException(`User with address ${address} not found`), - ); - }); }); - describe('findPublicPredictionsByAddress', () => { - it('should return only resolved-market predictions with outcome mapping', async () => { + describe('findUserCompetitions', () => { + it('should return paginated user competitions', async () => { jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser); - const now = new Date('2025-02-01T00:00:00.000Z'); const queryBuilder = { leftJoinAndSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), @@ -109,67 +113,44 @@ describe('UsersService', () => { getManyAndCount: jest.fn().mockResolvedValue([ [ { - id: 'pred-1', - chosen_outcome: 'YES', - stake_amount_stroops: '100', - payout_claimed: false, - payout_amount_stroops: '0', - tx_hash: null, - submitted_at: now, - market: { - id: 'mkt-1', - title: 'Resolved YES market', - end_time: now, - resolved_outcome: 'YES', - is_resolved: true, - is_cancelled: false, - }, - }, - { - id: 'pred-2', - chosen_outcome: 'NO', - stake_amount_stroops: '200', - payout_claimed: false, - payout_amount_stroops: '0', - tx_hash: null, - submitted_at: now, - market: { - id: 'mkt-2', - title: 'Resolved YES market', - end_time: now, - resolved_outcome: 'YES', - is_resolved: true, - is_cancelled: false, + rank: 1, + score: 100, + competition: { + id: 'comp-1', + title: 'Test Competition', + end_time: new Date(Date.now() + 10000), }, }, ], - 2, + 1, ]), }; jest - .spyOn(predictionsRepository, 'createQueryBuilder') - .mockReturnValue( - queryBuilder as unknown as ReturnType< - Repository['createQueryBuilder'] - >, - ); + .spyOn(participantsRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilder as any); - const result = await service.findPublicPredictionsByAddress( + const result = await service.findUserCompetitions( mockUser.stellar_address, - new ListUserPredictionsDto(), + { + page: 1, + limit: 10, + status: UserCompetitionFilterStatus.Active, + }, ); - expect(queryBuilder.andWhere).toHaveBeenCalledWith( - 'market.is_resolved = true', + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.data[0].title).toBe('Test Competition'); + expect(queryBuilder.where).toHaveBeenCalledWith( + 'participant.user_id = :userId', + { userId: mockUser.id }, ); - expect(result.total).toBe(2); - expect(result.data).toHaveLength(2); - expect(result.data[0].outcome).toBe('correct'); - expect(result.data[1].outcome).toBe('incorrect'); }); + }); - it('should filter public predictions by outcome', async () => { + describe('findPublicPredictionsByAddress', () => { + it('should return only resolved-market predictions with outcome mapping', async () => { jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser); const now = new Date('2025-02-01T00:00:00.000Z'); @@ -208,7 +189,7 @@ describe('UsersService', () => { tx_hash: null, submitted_at: now, market: { - id: 'mkt-2', + id: 'mkt-1', // same market, different outcome to test 'incorrect' title: 'Resolved YES market', end_time: now, resolved_outcome: 'YES', @@ -223,20 +204,15 @@ describe('UsersService', () => { jest .spyOn(predictionsRepository, 'createQueryBuilder') - .mockReturnValue( - queryBuilder as unknown as ReturnType< - Repository['createQueryBuilder'] - >, - ); + .mockReturnValue(queryBuilder as any); const result = await service.findPublicPredictionsByAddress( mockUser.stellar_address, - { outcome: 'correct' } as ListUserPredictionsDto, + new ListUserPredictionsDto(), ); - expect(result.total).toBe(2); - expect(result.data).toHaveLength(1); expect(result.data[0].outcome).toBe('correct'); + expect(result.data[1].outcome).toBe('incorrect'); }); }); });