From 22582fcb8ea9c46315c156c0c487edbc21c88513 Mon Sep 17 00:00:00 2001 From: pope-h Date: Mon, 30 Mar 2026 03:50:34 +0100 Subject: [PATCH] feat: implement rate limiting, detailed health check, bookmarks, and leaderboard tests Resolves #434, #436, #442, #497 --- backend/package.json | 3 +- backend/src/auth/auth.controller.spec.ts | 9 ++- backend/src/auth/auth.controller.ts | 42 +++++++++- backend/src/auth/auth.e2e.spec.ts | 12 +++ backend/src/auth/auth.module.ts | 5 +- backend/src/auth/dto/rate-limit-status.dto.ts | 21 +++++ backend/src/auth/rate-limit.service.ts | 43 +++++++++++ backend/src/health/dto/detailed-health.dto.ts | 42 ++++++++++ backend/src/health/health.controller.ts | 13 ++++ backend/src/health/health.service.ts | 76 +++++++++++++++++++ .../markets/entities/user-bookmark.entity.ts | 28 +++++++ backend/src/markets/markets.controller.ts | 18 +++++ backend/src/markets/markets.module.ts | 5 +- .../src/markets/markets.service.bulk.spec.ts | 14 +++- backend/src/markets/markets.service.spec.ts | 10 +++ backend/src/markets/markets.service.ts | 31 ++++++++ .../1774500004000-CreateUserBookmarksTable.ts | 31 ++++++++ .../src/users/dto/list-user-bookmarks.dto.ts | 34 +++++++++ backend/src/users/users.controller.ts | 19 +++++ backend/src/users/users.module.ts | 2 + backend/src/users/users.service.spec.ts | 11 +++ backend/src/users/users.service.ts | 32 ++++++++ contract/tests/leaderboard_tests.rs | 18 +++++ 23 files changed, 509 insertions(+), 10 deletions(-) create mode 100644 backend/src/auth/dto/rate-limit-status.dto.ts create mode 100644 backend/src/auth/rate-limit.service.ts create mode 100644 backend/src/health/dto/detailed-health.dto.ts create mode 100644 backend/src/markets/entities/user-bookmark.entity.ts create mode 100644 backend/src/migrations/1774500004000-CreateUserBookmarksTable.ts create mode 100644 backend/src/users/dto/list-user-bookmarks.dto.ts diff --git a/backend/package.json b/backend/package.json index f3e5df8d..da9d2e60 100644 --- a/backend/package.json +++ b/backend/package.json @@ -95,5 +95,6 @@ ], "coverageDirectory": "../coverage", "testEnvironment": "node" - } + }, + "packageManager": "pnpm@9.15.5+sha512.845196026aab1cc3f098a0474b64dfbab2afe7a1b4e91dd86895d8e4aa32a7a6d03049e2d0ad770bbe4de023a7122fb68c1a1d6e0d033c7076085f9d5d4800d4" } diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts index a7859205..093604df 100644 --- a/backend/src/auth/auth.controller.spec.ts +++ b/backend/src/auth/auth.controller.spec.ts @@ -4,6 +4,7 @@ import { User } from '../users/entities/user.entity'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { VerifyChallengeDto } from './dto/verify-challenge.dto'; +import { RateLimitService } from './rate-limit.service'; const mockAuthService = () => ({ generateChallenge: jest @@ -22,7 +23,13 @@ describe('AuthController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], - providers: [{ provide: AuthService, useValue: mockAuthService() }], + providers: [ + { provide: AuthService, useValue: mockAuthService() }, + { + provide: RateLimitService, + useValue: { getRateLimitStatus: jest.fn() }, + }, + ], }).compile(); controller = module.get(AuthController); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index c81c83b5..92a591e7 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,16 +1,35 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, +} from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; +import { RateLimitService } from './rate-limit.service'; import { GenerateChallengeDto } from './dto/generate-challenge.dto'; import { VerifyChallengeDto } from './dto/verify-challenge.dto'; import { VerifyWalletDto } from './dto/verify-wallet.dto'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { RateLimitStatusDto } from './dto/rate-limit-status.dto'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { User } from '../users/entities/user.entity'; @ApiTags('Auth') @Throttle({ default: { limit: 10, ttl: 60000 } }) @Controller('auth') export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly rateLimitService: RateLimitService, + ) {} @Post('challenge') @HttpCode(HttpStatus.OK) @@ -42,4 +61,21 @@ export class AuthController { ); return { verified }; } + + @Get('rate-limit') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get current rate limit status for authenticated user', + }) + @ApiResponse({ + status: 200, + description: 'Current rate limit status', + type: RateLimitStatusDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getRateLimitStatus( + @CurrentUser() user: User, + ): Promise { + return this.rateLimitService.getStatus(user.id); + } } diff --git a/backend/src/auth/auth.e2e.spec.ts b/backend/src/auth/auth.e2e.spec.ts index 33c5bb30..dcc7bae4 100644 --- a/backend/src/auth/auth.e2e.spec.ts +++ b/backend/src/auth/auth.e2e.spec.ts @@ -11,6 +11,8 @@ import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { Reflector } from '@nestjs/core'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RateLimitService } from './rate-limit.service'; +import { ThrottlerModule } from '@nestjs/throttler'; const sign = (kp: Keypair, text: string): string => kp.sign(Buffer.from(text, 'utf-8')).toString('hex'); @@ -57,6 +59,16 @@ describe('Auth E2E — challenge → verify flow', () => { { provide: JwtService, useValue: mockJwtService }, JwtStrategy, Reflector, + { + provide: RateLimitService, + useValue: { + getRateLimitStatus: jest.fn().mockResolvedValue({ + limit: 100, + remaining: 99, + reset_at: new Date(), + }), + }, + }, { provide: ConfigService, useValue: { diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index fcdc8031..02d98be0 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -3,9 +3,11 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ThrottlerModule } from '@nestjs/throttler'; import { User } from '../users/entities/user.entity'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { RateLimitService } from './rate-limit.service'; import { JwtStrategy } from './strategies/jwt.strategy'; @Module({ @@ -13,6 +15,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; PassportModule, ConfigModule, TypeOrmModule.forFeature([User]), + ThrottlerModule, JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -25,7 +28,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy], + providers: [AuthService, JwtStrategy, RateLimitService], exports: [AuthService, JwtModule], }) export class AuthModule {} diff --git a/backend/src/auth/dto/rate-limit-status.dto.ts b/backend/src/auth/dto/rate-limit-status.dto.ts new file mode 100644 index 00000000..2c2a8fe9 --- /dev/null +++ b/backend/src/auth/dto/rate-limit-status.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class RateLimitStatusDto { + @ApiProperty({ + description: 'Maximum number of requests allowed in the window', + example: 100, + }) + limit: number; + + @ApiProperty({ + description: 'Number of requests remaining in the current window', + example: 87, + }) + remaining: number; + + @ApiProperty({ + description: 'When the rate limit window resets', + example: '2026-03-30T04:00:00.000Z', + }) + reset_at: Date; +} diff --git a/backend/src/auth/rate-limit.service.ts b/backend/src/auth/rate-limit.service.ts new file mode 100644 index 00000000..c83dcc40 --- /dev/null +++ b/backend/src/auth/rate-limit.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { ThrottlerStorage } from '@nestjs/throttler'; +import { RateLimitStatusDto } from './dto/rate-limit-status.dto'; + +/** Default throttler config mirrors the global ThrottlerModule config in AppModule */ +const DEFAULT_LIMIT = 100; +const DEFAULT_TTL_MS = 60_000; // 60 seconds + +@Injectable() +export class RateLimitService { + constructor(private readonly throttlerStorage: ThrottlerStorage) {} + + /** + * Returns the current rate-limit status for the given identifier + * (typically the user id or IP address). + * + * @param identifier - unique key used by ThrottlerStorage (user id) + */ + async getStatus(identifier: string): Promise { + const key = `throttle:default:${identifier}`; + + let used = 0; + try { + const record = await this.throttlerStorage.increment( + key, + DEFAULT_TTL_MS, + DEFAULT_LIMIT, + DEFAULT_LIMIT, + 'default', + ); + // increment returns { totalHits, timeToExpire } + used = record.totalHits; + } catch { + // If storage doesn't have a record yet, treat as 0 hits + used = 0; + } + + const remaining = Math.max(0, DEFAULT_LIMIT - used); + const reset_at = new Date(Date.now() + DEFAULT_TTL_MS); + + return { limit: DEFAULT_LIMIT, remaining, reset_at }; + } +} diff --git a/backend/src/health/dto/detailed-health.dto.ts b/backend/src/health/dto/detailed-health.dto.ts new file mode 100644 index 00000000..81d176f6 --- /dev/null +++ b/backend/src/health/dto/detailed-health.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class DatabaseStatusDto { + @ApiProperty({ example: 'up' }) + status: string; + + @ApiProperty({ example: 4 }) + latency_ms: number; +} + +export class SorobanStatusDto { + @ApiProperty({ example: 'up' }) + status: string; + + @ApiProperty({ example: 120 }) + latency_ms: number; +} + +export class CacheStatusDto { + @ApiProperty({ example: 'up' }) + status: string; + + @ApiProperty({ example: 0.85 }) + hit_rate: number; +} + +export class DetailedHealthDto { + @ApiProperty({ enum: ['healthy', 'degraded', 'down'], example: 'healthy' }) + status: 'healthy' | 'degraded' | 'down'; + + @ApiProperty({ type: DatabaseStatusDto }) + database: DatabaseStatusDto; + + @ApiProperty({ type: SorobanStatusDto }) + soroban: SorobanStatusDto; + + @ApiProperty({ type: CacheStatusDto }) + cache: CacheStatusDto; + + @ApiProperty({ example: 3600 }) + uptime_seconds: number; +} diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts index 4e11fc8f..b4b99f39 100644 --- a/backend/src/health/health.controller.ts +++ b/backend/src/health/health.controller.ts @@ -3,6 +3,7 @@ import { HealthCheckResult } from '@nestjs/terminus'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Public } from '../common/decorators/public.decorator'; import { HealthService } from './health.service'; +import { DetailedHealthDto } from './dto/detailed-health.dto'; @ApiTags('Health') @Controller('health') @@ -34,4 +35,16 @@ export class HealthController { checkPing() { return this.healthService.checkPing(); } + + @Get('detailed') + @Public() + @ApiOperation({ summary: 'Detailed health status for monitoring' }) + @ApiResponse({ + status: 200, + description: 'Detailed health status of all components', + type: DetailedHealthDto, + }) + async checkDetailed(): Promise { + return this.healthService.checkDetailed(); + } } diff --git a/backend/src/health/health.service.ts b/backend/src/health/health.service.ts index 7aa693cb..03e0bdb5 100644 --- a/backend/src/health/health.service.ts +++ b/backend/src/health/health.service.ts @@ -10,6 +10,9 @@ import { import { InjectDataSource } from '@nestjs/typeorm'; import * as os from 'os'; import { DataSource } from 'typeorm'; +import { DetailedHealthDto } from './dto/detailed-health.dto'; + +const START_TIME = Date.now(); @Injectable() export class HealthService { @@ -61,4 +64,77 @@ export class HealthService { timestamp: new Date().toISOString(), }; } + + /** + * Detailed health check with individual component status and latency for monitoring. + * Checks database connectivity, Soroban RPC reachability, and cache status. + */ + async checkDetailed(): Promise { + const [dbResult, sorobanResult] = await Promise.all([ + this.checkDatabase(), + this.checkSoroban(), + ]); + + const overallStatus = + dbResult.status === 'down' + ? 'down' + : dbResult.status === 'degraded' || sorobanResult.status === 'degraded' + ? 'degraded' + : 'healthy'; + + return { + status: overallStatus, + database: dbResult, + soroban: sorobanResult, + cache: this.getCacheStatus(), + uptime_seconds: Math.floor((Date.now() - START_TIME) / 1000), + }; + } + + private async checkDatabase(): Promise<{ + status: string; + latency_ms: number; + }> { + const start = Date.now(); + try { + await this.dataSource.query('SELECT 1'); + return { status: 'up', latency_ms: Date.now() - start }; + } catch { + return { status: 'down', latency_ms: Date.now() - start }; + } + } + + private async checkSoroban(): Promise<{ + status: string; + latency_ms: number; + }> { + const rpcUrl = + process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; + const start = Date.now(); + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getHealth', + params: [], + }), + signal: controller.signal, + }); + clearTimeout(timeoutId); + const latency = Date.now() - start; + return { status: response.ok ? 'up' : 'degraded', latency_ms: latency }; + } catch { + return { status: 'down', latency_ms: Date.now() - start }; + } + } + + /** Cache is in-memory (challenge cache); always 'up'. Hit rate is not tracked externally. */ + private getCacheStatus(): { status: string; hit_rate: number } { + return { status: 'up', hit_rate: 0 }; + } } diff --git a/backend/src/markets/entities/user-bookmark.entity.ts b/backend/src/markets/entities/user-bookmark.entity.ts new file mode 100644 index 00000000..0d9d943c --- /dev/null +++ b/backend/src/markets/entities/user-bookmark.entity.ts @@ -0,0 +1,28 @@ +import { + Entity, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + Index, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Market } from './market.entity'; + +@Entity('user_bookmarks') +@Index(['user', 'market'], { unique: true }) +export class UserBookmark { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => Market, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'market_id' }) + market: Market; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/markets/markets.controller.ts b/backend/src/markets/markets.controller.ts index a0a2e746..a096856f 100644 --- a/backend/src/markets/markets.controller.ts +++ b/backend/src/markets/markets.controller.ts @@ -207,4 +207,22 @@ export class MarketsController { async getMarketReport(@Param('id') id: string): Promise { return this.marketsService.generateMarketReport(id); } + + @Post(':id/bookmark') + @ApiBearerAuth() + @ApiOperation({ summary: 'Bookmark a market' }) + @ApiResponse({ status: 201, description: 'Market bookmarked' }) + @ApiResponse({ status: 404, description: 'Market not found' }) + async bookmarkMarket(@Param('id') id: string, @CurrentUser() user: User) { + return this.marketsService.addBookmark(id, user); + } + + @Delete(':id/bookmark') + @ApiBearerAuth() + @ApiOperation({ summary: 'Remove a market bookmark' }) + @ApiResponse({ status: 200, description: 'Bookmark removed' }) + @ApiResponse({ status: 404, description: 'Market not found' }) + async removeBookmark(@Param('id') id: string, @CurrentUser() user: User) { + return this.marketsService.removeBookmark(id, user); + } } diff --git a/backend/src/markets/markets.module.ts b/backend/src/markets/markets.module.ts index d19643a0..ff797896 100644 --- a/backend/src/markets/markets.module.ts +++ b/backend/src/markets/markets.module.ts @@ -3,17 +3,18 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Market } from './entities/market.entity'; import { Comment } from './entities/comment.entity'; import { MarketTemplate } from './entities/market-template.entity'; +import { UserBookmark } from './entities/user-bookmark.entity'; import { MarketsService } from './markets.service'; import { MarketsController } from './markets.controller'; import { UsersModule } from '../users/users.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Market, Comment, MarketTemplate]), + TypeOrmModule.forFeature([Market, Comment, MarketTemplate, UserBookmark]), UsersModule, ], controllers: [MarketsController], providers: [MarketsService], - exports: [MarketsService], + exports: [MarketsService, TypeOrmModule], }) export class MarketsModule {} diff --git a/backend/src/markets/markets.service.bulk.spec.ts b/backend/src/markets/markets.service.bulk.spec.ts index c9549a1e..9c3ab258 100644 --- a/backend/src/markets/markets.service.bulk.spec.ts +++ b/backend/src/markets/markets.service.bulk.spec.ts @@ -10,6 +10,7 @@ import { SorobanService } from '../soroban/soroban.service'; import { UsersService } from '../users/users.service'; import { User } from '../users/entities/user.entity'; import { CreateMarketDto } from './dto/create-market.dto'; +import { UserBookmark } from './entities/user-bookmark.entity'; describe('MarketsService - Bulk Creation', () => { let service: MarketsService; @@ -81,6 +82,15 @@ describe('MarketsService - Bulk Creation', () => { provide: getRepositoryToken(MarketTemplate), useValue: {}, }, + { + provide: getRepositoryToken(UserBookmark), + useValue: { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }, + }, { provide: UsersService, useValue: {}, @@ -121,7 +131,7 @@ describe('MarketsService - Bulk Creation', () => { const dtos = [makeCreateDto(), makeCreateDto()]; const mockQueryRunner = dataSource.createQueryRunner(); - mockQueryRunner.manager.create.mockImplementation( + (mockQueryRunner.manager.create as jest.Mock).mockImplementation( (entity, data) => ({ ...data, @@ -129,7 +139,7 @@ describe('MarketsService - Bulk Creation', () => { }) as Market, ); - mockQueryRunner.manager.save.mockResolvedValue({ + (mockQueryRunner.manager.save as jest.Mock).mockResolvedValue({ id: 'market-123', on_chain_market_id: 'market_123', } as Market); diff --git a/backend/src/markets/markets.service.spec.ts b/backend/src/markets/markets.service.spec.ts index ee3f63fd..a34cd54a 100644 --- a/backend/src/markets/markets.service.spec.ts +++ b/backend/src/markets/markets.service.spec.ts @@ -10,6 +10,7 @@ import { Comment } from './entities/comment.entity'; import { MarketTemplate } from './entities/market-template.entity'; import { CreateMarketDto } from './dto/create-market.dto'; import { MarketsService } from './markets.service'; +import { UserBookmark } from './entities/user-bookmark.entity'; type MockRepo = jest.Mocked< Pick, 'create' | 'save' | 'findOne' | 'find'> @@ -83,6 +84,15 @@ describe('MarketsService', () => { provide: getRepositoryToken(MarketTemplate), useValue: marketsRepository, }, + { + provide: getRepositoryToken(UserBookmark), + useValue: { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }, + }, { provide: UsersService, useValue: {}, diff --git a/backend/src/markets/markets.service.ts b/backend/src/markets/markets.service.ts index cb289570..330b51ca 100644 --- a/backend/src/markets/markets.service.ts +++ b/backend/src/markets/markets.service.ts @@ -16,6 +16,7 @@ import { CreateMarketDto } from './dto/create-market.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; import { UsersService } from '../users/users.service'; import { User } from '../users/entities/user.entity'; +import { UserBookmark } from './entities/user-bookmark.entity'; import { ListMarketsDto, MarketStatus, @@ -44,6 +45,8 @@ export class MarketsService { private readonly commentsRepository: Repository, @InjectRepository(MarketTemplate) private readonly marketTemplatesRepository: Repository, + @InjectRepository(UserBookmark) + private readonly userBookmarksRepository: Repository, private readonly usersService: UsersService, private readonly sorobanService: SorobanService, private readonly dataSource: DataSource, @@ -586,4 +589,32 @@ export class MarketsService { generated_at: new Date(), }; } + + async addBookmark(marketId: string, user: User): Promise { + const market = await this.findByIdOrOnChainId(marketId); + + const existing = await this.userBookmarksRepository.findOne({ + where: { user: { id: user.id }, market: { id: market.id } }, + }); + + if (existing) { + return existing; // already bookmarked + } + + const bookmark = this.userBookmarksRepository.create({ + user, + market, + }); + + return await this.userBookmarksRepository.save(bookmark); + } + + async removeBookmark(marketId: string, user: User): Promise { + const market = await this.findByIdOrOnChainId(marketId); + + await this.userBookmarksRepository.delete({ + user: { id: user.id }, + market: { id: market.id }, + }); + } } diff --git a/backend/src/migrations/1774500004000-CreateUserBookmarksTable.ts b/backend/src/migrations/1774500004000-CreateUserBookmarksTable.ts new file mode 100644 index 00000000..6ddc4d01 --- /dev/null +++ b/backend/src/migrations/1774500004000-CreateUserBookmarksTable.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserBookmarksTable1774500004000 implements MigrationInterface { + name = 'CreateUserBookmarksTable1774500004000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_bookmarks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid, "market_id" uuid, CONSTRAINT "PK_user_bookmarks_id" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_user_bookmark_unique" ON "user_bookmarks" ("user_id", "market_id")`, + ); + await queryRunner.query( + `ALTER TABLE "user_bookmarks" ADD CONSTRAINT "FK_user_bookmarks_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "user_bookmarks" ADD CONSTRAINT "FK_user_bookmarks_market" FOREIGN KEY ("market_id") REFERENCES "markets"("id") ON DELETE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_bookmarks" DROP CONSTRAINT "FK_user_bookmarks_market"`, + ); + await queryRunner.query( + `ALTER TABLE "user_bookmarks" DROP CONSTRAINT "FK_user_bookmarks_user"`, + ); + await queryRunner.query(`DROP INDEX "public"."IDX_user_bookmark_unique"`); + await queryRunner.query(`DROP TABLE "user_bookmarks"`); + } +} diff --git a/backend/src/users/dto/list-user-bookmarks.dto.ts b/backend/src/users/dto/list-user-bookmarks.dto.ts new file mode 100644 index 00000000..0d2028f7 --- /dev/null +++ b/backend/src/users/dto/list-user-bookmarks.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListUserBookmarksDto { + @ApiProperty({ required: false, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiProperty({ required: false, default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number = 20; +} + +export class PaginatedUserBookmarksResponse { + @ApiProperty() + data: any[]; // we'll type it with the bookmark payload + + @ApiProperty() + total: number; + + @ApiProperty() + page: number; + + @ApiProperty() + limit: number; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 8e2fff01..db02be00 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -21,6 +21,11 @@ import { ListUserPredictionsDto, PaginatedPublicUserPredictionsResponse, } from './dto/list-user-predictions.dto'; +import { + ListUserBookmarksDto, + PaginatedUserBookmarksResponse, +} from './dto/list-user-bookmarks.dto'; +import { ApiBearerAuth } from '@nestjs/swagger'; import { ListUserCompetitionsDto } from './dto/list-user-competitions.dto'; import { @@ -46,6 +51,20 @@ export class UsersController { }); } + @Get('me/bookmarks') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get favorite markets for current user' }) + @ApiResponse({ + status: 200, + description: 'Paginated user bookmarks', + }) + async getUserBookmarks( + @CurrentUser() user: User, + @Query() query: ListUserBookmarksDto, + ): Promise { + return this.usersService.findUserBookmarks(user.id, query); + } + @Patch('me') @UsePipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false }), diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 7a6b91c4..7758ab8b 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -7,6 +7,7 @@ import { Prediction } from '../predictions/entities/prediction.entity'; import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; import { Market } from '../markets/entities/market.entity'; import { Notification } from '../notifications/entities/notification.entity'; +import { UserBookmark } from '../markets/entities/user-bookmark.entity'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { Notification } from '../notifications/entities/notification.entity'; CompetitionParticipant, Market, Notification, + UserBookmark, ]), ], controllers: [UsersController], diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 6dfb04df..30b091a5 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -16,6 +16,7 @@ import { UserMarketsSortBy, UserMarketsSortOrder, } from './dto/list-user-markets.dto'; +import { UserBookmark } from '../markets/entities/user-bookmark.entity'; describe('UsersService', () => { let service: UsersService; @@ -83,6 +84,16 @@ describe('UsersService', () => { find: jest.fn(), }, }, + { + provide: getRepositoryToken(UserBookmark), + useValue: { + findAndCount: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }, + }, ], }).compile(); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index f185288c..6989cc63 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -25,6 +25,11 @@ import { UserMarketsSortBy, UserMarketsSortOrder, } from './dto/list-user-markets.dto'; +import { UserBookmark } from '../markets/entities/user-bookmark.entity'; +import { + ListUserBookmarksDto, + PaginatedUserBookmarksResponse, +} from './dto/list-user-bookmarks.dto'; @Injectable() export class UsersService { @@ -39,6 +44,8 @@ export class UsersService { private readonly notificationsRepository: Repository, @InjectRepository(CompetitionParticipant) private readonly participantsRepository: Repository, + @InjectRepository(UserBookmark) + private readonly userBookmarksRepository: Repository, ) {} async findAll(): Promise { @@ -227,6 +234,31 @@ export class UsersService { return { data, total, page, limit }; } + async findUserBookmarks( + userId: string, + dto: ListUserBookmarksDto, + ): Promise { + const page = dto.page ?? 1; + const limit = Math.min(dto.limit ?? 20, 50); + const skip = (page - 1) * limit; + + const [bookmarks, total] = await this.userBookmarksRepository.findAndCount({ + where: { user: { id: userId } }, + relations: ['market'], + order: { created_at: 'DESC' }, + skip, + take: limit, + }); + + const data = bookmarks.map((b) => ({ + id: b.id, + market: b.market, + created_at: b.created_at, + })); + + return { data, total, page, limit }; + } + async exportUserData(userId: string) { const user = await this.findById(userId); diff --git a/contract/tests/leaderboard_tests.rs b/contract/tests/leaderboard_tests.rs index 69a21099..0d82778f 100644 --- a/contract/tests/leaderboard_tests.rs +++ b/contract/tests/leaderboard_tests.rs @@ -174,3 +174,21 @@ fn test_leaderboard_tie_handling() { assert_eq!(client.get_user_season_points(&user1, &season_id), 100); assert_eq!(client.get_user_season_points(&user2, &season_id), 100); } + +#[test] +fn test_update_leaderboard_empty_entries() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, xlm_token) = deploy(&env); + + let reward_pool = 15_000_000; + fund_reward_pool(&env, &client, &admin, &xlm_token, reward_pool); + + let season_id = client.create_season(&admin, &100, &200, &reward_pool); + let empty_entries = vec![&env]; + + client.update_leaderboard(&admin, &season_id, &empty_entries); + + let snapshot = client.get_leaderboard(&season_id); + assert_eq!(snapshot.entries.len(), 0); +}