diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 48ec932b..7dc3a678 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -19,6 +19,7 @@ import { BanUserDto } from './dto/ban-user.dto'; import { ActivityLogQueryDto } from './dto/activity-log-query.dto'; import { StatsResponseDto } from './dto/stats-response.dto'; import { ResolveMarketDto } from './dto/resolve-market.dto'; +import { UpdateUserRoleDto } from './dto/update-user-role.dto'; import { ModerateCommentDto } from './dto/moderate-comment.dto'; import { ReportQueryDto } from './dto/report-query.dto'; @@ -59,6 +60,19 @@ export class AdminController { ); } + @Patch('users/:id/role') + async updateUserRole( + @Param('id') id: string, + @Body() dto: UpdateUserRoleDto, + @Request() req: any, + ) { + return this.adminService.updateUserRole( + id, + dto, + (req as { user: { id: string } }).user.id, + ); + } + @Get('users/:id/activity') async getUserActivity( @Param('id') id: string, diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts index f475c542..12ea8a26 100644 --- a/backend/src/admin/admin.service.spec.ts +++ b/backend/src/admin/admin.service.spec.ts @@ -17,6 +17,7 @@ import { SorobanService } from '../soroban/soroban.service'; import { User } from '../users/entities/user.entity'; import { AdminService } from './admin.service'; import { ResolveMarketDto } from './dto/resolve-market.dto'; +import { Role } from '../common/enums/role.enum'; const mockRepo = () => ({ findOne: jest.fn(), @@ -206,3 +207,80 @@ describe('AdminService.adminResolveMarket', () => { ); }); }); + +describe('AdminService.updateUserRole', () => { + let service: AdminService; + let usersRepo: ReturnType; + let analyticsService: jest.Mocked>; + + const adminId = 'admin-1'; + + beforeEach(async () => { + usersRepo = mockRepo(); + analyticsService = { logActivity: jest.fn().mockResolvedValue({}) }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminService, + { provide: getRepositoryToken(User), useValue: usersRepo }, + { provide: getRepositoryToken(Market), useValue: mockRepo() }, + { provide: getRepositoryToken(Comment), useValue: mockRepo() }, + { provide: getRepositoryToken(Prediction), useValue: mockRepo() }, + { provide: getRepositoryToken(Competition), useValue: mockRepo() }, + { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, + { provide: AnalyticsService, useValue: analyticsService }, + { + provide: NotificationsService, + useValue: { create: jest.fn() }, + }, + { + provide: SorobanService, + useValue: { resolveMarket: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(AdminService); + }); + + it('should update user role from user to admin', async () => { + const user = { + id: 'user-1', + role: 'user', + } as User; + + usersRepo.findOne.mockResolvedValue(user); + usersRepo.save.mockResolvedValue({ ...user, role: Role.Admin }); + + const result = await service.updateUserRole( + 'user-1', + { role: Role.Admin }, + adminId, + ); + + expect(result.role).toBe(Role.Admin); + expect(analyticsService.logActivity).toHaveBeenCalledWith( + adminId, + 'USER_ROLE_CHANGED', + expect.objectContaining({ + target_user_id: 'user-1', + previous_role: 'user', + new_role: Role.Admin, + }), + ); + }); + + it('should throw BadRequestException when admin tries to change own role', async () => { + await expect( + service.updateUserRole(adminId, { role: Role.User }, adminId), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException when user does not exist', async () => { + usersRepo.findOne.mockResolvedValue(null); + + await expect( + service.updateUserRole('non-existent', { role: Role.Admin }, adminId), + ).rejects.toThrow(NotFoundException); + }); +}); diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index b5f8e227..1aeb48cb 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -22,6 +22,7 @@ import { ListUsersQueryDto } from './dto/list-users-query.dto'; import { ActivityLogQueryDto } from './dto/activity-log-query.dto'; import { StatsResponseDto } from './dto/stats-response.dto'; import { ResolveMarketDto } from './dto/resolve-market.dto'; +import { UpdateUserRoleDto } from './dto/update-user-role.dto'; import { ReportFormat, ReportQueryDto, @@ -180,6 +181,36 @@ export class AdminService { return user; } + async updateUserRole( + id: string, + dto: UpdateUserRoleDto, + adminId: string, + ): Promise { + if (id === adminId) { + throw new BadRequestException('You cannot change your own role'); + } + + const user = await this.usersRepository.findOne({ where: { id } }); + if (!user) throw new NotFoundException('User not found'); + + const previousRole = user.role; + user.role = dto.role; + + await this.usersRepository.save(user); + + await this.analyticsService.logActivity(adminId, 'USER_ROLE_CHANGED', { + target_user_id: id, + previous_role: previousRole, + new_role: dto.role, + }); + + this.logger.log( + `Admin ${adminId} changed role of user ${id} from "${previousRole}" to "${dto.role}"`, + ); + + return user; + } + async getUserActivity(userId: string, query: ActivityLogQueryDto) { const { page = 1, limit = 10, actionType, startDate, endDate } = query; const skip = (page - 1) * limit; diff --git a/backend/src/admin/dto/update-user-role.dto.ts b/backend/src/admin/dto/update-user-role.dto.ts new file mode 100644 index 00000000..7075de2f --- /dev/null +++ b/backend/src/admin/dto/update-user-role.dto.ts @@ -0,0 +1,13 @@ +import { IsEnum } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Role } from '../../common/enums/role.enum'; + +export class UpdateUserRoleDto { + @ApiProperty({ + description: 'The role to assign to the user', + enum: Role, + example: Role.Admin, + }) + @IsEnum(Role) + role: Role; +} diff --git a/backend/src/competitions/competitions.controller.ts b/backend/src/competitions/competitions.controller.ts index fe670e00..d17cbcab 100644 --- a/backend/src/competitions/competitions.controller.ts +++ b/backend/src/competitions/competitions.controller.ts @@ -23,6 +23,10 @@ import { ListCompetitionsDto, PaginatedCompetitionsResponse, } from './dto/list-competitions.dto'; +import { + ListParticipantsQueryDto, + PaginatedParticipantsResponse, +} from './dto/list-participants.dto'; import { Competition } from './entities/competition.entity'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { Public } from '../common/decorators/public.decorator'; @@ -76,4 +80,19 @@ export class CompetitionsController { } return competition; } + + @Get(':id/participants') + @Public() + @ApiOperation({ summary: 'Get participants of a competition' }) + @ApiResponse({ + status: 200, + description: 'Paginated participants with scores and rankings', + }) + @ApiResponse({ status: 404, description: 'Competition not found' }) + async getParticipants( + @Param('id') id: string, + @Query() query: ListParticipantsQueryDto, + ): Promise { + return this.competitionsService.getParticipants(id, query); + } } diff --git a/backend/src/competitions/competitions.module.ts b/backend/src/competitions/competitions.module.ts index 101c1fc4..ef1ffe7e 100644 --- a/backend/src/competitions/competitions.module.ts +++ b/backend/src/competitions/competitions.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Competition } from './entities/competition.entity'; +import { CompetitionParticipant } from './entities/competition-participant.entity'; import { CompetitionsService } from './competitions.service'; import { CompetitionsController } from './competitions.controller'; import { UsersModule } from '../users/users.module'; @Module({ - imports: [TypeOrmModule.forFeature([Competition]), UsersModule], + imports: [ + TypeOrmModule.forFeature([Competition, CompetitionParticipant]), + UsersModule, + ], controllers: [CompetitionsController], providers: [CompetitionsService], exports: [CompetitionsService], diff --git a/backend/src/competitions/competitions.service.spec.ts b/backend/src/competitions/competitions.service.spec.ts index c293777e..a6dedc04 100644 --- a/backend/src/competitions/competitions.service.spec.ts +++ b/backend/src/competitions/competitions.service.spec.ts @@ -1,10 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; import { CompetitionsService } from './competitions.service'; import { Competition, CompetitionVisibility, } from './entities/competition.entity'; +import { CompetitionParticipant } from './entities/competition-participant.entity'; import { CreateCompetitionDto } from './dto/create-competition.dto'; import { User } from '../users/entities/user.entity'; @@ -34,6 +36,11 @@ describe('CompetitionsService', () => { save: jest.fn(), find: jest.fn(), findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockParticipantsRepository = { + createQueryBuilder: jest.fn(), }; beforeEach(async () => { @@ -44,6 +51,10 @@ describe('CompetitionsService', () => { provide: getRepositoryToken(Competition), useValue: mockRepository, }, + { + provide: getRepositoryToken(CompetitionParticipant), + useValue: mockParticipantsRepository, + }, ], }).compile(); @@ -151,4 +162,69 @@ describe('CompetitionsService', () => { expect(result).toBeNull(); }); }); + + describe('getParticipants', () => { + it('should return paginated participants for a competition', async () => { + mockRepository.findOne.mockResolvedValue(mockCompetition); + + const participants = [ + { + id: 'part-1', + user_id: 'user-1', + competition_id: 'comp-uuid-1', + score: 100, + rank: 1, + joined_at: new Date(), + user: { + id: 'user-1', + username: 'alice', + stellar_address: 'GABCDEF', + }, + }, + { + id: 'part-2', + user_id: 'user-2', + competition_id: 'comp-uuid-1', + score: 50, + rank: 2, + joined_at: new Date(), + user: { + id: 'user-2', + username: null, + stellar_address: 'GXYZ123', + }, + }, + ]; + + const qbMock = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([participants, 2]), + }; + mockParticipantsRepository.createQueryBuilder.mockReturnValue(qbMock); + + const result = await service.getParticipants('comp-uuid-1', { + page: 1, + limit: 20, + }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.data[0].username).toBe('alice'); + expect(result.data[0].score).toBe(100); + expect(result.data[1].username).toBeNull(); + }); + + it('should throw NotFoundException if competition does not exist', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + service.getParticipants('non-existent', { page: 1, limit: 20 }), + ).rejects.toThrow(NotFoundException); + }); + }); }); diff --git a/backend/src/competitions/competitions.service.ts b/backend/src/competitions/competitions.service.ts index 19c87f6a..6ccb12de 100644 --- a/backend/src/competitions/competitions.service.ts +++ b/backend/src/competitions/competitions.service.ts @@ -1,16 +1,22 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, SelectQueryBuilder } from 'typeorm'; import { Competition, CompetitionVisibility, } from './entities/competition.entity'; +import { CompetitionParticipant } from './entities/competition-participant.entity'; import { CreateCompetitionDto } from './dto/create-competition.dto'; import { ListCompetitionsDto, CompetitionStatus, PaginatedCompetitionsResponse, } from './dto/list-competitions.dto'; +import { + ListParticipantsQueryDto, + ParticipantItem, + PaginatedParticipantsResponse, +} from './dto/list-participants.dto'; import { User } from '../users/entities/user.entity'; @Injectable() @@ -18,6 +24,8 @@ export class CompetitionsService { constructor( @InjectRepository(Competition) private readonly competitionsRepository: Repository, + @InjectRepository(CompetitionParticipant) + private readonly participantsRepository: Repository, ) {} async create(dto: CreateCompetitionDto, user: User): Promise { @@ -139,6 +147,47 @@ export class CompetitionsService { return competition.end_time.getTime() - now.getTime(); // Time until end } + async getParticipants( + competitionId: string, + dto: ListParticipantsQueryDto, + ): Promise { + const competition = await this.competitionsRepository.findOne({ + where: { id: competitionId }, + }); + + if (!competition) { + throw new NotFoundException( + `Competition with ID "${competitionId}" not found`, + ); + } + + const page = dto.page ?? 1; + const limit = Math.min(dto.limit ?? 20, 50); + const skip = (page - 1) * limit; + + const [participants, total] = await this.participantsRepository + .createQueryBuilder('participant') + .leftJoinAndSelect('participant.user', 'user') + .where('participant.competition_id = :competitionId', { competitionId }) + .orderBy('participant.score', 'DESC') + .addOrderBy('participant.joined_at', 'ASC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + const data: ParticipantItem[] = participants.map((p, index) => ({ + id: p.id, + user_id: p.user_id, + username: p.user?.username ?? null, + stellar_address: p.user?.stellar_address ?? '', + score: p.score, + rank: p.rank ?? skip + index + 1, + joined_at: p.joined_at, + })); + + return { data, total, page, limit }; + } + async findById(id: string): Promise { return this.competitionsRepository.findOne({ where: { id }, diff --git a/backend/src/competitions/dto/list-participants.dto.ts b/backend/src/competitions/dto/list-participants.dto.ts new file mode 100644 index 00000000..c241cef9 --- /dev/null +++ b/backend/src/competitions/dto/list-participants.dto.ts @@ -0,0 +1,61 @@ +import { IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ListParticipantsQueryDto { + @ApiPropertyOptional({ description: 'Page number', default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: 'Items per page (max 50)', + default: 20, + maximum: 50, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number = 20; +} + +export class ParticipantItem { + @ApiProperty() + id: string; + + @ApiProperty() + user_id: string; + + @ApiProperty({ nullable: true }) + username: string | null; + + @ApiProperty() + stellar_address: string; + + @ApiProperty() + score: number; + + @ApiProperty({ nullable: true }) + rank: number | null; + + @ApiProperty() + joined_at: Date; +} + +export class PaginatedParticipantsResponse { + @ApiProperty({ type: [ParticipantItem] }) + data: ParticipantItem[]; + + @ApiProperty() + total: number; + + @ApiProperty() + page: number; + + @ApiProperty() + limit: number; +} diff --git a/backend/src/markets/dto/trending-markets.dto.ts b/backend/src/markets/dto/trending-markets.dto.ts new file mode 100644 index 00000000..40784c3e --- /dev/null +++ b/backend/src/markets/dto/trending-markets.dto.ts @@ -0,0 +1,73 @@ +import { IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class TrendingMarketsQueryDto { + @ApiPropertyOptional({ description: 'Page number', default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: 'Items per page (max 50)', + default: 20, + maximum: 50, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number = 20; +} + +export class TrendingMarketItem { + @ApiProperty() + id: string; + + @ApiProperty() + title: string; + + @ApiProperty() + description: string; + + @ApiProperty() + category: string; + + @ApiProperty() + outcome_options: string[]; + + @ApiProperty() + end_time: Date; + + @ApiProperty() + is_resolved: boolean; + + @ApiProperty() + participant_count: number; + + @ApiProperty() + total_pool_stroops: string; + + @ApiProperty() + trending_score: number; + + @ApiProperty() + created_at: Date; +} + +export class PaginatedTrendingMarketsResponse { + @ApiProperty({ type: [TrendingMarketItem] }) + data: TrendingMarketItem[]; + + @ApiProperty() + total: number; + + @ApiProperty() + page: number; + + @ApiProperty() + limit: number; +} diff --git a/backend/src/markets/markets.controller.ts b/backend/src/markets/markets.controller.ts index 8aec3687..a0a2e746 100644 --- a/backend/src/markets/markets.controller.ts +++ b/backend/src/markets/markets.controller.ts @@ -30,6 +30,10 @@ import { ListMarketsDto, PaginatedMarketsResponse, } from './dto/list-markets.dto'; +import { + TrendingMarketsQueryDto, + PaginatedTrendingMarketsResponse, +} from './dto/trending-markets.dto'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { Public } from '../common/decorators/public.decorator'; import { Roles } from '../common/decorators/roles.decorator'; @@ -53,6 +57,19 @@ export class MarketsController { return this.marketsService.getTemplates(); } + @Get('trending') + @Public() + @ApiOperation({ summary: 'Get trending/popular markets' }) + @ApiResponse({ + status: 200, + description: 'Paginated trending markets sorted by trending score', + }) + async getTrendingMarkets( + @Query() query: TrendingMarketsQueryDto, + ): Promise { + return this.marketsService.getTrendingMarkets(query); + } + @Get(':id/predictions') @Public() @ApiOperation({ summary: 'Get prediction statistics for a market' }) diff --git a/backend/src/markets/markets.service.spec.ts b/backend/src/markets/markets.service.spec.ts index 713d5899..ee3f63fd 100644 --- a/backend/src/markets/markets.service.spec.ts +++ b/backend/src/markets/markets.service.spec.ts @@ -169,4 +169,85 @@ describe('MarketsService', () => { ); expect(sorobanService.resolveMarket).not.toHaveBeenCalled(); }); + + describe('getTrendingMarkets', () => { + it('should return trending markets sorted by trending score', async () => { + const now = new Date(); + const markets = [ + { + id: 'market-1', + title: 'Low activity market', + description: 'desc', + category: 'Crypto', + outcome_options: ['Yes', 'No'], + end_time: new Date(now.getTime() + 48 * 60 * 60 * 1000), + is_resolved: false, + is_cancelled: false, + participant_count: 2, + total_pool_stroops: '1000000', + created_at: now, + }, + { + id: 'market-2', + title: 'High activity market', + description: 'desc', + category: 'Sports', + outcome_options: ['Team A', 'Team B'], + end_time: new Date(now.getTime() + 12 * 60 * 60 * 1000), + is_resolved: false, + is_cancelled: false, + participant_count: 50, + total_pool_stroops: '50000000', + created_at: now, + }, + ] as Market[]; + + marketsRepository.find.mockResolvedValue(markets); + + const result = await service.getTrendingMarkets({ page: 1, limit: 20 }); + + expect(result.data.length).toBe(2); + expect(result.data[0].id).toBe('market-2'); + expect(result.data[0].trending_score).toBeGreaterThan( + result.data[1].trending_score, + ); + expect(result.total).toBe(2); + }); + + it('should support pagination', async () => { + const now = new Date(); + const markets = Array.from({ length: 5 }, (_, i) => ({ + id: `market-${i}`, + title: `Market ${i}`, + description: 'desc', + category: 'Crypto', + outcome_options: ['Yes', 'No'], + end_time: new Date(now.getTime() + 24 * 60 * 60 * 1000), + is_resolved: false, + is_cancelled: false, + participant_count: i * 10, + total_pool_stroops: String(i * 10000000), + created_at: now, + })) as Market[]; + + marketsRepository.find.mockResolvedValue(markets); + + const result = await service.getTrendingMarkets({ page: 1, limit: 2 }); + + expect(result.data.length).toBe(2); + expect(result.total).toBe(5); + expect(result.page).toBe(1); + expect(result.limit).toBe(2); + }); + + it('should use cached results within TTL', async () => { + marketsRepository.find.mockResolvedValue([]); + + await service.getTrendingMarkets({ page: 1, limit: 20 }); + await service.getTrendingMarkets({ page: 1, limit: 20 }); + + // find should only be called once due to caching + expect(marketsRepository.find).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/backend/src/markets/markets.service.ts b/backend/src/markets/markets.service.ts index ce6742ef..cb289570 100644 --- a/backend/src/markets/markets.service.ts +++ b/backend/src/markets/markets.service.ts @@ -21,11 +21,21 @@ import { MarketStatus, PaginatedMarketsResponse, } from './dto/list-markets.dto'; +import { + TrendingMarketsQueryDto, + TrendingMarketItem, + PaginatedTrendingMarketsResponse, +} from './dto/trending-markets.dto'; import { SorobanService } from '../soroban/soroban.service'; @Injectable() export class MarketsService { private readonly logger = new Logger(MarketsService.name); + private trendingCache: { + data: TrendingMarketItem[]; + cachedAt: number; + } | null = null; + private readonly TRENDING_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes constructor( @InjectRepository(Market) @@ -230,6 +240,93 @@ export class MarketsService { return this.marketsRepository.save(market); } + /** + * Get trending markets based on recent prediction volume, + * participant growth rate, and time to resolution. + * Results are cached for 15 minutes. + */ + async getTrendingMarkets( + dto: TrendingMarketsQueryDto, + ): Promise { + const page = dto.page ?? 1; + const limit = Math.min(dto.limit ?? 20, 50); + + const allTrending = await this.computeTrendingMarkets(); + const start = (page - 1) * limit; + const data = allTrending.slice(start, start + limit); + + return { data, total: allTrending.length, page, limit }; + } + + private async computeTrendingMarkets(): Promise { + const now = Date.now(); + if ( + this.trendingCache && + now - this.trendingCache.cachedAt < this.TRENDING_CACHE_TTL_MS + ) { + return this.trendingCache.data; + } + + // Fetch open (non-resolved, non-cancelled) markets + const markets = await this.marketsRepository.find({ + where: { is_resolved: false, is_cancelled: false }, + order: { created_at: 'DESC' }, + take: 200, + }); + + const scored: TrendingMarketItem[] = markets.map((market) => { + const trending_score = this.calculateTrendingScore(market); + return { + id: market.id, + title: market.title, + description: market.description, + category: market.category, + outcome_options: market.outcome_options, + end_time: market.end_time, + is_resolved: market.is_resolved, + participant_count: market.participant_count, + total_pool_stroops: market.total_pool_stroops, + trending_score, + created_at: market.created_at, + }; + }); + + scored.sort((a, b) => b.trending_score - a.trending_score); + + this.trendingCache = { data: scored, cachedAt: now }; + this.logger.log( + `Trending markets cache refreshed with ${scored.length} markets`, + ); + + return scored; + } + + /** + * Calculate trending score based on: + * - Recent prediction volume (participant_count weight: 50%) + * - Pool size / activity indicator (total_pool weight: 30%) + * - Time to resolution - markets closing soon rank higher (20%) + */ + private calculateTrendingScore(market: Market): number { + const now = Date.now(); + + // Participant count score (normalized, capped at 100 participants) + const participantScore = Math.min(market.participant_count / 100, 1) * 50; + + // Pool size score (normalized, capped at 100M stroops = 10 XLM) + const poolSize = Number(BigInt(market.total_pool_stroops)); + const poolScore = Math.min(poolSize / 100_000_000, 1) * 30; + + // Time to resolution score - markets ending sooner rank higher + const endTime = new Date(market.end_time).getTime(); + const hoursUntilEnd = Math.max((endTime - now) / (1000 * 60 * 60), 0); + // Markets ending within 24h get highest score, decaying over 7 days + const timeScore = + hoursUntilEnd <= 168 ? ((168 - hoursUntilEnd) / 168) * 20 : 0; + + return Math.round((participantScore + poolScore + timeScore) * 100) / 100; + } + /** * List markets with pagination, filtering, and keyword search. */ diff --git a/backend/src/migrations/1775000000000-AddPredictionNoteColumn.ts b/backend/src/migrations/1775000000000-AddPredictionNoteColumn.ts new file mode 100644 index 00000000..7b674898 --- /dev/null +++ b/backend/src/migrations/1775000000000-AddPredictionNoteColumn.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPredictionNoteColumn1775000000000 implements MigrationInterface { + name = 'AddPredictionNoteColumn1775000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "predictions" ADD "note" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "predictions" DROP COLUMN "note"`); + } +} diff --git a/backend/src/predictions/dto/list-my-predictions.dto.ts b/backend/src/predictions/dto/list-my-predictions.dto.ts index f0075ffb..45f68e4e 100644 --- a/backend/src/predictions/dto/list-my-predictions.dto.ts +++ b/backend/src/predictions/dto/list-my-predictions.dto.ts @@ -46,6 +46,7 @@ export interface PredictionWithStatus { payout_claimed: boolean; payout_amount_stroops: string; tx_hash: string | null; + note: string | null; submitted_at: Date; status: PredictionStatus; market: { diff --git a/backend/src/predictions/dto/update-prediction-note.dto.ts b/backend/src/predictions/dto/update-prediction-note.dto.ts new file mode 100644 index 00000000..7467b092 --- /dev/null +++ b/backend/src/predictions/dto/update-prediction-note.dto.ts @@ -0,0 +1,13 @@ +import { IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdatePredictionNoteDto { + @ApiProperty({ + description: 'Personal note for the prediction', + example: 'I think this outcome is likely based on recent trends', + maxLength: 1000, + }) + @IsString() + @MaxLength(1000) + note: string; +} diff --git a/backend/src/predictions/entities/prediction.entity.ts b/backend/src/predictions/entities/prediction.entity.ts index 84ad640f..6a086705 100644 --- a/backend/src/predictions/entities/prediction.entity.ts +++ b/backend/src/predictions/entities/prediction.entity.ts @@ -42,6 +42,9 @@ export class Prediction { @Column({ nullable: true }) tx_hash: string; + @Column({ type: 'text', nullable: true }) + note: string | null; + @CreateDateColumn() submitted_at: Date; } diff --git a/backend/src/predictions/predictions.controller.ts b/backend/src/predictions/predictions.controller.ts index 0fecaa83..156892df 100644 --- a/backend/src/predictions/predictions.controller.ts +++ b/backend/src/predictions/predictions.controller.ts @@ -2,11 +2,14 @@ import { Controller, Post, Get, + Patch, + Param, Body, Query, HttpCode, HttpStatus, UseGuards, + ParseUUIDPipe, } from '@nestjs/common'; import { BanGuard } from '../common/guards/ban.guard'; import { @@ -17,6 +20,7 @@ import { } from '@nestjs/swagger'; import { PredictionsService } from './predictions.service'; import { SubmitPredictionDto } from './dto/submit-prediction.dto'; +import { UpdatePredictionNoteDto } from './dto/update-prediction-note.dto'; import { ListMyPredictionsDto, PaginatedMyPredictionsResponse, @@ -65,4 +69,24 @@ export class PredictionsController { ): Promise { return this.predictionsService.findMine(user, query); } + + @Patch(':id/note') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Update personal note on a prediction' }) + @ApiResponse({ + status: 200, + description: 'Prediction note updated', + type: Prediction, + }) + @ApiResponse({ + status: 404, + description: 'Prediction not found or not owned by user', + }) + async updateNote( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePredictionNoteDto, + @CurrentUser() user: User, + ): Promise { + return this.predictionsService.updateNote(id, dto, user); + } } diff --git a/backend/src/predictions/predictions.service.spec.ts b/backend/src/predictions/predictions.service.spec.ts index 28fc28dd..e375d1f1 100644 --- a/backend/src/predictions/predictions.service.spec.ts +++ b/backend/src/predictions/predictions.service.spec.ts @@ -336,4 +336,44 @@ describe('PredictionsService', () => { ); }); }); + + describe('updateNote', () => { + it('should update the note on a prediction owned by the user', async () => { + const user = makeUser(); + const market = makeMarket(); + const prediction = { + id: 'pred-1', + user, + market, + chosen_outcome: 'Yes', + note: null, + } as unknown as Prediction; + + mockPredictionsRepo.findOne.mockResolvedValue(prediction); + mockPredictionsRepo.save.mockResolvedValue({ + ...prediction, + note: 'My analysis note', + } as Prediction); + + const result = await service.updateNote( + 'pred-1', + { note: 'My analysis note' }, + user, + ); + + expect(result.note).toBe('My analysis note'); + expect(mockPredictionsRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'pred-1', user: { id: user.id } }, + relations: ['market'], + }); + }); + + it('should throw NotFoundException if prediction is not found or not owned', async () => { + mockPredictionsRepo.findOne.mockResolvedValue(null); + + await expect( + service.updateNote('non-existent', { note: 'Some note' }, makeUser()), + ).rejects.toThrow(NotFoundException); + }); + }); }); diff --git a/backend/src/predictions/predictions.service.ts b/backend/src/predictions/predictions.service.ts index 4dc6bc44..4463b66b 100644 --- a/backend/src/predictions/predictions.service.ts +++ b/backend/src/predictions/predictions.service.ts @@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { Prediction } from './entities/prediction.entity'; import { SubmitPredictionDto } from './dto/submit-prediction.dto'; +import { UpdatePredictionNoteDto } from './dto/update-prediction-note.dto'; import { ListMyPredictionsDto, PredictionStatus, @@ -165,6 +166,7 @@ export class PredictionsService { payout_claimed: prediction.payout_claimed, payout_amount_stroops: prediction.payout_amount_stroops, tx_hash: prediction.tx_hash ?? null, + note: prediction.note ?? null, submitted_at: prediction.submitted_at, status, market: { @@ -190,6 +192,28 @@ export class PredictionsService { return PredictionStatus.Lost; } + /** + * Update the personal note on a prediction. + * Only the prediction owner can update their note. + */ + async updateNote( + predictionId: string, + dto: UpdatePredictionNoteDto, + user: User, + ): Promise { + const prediction = await this.predictionsRepository.findOne({ + where: { id: predictionId, user: { id: user.id } }, + relations: ['market'], + }); + + if (!prediction) { + throw new NotFoundException(`Prediction "${predictionId}" not found`); + } + + prediction.note = dto.note; + return this.predictionsRepository.save(prediction); + } + /** * Claim the payout for a winning prediction. * Validates that the market is resolved, the user won, and hasn't already claimed.