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 9b164dd3..5fd0a0f3 100644 --- a/backend/src/creator-events/creator-events-predictions-stats.spec.ts +++ b/backend/src/creator-events/creator-events-predictions-stats.spec.ts @@ -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', () => { @@ -85,6 +86,10 @@ describe('CreatorEventsService predictions and stats', () => { provide: getRepositoryToken(CreatorEventLeaderboardEntry), useValue: {}, }, + { + provide: getRepositoryToken(CreatorEventPayout), + useValue: {}, + }, ], }).compile(); diff --git a/backend/src/creator-events/creator-events.controller.ts b/backend/src/creator-events/creator-events.controller.ts index e1ec3fa5..cfae39ea 100644 --- a/backend/src/creator-events/creator-events.controller.ts +++ b/backend/src/creator-events/creator-events.controller.ts @@ -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') @@ -199,6 +204,58 @@ export class CreatorEventsController { ): Promise { 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 { + 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 { + return this.creatorEventsService.getPayoutByAddress(id, address); + } } @ApiTags('creator-events') diff --git a/backend/src/creator-events/creator-events.module.ts b/backend/src/creator-events/creator-events.module.ts index 3ae04240..d5253dee 100644 --- a/backend/src/creator-events/creator-events.module.ts +++ b/backend/src/creator-events/creator-events.module.ts @@ -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, @@ -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: [ diff --git a/backend/src/creator-events/creator-events.service.spec.ts b/backend/src/creator-events/creator-events.service.spec.ts index 82dc7a12..6468aad4 100644 --- a/backend/src/creator-events/creator-events.service.spec.ts +++ b/backend/src/creator-events/creator-events.service.spec.ts @@ -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'; @@ -94,6 +95,10 @@ describe('CreatorEventsService searchEvents', () => { provide: getRepositoryToken(CreatorEventLeaderboardEntry), useValue: {}, }, + { + provide: getRepositoryToken(CreatorEventPayout), + useValue: {}, + }, ], }).compile(); diff --git a/backend/src/creator-events/creator-events.service.ts b/backend/src/creator-events/creator-events.service.ts index 1525e9c1..494c8cd7 100644 --- a/backend/src/creator-events/creator-events.service.ts +++ b/backend/src/creator-events/creator-events.service.ts @@ -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, @@ -82,6 +88,9 @@ export class CreatorEventsService { private readonly creatorEventRepository: Repository, @InjectRepository(CreatorEventLeaderboardEntry) private readonly leaderboardEntryRepository: Repository, + + @InjectRepository(CreatorEventPayout) + private readonly creatorEventPayoutRepository: Repository, ) {} async searchEvents( @@ -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 { + 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 { + 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, diff --git a/backend/src/creator-events/dto/payouts.dto.ts b/backend/src/creator-events/dto/payouts.dto.ts new file mode 100644 index 00000000..772bc1d9 --- /dev/null +++ b/backend/src/creator-events/dto/payouts.dto.ts @@ -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; +} diff --git a/backend/src/indexer/indexer.module.ts b/backend/src/indexer/indexer.module.ts index 27c3d61b..a3641315 100644 --- a/backend/src/indexer/indexer.module.ts +++ b/backend/src/indexer/indexer.module.ts @@ -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'; @@ -22,6 +24,8 @@ import { WebsocketModule } from '../websocket/websocket.module'; FeeHistory, IndexerCheckpoint, CreatorEvent, + CreatorEventLeaderboardEntry, + CreatorEventPayout, Match, MatchPrediction, User, diff --git a/backend/src/indexer/indexer.service.spec.ts b/backend/src/indexer/indexer.service.spec.ts index bb35d582..f309960e 100644 --- a/backend/src/indexer/indexer.service.spec.ts +++ b/backend/src/indexer/indexer.service.spec.ts @@ -15,6 +15,8 @@ import { 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'; @@ -141,6 +143,22 @@ describe('IndexerService', () => { 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: { @@ -163,6 +181,7 @@ describe('IndexerService', () => { broadcastMatchResolved: jest.fn(), broadcastWinnersVerified: jest.fn(), broadcastEventCancelled: jest.fn(), + broadcastEventFinalized: jest.fn(), }, }, ], diff --git a/backend/src/indexer/indexer.service.ts b/backend/src/indexer/indexer.service.ts index 7362f2b8..d55e0eee 100644 --- a/backend/src/indexer/indexer.service.ts +++ b/backend/src/indexer/indexer.service.ts @@ -12,6 +12,8 @@ import { IndexerCheckpoint } from './entities/indexer-checkpoint.entity'; import { IndexerMetricsDto } from './dto/indexer-metrics.dto'; import { Match, WinningTeam } from '../matches/entities/match.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 { MatchPrediction, PredictedOutcome, @@ -63,6 +65,12 @@ export class IndexerService implements OnModuleInit { @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(CreatorEventLeaderboardEntry) + private readonly creatorEventLeaderboardEntryRepository: Repository, + + @InjectRepository(CreatorEventPayout) + private readonly creatorEventPayoutRepository: Repository, + private readonly notificationGeneratorService: NotificationGeneratorService, private readonly broadcasterService: BroadcasterService, ) {} @@ -336,6 +344,11 @@ export class IndexerService implements OnModuleInit { hasTopicPair('event', 'winners_verified') ) return 'WinnersVerified'; + if ( + topicStr.includes('eventfinalized') || + hasTopicPair('event', 'finalized') + ) + return 'EventFinalized'; if ( topicStr.includes('eventcancelled') || hasTopicPair('event', 'cancelled') @@ -433,6 +446,15 @@ export class IndexerService implements OnModuleInit { verified_at: this.readNum(base, 'verified_at'), winners: Array.isArray(base.winners) ? base.winners : [], }; + case 'EventFinalized': + return { + event_id: this.readBigInt(base, 'event_id'), + finalized_at: this.readNum(base, 'finalized_at'), + // leaderboard is passed through as-is; per-entry unwrapping happens + // inside handleEventFinalized to keep extractEventData free of + // business logic. + leaderboard: Array.isArray(base.leaderboard) ? base.leaderboard : [], + }; case 'EventCancelled': return { event_id: this.readBigInt(base, 'event_id'), @@ -541,6 +563,9 @@ export class IndexerService implements OnModuleInit { case 'WinnersVerified': void this.handleWinnersVerified(data); break; + case 'EventFinalized': + await this.handleEventFinalized(data); + break; case 'EventCancelled': await this.handleEventCancelled(data); break; @@ -869,6 +894,149 @@ export class IndexerService implements OnModuleInit { this.broadcasterService.broadcastWinnersVerified(data); } + private async handleEventFinalized( + data: Record, + ): Promise { + const onChainEventId = Number(data.event_id); + if (!onChainEventId) { + this.logger.warn('EventFinalized skipped: missing event_id'); + return; + } + + const event = await this.creatorEventRepository.findOne({ + where: { on_chain_event_id: onChainEventId }, + }); + if (!event) { + this.logger.warn( + `EventFinalized skipped: event ${onChainEventId} not found in DB`, + ); + return; + } + + const eventIdStr = String(onChainEventId); + + // Idempotency guard: if any payout rows already exist for this event the + // entire EventFinalized payload has already been processed (payouts are + // created atomically per entry in the loop below). An early exit here is + // safe because the creation path uses the same event_id string key. + const existingCount = await this.creatorEventPayoutRepository.count({ + where: { event_id: eventIdStr }, + }); + if (existingCount > 0) { + this.logger.log( + `EventFinalized idempotent skip: payouts already exist for event ${onChainEventId}`, + ); + return; + } + + // Mark finalized in DB in case this event was finalized by a third party + // (contract is permissionless) and the finalizer service has not yet run. + if (!event.is_finalized) { + event.is_finalized = true; + await this.creatorEventRepository.save(event); + } + + const leaderboard: unknown[] = Array.isArray(data.leaderboard) + ? data.leaderboard + : []; + + let successCount = 0; + for (const rawEntry of leaderboard) { + try { + await this.processLeaderboardEntry(eventIdStr, rawEntry); + successCount++; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + this.logger.warn( + `EventFinalized: failed to persist entry for event ${onChainEventId}: ${message}`, + ); + } + } + + this.logger.log( + `Indexed EventFinalized: event_id=${onChainEventId} entries=${successCount}/${leaderboard.length}`, + ); + + this.broadcasterService.broadcastEventFinalized(data); + } + + /** + * Upserts one CreatorEventLeaderboardEntry and creates the linked + * CreatorEventPayout for a single participant in an EventFinalized payload. + * + * Time complexity: O(1) per entry — two indexed point-lookups + two writes. + * Space complexity: O(1). + */ + private async processLeaderboardEntry( + eventIdStr: string, + raw: unknown, + ): Promise { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return; + + const entry = raw as Record; + const userAddress = this.readStr(entry, 'address'); + const rank = this.readNum(entry, 'rank'); + const payoutAmountStroops = this.readUnsignedBigInt(entry, 'payout_amount'); + const totalPredictions = this.readNum(entry, 'total_predictions') ?? 0; + const correctPredictions = this.readNum(entry, 'correct_predictions') ?? 0; + + if (!userAddress || rank === null) { + this.logger.warn( + `processLeaderboardEntry: skipping entry with missing address or rank`, + ); + return; + } + + const accuracyPercentage = + totalPredictions > 0 + ? Math.round((correctPredictions / totalPredictions) * 10000) / 100 + : 0; + + const isWinner = BigInt(payoutAmountStroops) > 0n; + + // Upsert leaderboard entry — the contract is the source of truth for final + // rankings, so we overwrite any pre-existing DB values. + let leaderboardEntry = await this.creatorEventLeaderboardEntryRepository.findOne( + { where: { event_id: eventIdStr, user_address: userAddress } }, + ); + + if (!leaderboardEntry) { + leaderboardEntry = this.creatorEventLeaderboardEntryRepository.create({ + event_id: eventIdStr, + user_address: userAddress, + rank, + total_predictions: totalPredictions, + correct_predictions: correctPredictions, + accuracy_percentage: accuracyPercentage, + is_winner: isWinner, + completion_time: null, + }); + } else { + leaderboardEntry.rank = rank; + leaderboardEntry.total_predictions = totalPredictions; + leaderboardEntry.correct_predictions = correctPredictions; + leaderboardEntry.accuracy_percentage = accuracyPercentage; + leaderboardEntry.is_winner = isWinner; + } + + leaderboardEntry = await this.creatorEventLeaderboardEntryRepository.save( + leaderboardEntry, + ); + + // Create the payout row linked to the leaderboard entry. + // The idempotency check at the top of handleEventFinalized ensures we only + // reach this point once per event, so we use a plain insert here. + const payout = this.creatorEventPayoutRepository.create({ + event_id: eventIdStr, + user_address: userAddress, + payout_amount_stroops: payoutAmountStroops, + is_claimed: false, + leaderboard_entry_id: leaderboardEntry.id, + }); + + await this.creatorEventPayoutRepository.save(payout); + } + private async handleEventCancelled( data: Record, ): Promise { diff --git a/backend/src/matches/entities/creator-event-payout.entity.ts b/backend/src/matches/entities/creator-event-payout.entity.ts new file mode 100644 index 00000000..4309c631 --- /dev/null +++ b/backend/src/matches/entities/creator-event-payout.entity.ts @@ -0,0 +1,54 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { CreatorEventLeaderboardEntry } from './creator-event-leaderboard-entry.entity'; + +/** + * Persists the per-user prize payout emitted by the contract's EventFinalized event. + * One row per participant per event. Keyed by (event_id, user_address) so the + * payouts API can resolve a single address in O(log n) without a full scan. + * + * event_id stores the string representation of the on-chain event ID, matching + * the convention already used in CreatorEventLeaderboardEntry. + */ +@Entity('creator_event_payouts') +@Index('UQ_cep_event_address', ['event_id', 'user_address'], { unique: true }) +@Index('IDX_cep_event_id', ['event_id']) +export class CreatorEventPayout { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 255 }) + event_id: string; + + @Column({ type: 'varchar', length: 255 }) + user_address: string; + + /** Prize amount in stroops (1 XLM = 10_000_000 stroops). Stored as bigint string. */ + @Column({ type: 'bigint', default: '0' }) + payout_amount_stroops: string; + + /** + * Flipped to true by the PayoutClaimed handler (future work). + * Included now so the entity schema is stable and the frontend can + * distinguish claimable vs already-claimed payouts. + */ + @Column({ default: false }) + is_claimed: boolean; + + @Column({ type: 'uuid' }) + leaderboard_entry_id: string; + + @ManyToOne(() => CreatorEventLeaderboardEntry, { onDelete: 'CASCADE', eager: false }) + @JoinColumn({ name: 'leaderboard_entry_id' }) + leaderboard_entry: CreatorEventLeaderboardEntry; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/migrations/1776000000000-CreateCreatorEventPayout.ts b/backend/src/migrations/1776000000000-CreateCreatorEventPayout.ts new file mode 100644 index 00000000..7cfbdf30 --- /dev/null +++ b/backend/src/migrations/1776000000000-CreateCreatorEventPayout.ts @@ -0,0 +1,100 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, +} from 'typeorm'; + +export class CreateCreatorEventPayout1776000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'creator_event_payouts', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { + name: 'event_id', + type: 'varchar', + length: '255', + isNullable: false, + }, + { + name: 'user_address', + type: 'varchar', + length: '255', + isNullable: false, + }, + { + name: 'payout_amount_stroops', + type: 'bigint', + default: '0', + isNullable: false, + }, + { + name: 'is_claimed', + type: 'boolean', + default: false, + isNullable: false, + }, + { + name: 'leaderboard_entry_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'created_at', + type: 'timestamptz', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true, + ); + + // Composite unique: one payout row per participant per event + await queryRunner.createIndex( + 'creator_event_payouts', + new TableIndex({ + name: 'UQ_cep_event_address', + columnNames: ['event_id', 'user_address'], + isUnique: true, + }), + ); + + // Supporting index for paginated list queries (GET /creator-events/:id/payouts) + await queryRunner.createIndex( + 'creator_event_payouts', + new TableIndex({ + name: 'IDX_cep_event_id', + columnNames: ['event_id'], + }), + ); + + // FK enforces referential integrity; CASCADE keeps payouts in sync if + // a leaderboard entry is ever removed during a reindex/cleanup. + await queryRunner.createForeignKey( + 'creator_event_payouts', + new TableForeignKey({ + name: 'FK_cep_leaderboard_entry', + columnNames: ['leaderboard_entry_id'], + referencedTableName: 'creator_event_leaderboard_entries', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('creator_event_payouts'); + } +} diff --git a/backend/src/websocket/broadcaster.service.ts b/backend/src/websocket/broadcaster.service.ts index 3f56dca7..7189a5da 100644 --- a/backend/src/websocket/broadcaster.service.ts +++ b/backend/src/websocket/broadcaster.service.ts @@ -124,4 +124,18 @@ export class BroadcasterService { this.gateway.server.to(`event:${eventId}`).emit('event:cancelled', payload); this.logger.log(`Broadcast event:cancelled → event:${eventId}`); } + + broadcastEventFinalized(data: Record): void { + const eventId = String(data.event_id); + const payload = { + event: 'event:finalized', + data: { + event_id: eventId, + finalized_at: data.finalized_at, + leaderboard: data.leaderboard, + }, + }; + this.gateway.server.to(`event:${eventId}`).emit('event:finalized', payload); + this.logger.log(`Broadcast event:finalized → event:${eventId}`); + } }