diff --git a/backend/src/contract/contract.service.ts b/backend/src/contract/contract.service.ts index 8b41d6e0..e1ff73f6 100644 --- a/backend/src/contract/contract.service.ts +++ b/backend/src/contract/contract.service.ts @@ -12,6 +12,14 @@ import { xdr, } from '@stellar/stellar-sdk'; +export interface RewardDistribution { + rank1: number; + rank2: number; + rank3: number; + rank4?: number; + rank5?: number; +} + export interface ContractEvent { eventId: string; inviteCode: string; diff --git a/backend/src/creator-events/creator-events-predictions-stats.spec.ts b/backend/src/creator-events/creator-events-predictions-stats.spec.ts index 8586dc5f..d4c9a112 100644 --- a/backend/src/creator-events/creator-events-predictions-stats.spec.ts +++ b/backend/src/creator-events/creator-events-predictions-stats.spec.ts @@ -34,6 +34,17 @@ describe('CreatorEventsService predictions and stats', () => { maxParticipants: 100, participantCount: 3, isActive: true, + prizePool: '5000000000', + entryFee: '100000000', + category: 'Sports', + bannerUrl: 'https://example.com/banner.jpg', + rewardDistribution: { + rank1: 40, + rank2: 30, + rank3: 20, + rank4: 5, + rank5: 5, + }, }; const mockMatches = [ @@ -42,6 +53,8 @@ describe('CreatorEventsService predictions and stats', () => { eventId: '1', homeTeam: 'Alpha', awayTeam: 'Beta', + homeScore: 1, + awayScore: 0, startTime: 1_100_000, resolved: true, outcome: 'TEAM_A', @@ -51,6 +64,8 @@ describe('CreatorEventsService predictions and stats', () => { eventId: '1', homeTeam: 'Gamma', awayTeam: 'Delta', + homeScore: null, + awayScore: null, startTime: 1_200_000, resolved: false, outcome: null, @@ -170,6 +185,14 @@ describe('CreatorEventsService predictions and stats', () => { expect(result.averagePredictionsPerUser).toBe(1.67); expect(result.completionRate).toBe(67); expect(result.winnersVerified).toBe(false); + expect(result.prizePool).toBe('5000000000'); + expect(result.rewardDistribution).toEqual({ + rank1: 40, + rank2: 30, + rank3: 20, + rank4: 5, + rank5: 5, + }); }); it('throws when event is not found', async () => { @@ -179,5 +202,35 @@ describe('CreatorEventsService predictions and stats', () => { NotFoundException, ); }); + + it('includes prizePool and rewardDistribution in stats response', async () => { + contractService.getEvent.mockResolvedValue(mockEvent); + contractService.getEventStatistics.mockResolvedValue({ + eventId: '1', + participantCount: 1, + matchCount: 1, + totalPredictions: 1, + allMatchesResolved: false, + winnersVerified: false, + winnerCount: 0, + }); + contractService.getEventParticipants.mockResolvedValue([]); + contractService.getPredictionDistribution.mockResolvedValue({ + teamA: 0, + teamB: 0, + draw: 0, + }); + + const result = await service.getEventStats('1'); + + expect(result.prizePool).toBe('5000000000'); + expect(result.rewardDistribution).toEqual({ + rank1: 40, + rank2: 30, + rank3: 20, + rank4: 5, + rank5: 5, + }); + }); }); }); diff --git a/backend/src/creator-events/creator-events.controller.spec.ts b/backend/src/creator-events/creator-events.controller.spec.ts index 84f47bb4..b6d06acb 100644 --- a/backend/src/creator-events/creator-events.controller.spec.ts +++ b/backend/src/creator-events/creator-events.controller.spec.ts @@ -66,6 +66,8 @@ describe('CreatorEventsController', () => { eventId: 'event-1', homeTeam: 'Team A', awayTeam: 'Team B', + homeScore: 0, + awayScore: 0, startTime: 1100000, resolved: false, outcome: null, @@ -218,6 +220,17 @@ describe('PublicCreatorEventsController', () => { matchPreview: [], startTime: 1000000, endTime: 2000000, + prizePool: '5000000000', + entryFee: '100000000', + category: 'Sports', + bannerUrl: 'https://example.com/banner.jpg', + rewardDistribution: { + rank1: 40, + rank2: 30, + rank3: 20, + rank4: 5, + rank5: 5, + }, }; service.getEventByInviteCode.mockResolvedValue(mockEvent); @@ -247,6 +260,17 @@ describe('PublicCreatorEventsController', () => { ], startTime: 1000000, endTime: 2000000, + prizePool: '5000000000', + entryFee: '100000000', + category: 'Sports', + bannerUrl: 'https://example.com/banner.jpg', + rewardDistribution: { + rank1: 40, + rank2: 30, + rank3: 20, + rank4: 5, + rank5: 5, + }, }; service.getEventByInviteCode.mockResolvedValue(mockEvent); diff --git a/backend/src/creator-events/creator-events.service.spec.ts b/backend/src/creator-events/creator-events.service.spec.ts index 82dc7a12..cbb9d75a 100644 --- a/backend/src/creator-events/creator-events.service.spec.ts +++ b/backend/src/creator-events/creator-events.service.spec.ts @@ -174,10 +174,14 @@ describe('CreatorEventsService searchEvents', () => { await service.searchEvents({ q: 'league', status: CreatorEventSearchStatus.Cancelled, + page: 1, + limit: 20, }); await service.searchEvents({ q: 'league', status: CreatorEventSearchStatus.Inactive, + page: 1, + limit: 20, }); expect(queryBuilder.andWhere).toHaveBeenCalledWith( diff --git a/backend/src/creator-events/creator-events.service.ts b/backend/src/creator-events/creator-events.service.ts index bca46b2d..afc05bea 100644 --- a/backend/src/creator-events/creator-events.service.ts +++ b/backend/src/creator-events/creator-events.service.ts @@ -46,8 +46,8 @@ import { normalizeContractPrediction, resolveCorrectness, } from './utils/prediction.util'; +import { toRewardDistributionDto } from './utils/reward-distribution.util'; -// Type definitions - exported for use in controllers export interface ParticipantWithStats { address: string; joinedAt: number; @@ -319,6 +319,11 @@ export class CreatorEventsService { matchPreview, startTime: event.startTime, endTime: event.endTime, + prizePool: event.prizePool, + entryFee: event.entryFee, + category: event.category, + bannerUrl: event.bannerUrl, + rewardDistribution: toRewardDistributionDto(event.rewardDistribution), }; } @@ -470,6 +475,8 @@ export class CreatorEventsService { winnerCount: statistics?.winnerCount ?? 0, averagePredictionsPerUser, completionRate, + prizePool: event.prizePool, + rewardDistribution: toRewardDistributionDto(event.rewardDistribution), }; } @@ -634,6 +641,7 @@ export class CreatorEventsService { match_count: event.match_count, rank: Number(rank ?? 0), highlights: this.buildHighlights(event, searchTerm), + category: (event as CreatorEvent & { category?: string }).category, }; } diff --git a/backend/src/creator-events/dto/event-by-code-response.dto.ts b/backend/src/creator-events/dto/event-by-code-response.dto.ts index bdf79047..36b4e6b1 100644 --- a/backend/src/creator-events/dto/event-by-code-response.dto.ts +++ b/backend/src/creator-events/dto/event-by-code-response.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class MatchPreviewDto { @ApiProperty({ description: 'Match ID' }) @@ -14,6 +14,23 @@ export class MatchPreviewDto { startTime: number; } +export class RewardDistributionDto { + @ApiProperty({ description: 'Rank 1 percentage' }) + rank1: number; + + @ApiProperty({ description: 'Rank 2 percentage' }) + rank2: number; + + @ApiProperty({ description: 'Rank 3 percentage' }) + rank3: number; + + @ApiPropertyOptional({ description: 'Rank 4 percentage' }) + rank4?: number; + + @ApiPropertyOptional({ description: 'Rank 5 percentage' }) + rank5?: number; +} + export class EventByCodeResponseDto { @ApiProperty({ description: 'Event ID' }) eventId: string; @@ -53,4 +70,26 @@ export class EventByCodeResponseDto { @ApiProperty({ description: 'Event end time (Unix timestamp)' }) endTime: number; + + @ApiPropertyOptional({ + description: 'Prize pool amount in stroops (smallest unit)', + }) + prizePool?: string; + + @ApiPropertyOptional({ + description: 'Entry fee amount in stroops (smallest unit)', + }) + entryFee?: string; + + @ApiPropertyOptional({ description: 'Event category' }) + category?: string; + + @ApiPropertyOptional({ description: 'Banner image URL' }) + bannerUrl?: string; + + @ApiPropertyOptional({ + description: 'Reward distribution percentages by rank', + type: RewardDistributionDto, + }) + rewardDistribution?: RewardDistributionDto; } diff --git a/backend/src/creator-events/dto/event-response.dto.ts b/backend/src/creator-events/dto/event-response.dto.ts index 4ace60fd..3b7896f0 100644 --- a/backend/src/creator-events/dto/event-response.dto.ts +++ b/backend/src/creator-events/dto/event-response.dto.ts @@ -1,5 +1,22 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +export class RewardDistributionDto { + @ApiProperty({ description: 'Rank 1 percentage' }) + rank1: number; + + @ApiProperty({ description: 'Rank 2 percentage' }) + rank2: number; + + @ApiProperty({ description: 'Rank 3 percentage' }) + rank3: number; + + @ApiPropertyOptional({ description: 'Rank 4 percentage' }) + rank4?: number; + + @ApiPropertyOptional({ description: 'Rank 5 percentage' }) + rank5?: number; +} + export class EventResponseDto { @ApiProperty({ description: 'Event ID' }) eventId: string; @@ -34,6 +51,28 @@ export class EventResponseDto { @ApiProperty({ description: 'Is event active' }) isActive: boolean; + @ApiPropertyOptional({ + description: 'Prize pool amount in stroops (smallest unit)', + }) + prizePool?: string; + + @ApiPropertyOptional({ + description: 'Entry fee amount in stroops (smallest unit)', + }) + entryFee?: string; + + @ApiPropertyOptional({ description: 'Event category' }) + category?: string; + + @ApiPropertyOptional({ description: 'Banner image URL' }) + bannerUrl?: string; + + @ApiPropertyOptional({ + description: 'Reward distribution percentages by rank', + type: RewardDistributionDto, + }) + rewardDistribution?: RewardDistributionDto; + @ApiPropertyOptional({ description: 'Number of winners' }) winnerCount?: number; diff --git a/backend/src/creator-events/dto/event-stats-response.dto.ts b/backend/src/creator-events/dto/event-stats-response.dto.ts index 7897173f..c04ff9b2 100644 --- a/backend/src/creator-events/dto/event-stats-response.dto.ts +++ b/backend/src/creator-events/dto/event-stats-response.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class MatchPredictionDistributionDto { @ApiProperty({ description: 'Match identifier' }) @@ -23,6 +23,23 @@ export class MatchPredictionDistributionDto { total: number; } +export class RewardDistributionDto { + @ApiProperty({ description: 'Rank 1 percentage' }) + rank1: number; + + @ApiProperty({ description: 'Rank 2 percentage' }) + rank2: number; + + @ApiProperty({ description: 'Rank 3 percentage' }) + rank3: number; + + @ApiPropertyOptional({ description: 'Rank 4 percentage' }) + rank4?: number; + + @ApiPropertyOptional({ description: 'Rank 5 percentage' }) + rank5?: number; +} + export class EventStatsResponseDto { @ApiProperty({ description: 'Event identifier' }) eventId: string; @@ -65,4 +82,15 @@ export class EventStatsResponseDto { description: 'Percentage of participants who predicted all matches (0-100)', }) completionRate: number; + + @ApiPropertyOptional({ + description: 'Prize pool amount in stroops (smallest unit)', + }) + prizePool?: string; + + @ApiPropertyOptional({ + description: 'Reward distribution percentages by rank', + type: RewardDistributionDto, + }) + rewardDistribution?: RewardDistributionDto; } diff --git a/backend/src/creator-events/dto/search-events-query.dto.ts b/backend/src/creator-events/dto/search-events-query.dto.ts index 72525984..7dad0d49 100644 --- a/backend/src/creator-events/dto/search-events-query.dto.ts +++ b/backend/src/creator-events/dto/search-events-query.dto.ts @@ -48,4 +48,12 @@ export class SearchEventsQueryDto { @IsOptional() @IsString() creator?: string; + + @ApiPropertyOptional({ + description: 'Filter results by event category.', + example: 'football', + }) + @IsOptional() + @IsString() + category?: string; } diff --git a/backend/src/creator-events/dto/search-events-response.dto.ts b/backend/src/creator-events/dto/search-events-response.dto.ts index 9b160348..24d25d10 100644 --- a/backend/src/creator-events/dto/search-events-response.dto.ts +++ b/backend/src/creator-events/dto/search-events-response.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class SearchHighlightsDto { @ApiProperty({ required: false }) @@ -44,6 +44,9 @@ export class SearchEventResultDto { @ApiProperty({ type: SearchHighlightsDto }) highlights: SearchHighlightsDto; + + @ApiPropertyOptional({ description: 'Event category' }) + category?: string; } export class SearchEventsResponseDto { diff --git a/backend/src/creator-events/utils/reward-distribution.util.spec.ts b/backend/src/creator-events/utils/reward-distribution.util.spec.ts new file mode 100644 index 00000000..358f06aa --- /dev/null +++ b/backend/src/creator-events/utils/reward-distribution.util.spec.ts @@ -0,0 +1,109 @@ +import { + validateRewardDistribution, + toRewardDistributionDto, +} from './reward-distribution.util'; +import { RewardDistribution } from '../../contract/contract.service'; + +describe('Reward Distribution Utility', () => { + describe('validateRewardDistribution', () => { + it('returns valid for null/undefined distribution', () => { + expect(validateRewardDistribution(null).valid).toBe(true); + expect(validateRewardDistribution(undefined).valid).toBe(true); + }); + + it('returns valid for distribution summing to 100%', () => { + const distribution: RewardDistribution = { + rank1: 40, + rank2: 30, + rank3: 20, + rank4: 5, + rank5: 5, + }; + expect(validateRewardDistribution(distribution).valid).toBe(true); + }); + + it('returns valid for distribution with only 3 ranks summing to 100%', () => { + const distribution: RewardDistribution = { + rank1: 50, + rank2: 30, + rank3: 20, + }; + expect(validateRewardDistribution(distribution).valid).toBe(true); + }); + + it('returns invalid for distribution not summing to 100%', () => { + const distribution: RewardDistribution = { + rank1: 30, + rank2: 30, + rank3: 20, + }; + const result = validateRewardDistribution(distribution); + expect(result.valid).toBe(false); + expect(result.error).toContain('must sum to 100%'); + }); + + it('returns invalid for distribution with negative values', () => { + const distribution: RewardDistribution = { + rank1: 50, + rank2: -10, + rank3: 60, + }; + const result = validateRewardDistribution(distribution); + expect(result.valid).toBe(false); + expect(result.error).toContain('cannot be negative'); + }); + }); + + describe('toRewardDistributionDto', () => { + it('returns undefined for null/undefined distribution', () => { + expect(toRewardDistributionDto(null)).toBeUndefined(); + expect(toRewardDistributionDto(undefined)).toBeUndefined(); + }); + + it('converts full distribution with all ranks', () => { + const distribution: RewardDistribution = { + rank1: 40, + rank2: 30, + rank3: 20, + rank4: 5, + rank5: 5, + }; + const result = toRewardDistributionDto(distribution); + expect(result).toEqual({ + rank1: 40, + rank2: 30, + rank3: 20, + rank4: 5, + rank5: 5, + }); + }); + + it('omits optional fields when not provided', () => { + const distribution: RewardDistribution = { + rank1: 50, + rank2: 30, + rank3: 20, + }; + const result = toRewardDistributionDto(distribution); + expect(result).toEqual({ + rank1: 50, + rank2: 30, + rank3: 20, + }); + expect(result?.rank4).toBeUndefined(); + expect(result?.rank5).toBeUndefined(); + }); + + it('includes rank4 when provided', () => { + const distribution: RewardDistribution = { + rank1: 50, + rank2: 30, + rank3: 15, + rank4: 5, + }; + const result = toRewardDistributionDto(distribution); + expect(result?.rank4).toBe(5); + expect(result?.rank5).toBeUndefined(); + }); + }); +}); diff --git a/backend/src/creator-events/utils/reward-distribution.util.ts b/backend/src/creator-events/utils/reward-distribution.util.ts new file mode 100644 index 00000000..a85e12f1 --- /dev/null +++ b/backend/src/creator-events/utils/reward-distribution.util.ts @@ -0,0 +1,68 @@ +import { RewardDistribution } from '../../contract/contract.service'; + +export function validateRewardDistribution( + distribution: RewardDistribution | undefined | null, +): { valid: boolean; error?: string } { + if (!distribution) { + return { valid: true }; + } + + const total = + (distribution.rank1 ?? 0) + + (distribution.rank2 ?? 0) + + (distribution.rank3 ?? 0) + + (distribution.rank4 ?? 0) + + (distribution.rank5 ?? 0); + + if (total !== 100) { + return { + valid: false, + error: `Reward distribution must sum to 100%, got ${total}%`, + }; + } + + const hasNegative = Object.values(distribution).some((v) => (v ?? 0) < 0); + if (hasNegative) { + return { + valid: false, + error: 'Reward distribution percentages cannot be negative', + }; + } + + return { valid: true }; +} + +export function toRewardDistributionDto( + distribution: RewardDistribution | undefined | null, +): + | { + rank1: number; + rank2: number; + rank3: number; + rank4?: number; + rank5?: number; + } + | undefined { + if (!distribution) return undefined; + + const dto: { + rank1: number; + rank2: number; + rank3: number; + rank4?: number; + rank5?: number; + } = { + rank1: distribution.rank1 ?? 0, + rank2: distribution.rank2 ?? 0, + rank3: distribution.rank3 ?? 0, + }; + + if (distribution.rank4 !== undefined && distribution.rank4 !== null) { + dto.rank4 = distribution.rank4; + } + if (distribution.rank5 !== undefined && distribution.rank5 !== null) { + dto.rank5 = distribution.rank5; + } + + return dto; +}