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
70 changes: 70 additions & 0 deletions backend/src/users/dto/list-user-markets.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsOptional,
IsEnum,
IsInt,
Min,
Max,
} from 'class-validator';
import { Type } from 'class-transformer';

export enum UserMarketFilterStatus {
Active = 'active',
Resolved = 'resolved',
Cancelled = 'cancelled',
}

export enum UserMarketsSortBy {
CreatedAt = 'created_at',
ParticipantCount = 'participant_count',
}

export enum UserMarketsSortOrder {
Asc = 'asc',
Desc = 'desc',
}

export class ListUserMarketsDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;

@ApiPropertyOptional({ default: 20, maximum: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit?: number = 20;

@ApiPropertyOptional({ enum: UserMarketFilterStatus })
@IsOptional()
@IsEnum(UserMarketFilterStatus)
status?: UserMarketFilterStatus;

@ApiPropertyOptional({
enum: UserMarketsSortBy,
default: UserMarketsSortBy.CreatedAt,
})
@IsOptional()
@IsEnum(UserMarketsSortBy)
sort_by?: UserMarketsSortBy = UserMarketsSortBy.CreatedAt;

@ApiPropertyOptional({
enum: UserMarketsSortOrder,
default: UserMarketsSortOrder.Desc,
})
@IsOptional()
@IsEnum(UserMarketsSortOrder)
order?: UserMarketsSortOrder = UserMarketsSortOrder.Desc;
}

export class PaginatedUserMarketsResponse {
data: unknown[];
total: number;
page: number;
limit: number;
}
19 changes: 19 additions & 0 deletions backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import {
} from './dto/list-user-predictions.dto';

import { ListUserCompetitionsDto } from './dto/list-user-competitions.dto';
import {
ListUserMarketsDto,
PaginatedUserMarketsResponse,
} from './dto/list-user-markets.dto';

@Controller('users')
export class UsersController {
Expand Down Expand Up @@ -92,6 +96,21 @@ export class UsersController {
return this.usersService.findPublicPredictionsByAddress(address, query);
}

@Get(':address/markets')
@Public()
@UsePipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false }),
)
@ApiOperation({ summary: "List markets created by a user (public)" })
@ApiResponse({ status: 200, description: 'Paginated markets list' })
@ApiResponse({ status: 404, description: 'User not found' })
async getUserMarkets(
@Param('address') address: string,
@Query() query: ListUserMarketsDto,
): Promise<PaginatedUserMarketsResponse> {
return this.usersService.findMarketsByAddress(address, query);
}

@Get(':address/competitions')
@Public()
@ApiOperation({ summary: 'Get competitions a user has participated in' })
Expand Down
10 changes: 8 additions & 2 deletions backend/src/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ 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';

@Module({
imports: [
TypeOrmModule.forFeature([User, Prediction, CompetitionParticipant]),
TypeOrmModule.forFeature([
User,
Prediction,
CompetitionParticipant,
Market,
]),
],
controllers: [UsersController],
providers: [UsersService],
Expand Down
128 changes: 128 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,20 @@
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 {
ListUserMarketsDto,
UserMarketFilterStatus,
UserMarketsSortBy,
UserMarketsSortOrder,
} from './dto/list-user-markets.dto';

describe('UsersService', () => {
let service: UsersService;
let repository: Repository<User>;
let predictionsRepository: Repository<Prediction>;
let participantsRepository: Repository<CompetitionParticipant>;
let marketsRepository: Repository<Market>;

const mockUser: User = {
id: '123e4567-e89b-12d3-a456-426614174000',
Expand Down Expand Up @@ -59,6 +67,12 @@
createQueryBuilder: jest.fn(),
},
},
{
provide: getRepositoryToken(Market),
useValue: {
createQueryBuilder: jest.fn(),
},
},
],
}).compile();

Expand All @@ -70,6 +84,9 @@
participantsRepository = module.get<Repository<CompetitionParticipant>>(
getRepositoryToken(CompetitionParticipant),
);
marketsRepository = module.get<Repository<Market>>(
getRepositoryToken(Market),
);
});

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

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

Check warning on line 148 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 +221,7 @@

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

Check warning on line 224 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 +232,115 @@
expect(result.data[1].outcome).toBe('incorrect');
});
});

describe('findMarketsByAddress', () => {
const queryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
queryBuilder.leftJoinAndSelect.mockReturnThis();
queryBuilder.where.mockReturnThis();
queryBuilder.andWhere.mockReturnThis();
queryBuilder.orderBy.mockReturnThis();
queryBuilder.skip.mockReturnThis();
queryBuilder.take.mockReturnThis();
});

