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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '../contract/contract.service';
import { CreatorEvent } from '../matches/entities/creator-event.entity';
import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import { CreatorEventsService } from './creator-events.service';

describe('CreatorEventsService predictions and stats', () => {
Expand Down Expand Up @@ -85,6 +86,10 @@ describe('CreatorEventsService predictions and stats', () => {
provide: getRepositoryToken(CreatorEventLeaderboardEntry),
useValue: {},
},
{
provide: getRepositoryToken(CreatorEventPayout),
useValue: {},
},
],
}).compile();

Expand Down
57 changes: 57 additions & 0 deletions backend/src/creator-events/creator-events.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import { SearchEventsResponseDto } from './dto/search-events-response.dto';
import { UserScoreResponseDto } from './dto/user-score-response.dto';
import { UserPredictionsResponseDto } from './dto/user-predictions-response.dto';
import { EventStatsResponseDto } from './dto/event-stats-response.dto';
import {
PaginatedPayoutsDto,
PayoutEntryDto,
PayoutsQueryDto,
} from './dto/payouts.dto';

@ApiTags('creator-events')
@Controller('creator-events')
Expand Down Expand Up @@ -199,6 +204,58 @@ export class CreatorEventsController {
): Promise<UserScoreResponseDto> {
return this.creatorEventsService.getUserScore(id, address);
}

/**
* GET /api/creator-events/:id/payouts
* #958 — Paginated list of all prize payouts for a finalized event,
* ordered by rank ascending. Returns 404 when the event does not exist
* or has not been finalized yet.
*/
@Get(':id/payouts')
@UseInterceptors(CacheInterceptor)
@CacheTTL(60) // 1 minute — payouts are immutable once created
@ApiOperation({ summary: 'List all prize payouts for a finalized event' })
@ApiQuery({ name: 'page', required: false, example: 1 })
@ApiQuery({ name: 'limit', required: false, example: 20 })
@ApiResponse({
status: 200,
description: 'Paginated payout list ordered by rank',
type: PaginatedPayoutsDto,
})
@ApiResponse({ status: 404, description: 'Event not found or not finalized' })
getPayouts(
@Param('id') id: string,
@Query(new ValidationPipe({ transform: true, whitelist: true }))
query: PayoutsQueryDto,
): Promise<PaginatedPayoutsDto> {
return this.creatorEventsService.getPayouts(id, query);
}

/**
* GET /api/creator-events/:id/payouts/:address
* #958 — Returns the payout record for a single address.
* Returns 404 when the address did not participate or the event has not
* been finalized.
*/
@Get(':id/payouts/:address')
@UseInterceptors(CacheInterceptor)
@CacheTTL(60)
@ApiOperation({ summary: 'Get prize payout for a specific address' })
@ApiResponse({
status: 200,
description: 'Payout record for the address',
type: PayoutEntryDto,
})
@ApiResponse({
status: 404,
description: 'Address has no payout in this event',
})
getPayoutByAddress(
@Param('id') id: string,
@Param('address') address: string,
): Promise<PayoutEntryDto> {
return this.creatorEventsService.getPayoutByAddress(id, address);
}
}