it('should scope markets to creator and return pagination', async () => {
jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser);
queryBuilder.getManyAndCount.mockResolvedValue([[], 0]);
jest
.spyOn(marketsRepository, 'createQueryBuilder')
.mockReturnValue(queryBuilder as any);

Check warning on line 262 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<Market>`

const result = await service.findMarketsByAddress(
mockUser.stellar_address,
new ListUserMarketsDto(),
);

expect(queryBuilder.where).toHaveBeenCalledWith(
'market.creatorId = :userId',
{ userId: mockUser.id },
);
expect(queryBuilder.orderBy).toHaveBeenCalledWith(
'market.created_at',
'DESC',
);
expect(result).toEqual({ data: [], total: 0, page: 1, limit: 20 });
});

it('should filter active markets', async () => {
jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser);
queryBuilder.getManyAndCount.mockResolvedValue([[], 0]);
jest
.spyOn(marketsRepository, 'createQueryBuilder')
.mockReturnValue(queryBuilder as any);

Check warning on line 285 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<Market>`

await service.findMarketsByAddress(mockUser.stellar_address, {
status: UserMarketFilterStatus.Active,
} as ListUserMarketsDto);

expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'market.is_resolved = false AND market.is_cancelled = false',
);
});

it('should filter resolved markets', async () => {
jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser);
queryBuilder.getManyAndCount.mockResolvedValue([[], 0]);
jest
.spyOn(marketsRepository, 'createQueryBuilder')
.mockReturnValue(queryBuilder as any);

Check warning on line 301 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<Market>`

await service.findMarketsByAddress(mockUser.stellar_address, {
status: UserMarketFilterStatus.Resolved,
} as ListUserMarketsDto);

expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'market.is_resolved = true',
);
});

it('should filter cancelled markets', async () => {
jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser);
queryBuilder.getManyAndCount.mockResolvedValue([[], 0]);
jest
.spyOn(marketsRepository, 'createQueryBuilder')
.mockReturnValue(queryBuilder as any);

Check warning on line 317 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<Market>`

await service.findMarketsByAddress(mockUser.stellar_address, {
status: UserMarketFilterStatus.Cancelled,
} as ListUserMarketsDto);

expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'market.is_cancelled = true',
);
});

it('should sort by participant_count and order asc', async () => {
jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser);
queryBuilder.getManyAndCount.mockResolvedValue([[], 0]);
jest
.spyOn(marketsRepository, 'createQueryBuilder')
.mockReturnValue(queryBuilder as any);

Check warning on line 333 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<Market>`

await service.findMarketsByAddress(mockUser.stellar_address, {
sort_by: UserMarketsSortBy.ParticipantCount,
order: UserMarketsSortOrder.Asc,
} as ListUserMarketsDto);

expect(queryBuilder.orderBy).toHaveBeenCalledWith(
'market.participant_count',
'ASC',
);
});
});
});
56 changes: 56 additions & 0 deletions backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ import {
ListUserCompetitionsDto,
UserCompetitionFilterStatus,
} from './dto/list-user-competitions.dto';
import { Market } from '../markets/entities/market.entity';
import {
ListUserMarketsDto,
PaginatedUserMarketsResponse,
UserMarketFilterStatus,
UserMarketsSortBy,
UserMarketsSortOrder,
} from './dto/list-user-markets.dto';

@Injectable()
export class UsersService {
Expand All @@ -27,6 +35,8 @@ export class UsersService {

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

async findAll(): Promise<User[]> {
Expand Down Expand Up @@ -168,4 +178,50 @@ export class UsersService {

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

async findMarketsByAddress(
stellar_address: string,
dto: ListUserMarketsDto,
): Promise<PaginatedUserMarketsResponse> {
const user = await this.findByAddress(stellar_address);
const page = dto.page ?? 1;
const limit = Math.min(dto.limit ?? 20, 50);
const skip = (page - 1) * limit;

const qb = this.marketsRepository
.createQueryBuilder('market')
.leftJoinAndSelect('market.creator', 'creator')
.where('market.creatorId = :userId', { userId: user.id });

if (dto.status) {
switch (dto.status) {
case UserMarketFilterStatus.Active:
qb.andWhere(
'market.is_resolved = false AND market.is_cancelled = false',
);
break;
case UserMarketFilterStatus.Resolved:
qb.andWhere('market.is_resolved = true');
break;
case UserMarketFilterStatus.Cancelled:
qb.andWhere('market.is_cancelled = true');
break;
}
}

const sortColumn =
dto.sort_by === UserMarketsSortBy.ParticipantCount
? 'market.participant_count'
: 'market.created_at';
const sortDir =
(dto.order ?? UserMarketsSortOrder.Desc) === UserMarketsSortOrder.Asc
? 'ASC'
: 'DESC';

qb.orderBy(sortColumn, sortDir).skip(skip).take(limit);

const [data, total] = await qb.getManyAndCount();

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