@ApiTags('creator-events')
Expand Down
7 changes: 6 additions & 1 deletion backend/src/creator-events/creator-events.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ContractModule } from '../contract/contract.module';
import { CreatorEvent } from '../matches/entities/creator-event.entity';
import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import {
AdminCreatorEventsController,
CreatorEventsController,
Expand All @@ -14,7 +15,11 @@ import { CreatorEventsService } from './creator-events.service';
@Module({
imports: [
ContractModule,
TypeOrmModule.forFeature([CreatorEvent, CreatorEventLeaderboardEntry]),
TypeOrmModule.forFeature([
CreatorEvent,
CreatorEventLeaderboardEntry,
CreatorEventPayout,
]),
CacheModule.register(),
],
controllers: [
Expand Down
5 changes: 5 additions & 0 deletions backend/src/creator-events/creator-events.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Repository, SelectQueryBuilder } from 'typeorm';
import { ContractService } from '../contract/contract.service';
import { CreatorEvent } from '../matches/entities/creator-event.entity';
import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import { CreatorEventsService } from './creator-events.service';
import { CreatorEventSearchStatus } from './dto/search-events-query.dto';

Expand Down Expand Up @@ -94,6 +95,10 @@ describe('CreatorEventsService searchEvents', () => {
provide: getRepositoryToken(CreatorEventLeaderboardEntry),
useValue: {},
},
{
provide: getRepositoryToken(CreatorEventPayout),
useValue: {},
},
],
}).compile();

Expand Down
95 changes: 95 additions & 0 deletions backend/src/creator-events/creator-events.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, Repository } from 'typeorm';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import {
PaginatedPayoutsDto,
PayoutEntryDto,
PayoutsQueryDto,
} from './dto/payouts.dto';
import {
ContractService,
ContractEvent,
Expand Down Expand Up @@ -82,6 +88,9 @@ export class CreatorEventsService {
private readonly creatorEventRepository: Repository<CreatorEvent>,
@InjectRepository(CreatorEventLeaderboardEntry)
private readonly leaderboardEntryRepository: Repository<CreatorEventLeaderboardEntry>,

@InjectRepository(CreatorEventPayout)
private readonly creatorEventPayoutRepository: Repository<CreatorEventPayout>,
) {}

async searchEvents(
Expand Down Expand Up @@ -703,6 +712,92 @@ export class CreatorEventsService {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
* GET /creator-events/:id/payouts
*
* Returns all payout records for a finalized event, ordered by rank ASC.
* Raises 404 when the event does not exist or is not yet finalized so the
* frontend knows not to show the payouts UI prematurely.
*
* Time complexity: O(k log n) where k = limit, n = total payout rows.
* The query uses the IDX_cep_event_id index + leaderboard entry JOIN.
*/
async getPayouts(
eventId: string,
query: PayoutsQueryDto,
): Promise<PaginatedPayoutsDto> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;

const cachedEvent = await this.creatorEventRepository.findOne({
where: { on_chain_event_id: Number(eventId) as unknown as number },
select: ['is_finalized'],
});

if (!cachedEvent?.is_finalized) {
throw new NotFoundException(
`Event ${eventId} is not finalized or does not exist`,
);
}

const [payouts, total] = await this.creatorEventPayoutRepository
.createQueryBuilder('payout')
.leftJoinAndSelect('payout.leaderboard_entry', 'entry')
.where('payout.event_id = :eventId', { eventId })
.orderBy('entry.rank', 'ASC')
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();

return {
data: payouts.map((p) => this.toPayoutDto(p)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

/**
* GET /creator-events/:id/payouts/:address
*
* Returns a single payout for the given address.
* Returns 404 when the address has no payout row (i.e. the address did not
* participate, or the event has not been finalized yet).
*
* Time complexity: O(log n) — uses the UQ_cep_event_address composite index.
*/
async getPayoutByAddress(
eventId: string,
address: string,
): Promise<PayoutEntryDto> {
const payout = await this.creatorEventPayoutRepository.findOne({
where: { event_id: eventId, user_address: address },
relations: ['leaderboard_entry'],
});

if (!payout) {
throw new NotFoundException(
`No payout found for address ${address} in event ${eventId}`,
);
}

return this.toPayoutDto(payout);
}

private toPayoutDto(payout: CreatorEventPayout): PayoutEntryDto {
return {
id: payout.id,
event_id: payout.event_id,
user_address: payout.user_address,
payout_amount_stroops: payout.payout_amount_stroops,
is_claimed: payout.is_claimed,
rank: payout.leaderboard_entry?.rank ?? 0,
is_winner: payout.leaderboard_entry?.is_winner ?? false,
created_at: payout.created_at,
};
}

async getLeaderboard(
eventId: string,
query: LeaderboardQueryDto,
Expand Down
69 changes: 69 additions & 0 deletions backend/src/creator-events/dto/payouts.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';

export class PayoutsQueryDto {
@ApiPropertyOptional({ description: 'Page number', default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;

@ApiPropertyOptional({
description: 'Results per page (max 100)',
default: 20,
maximum: 100,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit: number = 20;
}

export class PayoutEntryDto {
@ApiProperty({ description: 'Payout record UUID' })
id: string;

@ApiProperty({ description: 'On-chain event ID (string)' })
event_id: string;

@ApiProperty({ description: 'Stellar address of the participant' })
user_address: string;

@ApiProperty({
description: 'Prize amount in stroops (1 XLM = 10_000_000 stroops)',
})
payout_amount_stroops: string;

@ApiProperty({ description: 'Whether the participant has claimed their prize' })
is_claimed: boolean;

@ApiProperty({ description: 'Final rank in the event leaderboard' })
rank: number;

@ApiProperty({ description: 'True when payout_amount_stroops > 0' })
is_winner: boolean;

@ApiProperty()
created_at: Date;
}

export class PaginatedPayoutsDto {
@ApiProperty({ type: [PayoutEntryDto] })
data: PayoutEntryDto[];

@ApiProperty()
total: number;

@ApiProperty()
page: number;

@ApiProperty()
limit: number;

@ApiProperty()
totalPages: number;
}
4 changes: 4 additions & 0 deletions backend/src/indexer/indexer.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { IndexerController } from './indexer.controller';
import { IndexerHealthController } from './indexer-health.controller';
import { IndexerHealthService } from './health.service';
import { CreatorEvent } from '../matches/entities/creator-event.entity';
import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import { Match } from '../matches/entities/match.entity';
import { MatchPrediction } from '../matches/entities/match-prediction.entity';
import { User } from '../users/entities/user.entity';
Expand All @@ -22,6 +24,8 @@ import { WebsocketModule } from '../websocket/websocket.module';
FeeHistory,
IndexerCheckpoint,
CreatorEvent,
CreatorEventLeaderboardEntry,
CreatorEventPayout,
Match,
MatchPrediction,
User,
Expand Down
19 changes: 19 additions & 0 deletions backend/src/indexer/indexer.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import { FeeHistory } from './entities/fee-history.entity';
import { IndexerCheckpoint } from './entities/indexer-checkpoint.entity';
import { CreatorEvent } from '../matches/entities/creator-event.entity';
import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import { Match } from '../matches/entities/match.entity';
import { MatchPrediction } from '../matches/entities/match-prediction.entity';
import { User } from '../users/entities/user.entity';
Expand Down Expand Up @@ -141,6 +143,22 @@
useValue: matchPredictionRepository,
},
{ provide: getRepositoryToken(User), useValue: userRepository },
{
provide: getRepositoryToken(CreatorEventLeaderboardEntry),
useValue: {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(CreatorEventPayout),
useValue: {
count: jest.fn(),
create: jest.fn(),
save: jest.fn(),
},
},
{
provide: NotificationGeneratorService,
useValue: {
Expand All @@ -163,6 +181,7 @@
broadcastMatchResolved: jest.fn(),
broadcastWinnersVerified: jest.fn(),
broadcastEventCancelled: jest.fn(),
broadcastEventFinalized: jest.fn(),
},
},
],
Expand Down Expand Up @@ -239,7 +258,7 @@
(event: unknown) => event as CreatorEvent,
);
(creatorEventRepository.save as jest.Mock).mockImplementation(
async (event: unknown) => event as CreatorEvent,

Check warning on line 261 in backend/src/indexer/indexer.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Async arrow function has no 'await' expression
);
});

Expand All @@ -266,7 +285,7 @@
});
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ result: { events: [], latestLedger: 100 } }),

Check warning on line 288 in backend/src/indexer/indexer.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Async method 'json' has no 'await' expression
} as unknown as Response);

try {
Expand Down
Loading
Loading