diff --git a/backend/src/leaderboard/leaderboard.module.ts b/backend/src/leaderboard/leaderboard.module.ts index 7230396f..b1e893c9 100644 --- a/backend/src/leaderboard/leaderboard.module.ts +++ b/backend/src/leaderboard/leaderboard.module.ts @@ -5,9 +5,10 @@ import { UsersModule } from '../users/users.module'; import { LeaderboardService } from './leaderboard.service'; import { LeaderboardScheduler } from './leaderboard.scheduler'; import { LeaderboardController } from './leaderboard.controller'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [TypeOrmModule.forFeature([LeaderboardEntry]), UsersModule], + imports: [TypeOrmModule.forFeature([LeaderboardEntry]), UsersModule, NotificationsModule], controllers: [LeaderboardController], providers: [LeaderboardService, LeaderboardScheduler], exports: [LeaderboardService], diff --git a/backend/src/leaderboard/leaderboard.scheduler.ts b/backend/src/leaderboard/leaderboard.scheduler.ts index 19d326be..95801782 100644 --- a/backend/src/leaderboard/leaderboard.scheduler.ts +++ b/backend/src/leaderboard/leaderboard.scheduler.ts @@ -1,12 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { LeaderboardService } from './leaderboard.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../notifications/entities/notification.entity'; @Injectable() export class LeaderboardScheduler { private readonly logger = new Logger(LeaderboardScheduler.name); - constructor(private readonly leaderboardService: LeaderboardService) {} + constructor( + private readonly leaderboardService: LeaderboardService, + private readonly notificationsService: NotificationsService, + ) {} @Cron('0 */1 * * *') async handleHourlyRecalculation(): Promise { diff --git a/backend/src/leaderboard/leaderboard.service.ts b/backend/src/leaderboard/leaderboard.service.ts index 3d21b6ba..a3ce63ea 100644 --- a/backend/src/leaderboard/leaderboard.service.ts +++ b/backend/src/leaderboard/leaderboard.service.ts @@ -8,6 +8,8 @@ import { LeaderboardEntryResponse, PaginatedLeaderboardResponse, } from './dto/leaderboard-query.dto'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../notifications/entities/notification.entity'; @Injectable() export class LeaderboardService { @@ -18,6 +20,7 @@ export class LeaderboardService { private readonly leaderboardRepository: Repository, private readonly usersService: UsersService, private readonly dataSource: DataSource, + private readonly notificationsService: NotificationsService, ) {} async getLeaderboard( @@ -82,10 +85,13 @@ export class LeaderboardService { (a, b) => b.reputation_score - a.reputation_score, ); + // Track rank changes for notifications + const rankChanges: Array<{ userId: string; oldRank: number; newRank: number }> = []; + await this.dataSource.transaction(async (manager) => { for (let i = 0; i < sorted.length; i++) { const user = sorted[i]; - const rank = i + 1; + const newRank = i + 1; const existing = await manager .createQueryBuilder(LeaderboardEntry, 'entry') @@ -94,12 +100,14 @@ export class LeaderboardService { }) .getOne(); + const oldRank = existing?.rank ?? null; + if (existing) { await manager.update( LeaderboardEntry, { id: existing.id }, { - rank, + rank: newRank, reputation_score: user.reputation_score, season_points: user.season_points, total_predictions: user.total_predictions, @@ -110,7 +118,7 @@ export class LeaderboardService { } else { const entry = manager.create(LeaderboardEntry, { user_id: user.id, - rank, + rank: newRank, reputation_score: user.reputation_score, season_points: user.season_points, total_predictions: user.total_predictions, @@ -119,12 +127,36 @@ export class LeaderboardService { }); await manager.save(LeaderboardEntry, entry); } + + // Track rank changes + if (oldRank !== null && oldRank !== newRank) { + rankChanges.push({ userId: user.id, oldRank, newRank }); + } } }); + // Send notifications for rank changes + for (const change of rankChanges) { + try { + const rankChange = change.oldRank - change.newRank; + const direction = rankChange > 0 ? 'up' : 'down'; + const message = `Your leaderboard rank has changed from #${change.oldRank} to #${change.newRank}`; + + await this.notificationsService.create( + change.userId, + NotificationType.RANK_CHANGED, + 'Leaderboard Rank Updated', + message, + { old_rank: change.oldRank, new_rank: change.newRank, change: rankChange }, + ); + } catch (err) { + this.logger.error(`Failed to send rank change notification for user ${change.userId}`, err); + } + } + const elapsed = Date.now() - start; this.logger.log( - `Leaderboard recalculation complete: ${sorted.length} users updated in ${elapsed}ms`, + `Leaderboard recalculation complete: ${sorted.length} users updated, ${rankChanges.length} rank changes notified in ${elapsed}ms`, ); } } diff --git a/backend/src/markets/markets.module.ts b/backend/src/markets/markets.module.ts index 97f1ae5f..c206b3aa 100644 --- a/backend/src/markets/markets.module.ts +++ b/backend/src/markets/markets.module.ts @@ -4,9 +4,10 @@ import { Market } from './entities/market.entity'; import { MarketsService } from './markets.service'; import { MarketsController } from './markets.controller'; import { UsersModule } from '../users/users.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [TypeOrmModule.forFeature([Market]), UsersModule], + imports: [TypeOrmModule.forFeature([Market]), UsersModule, NotificationsModule], controllers: [MarketsController], providers: [MarketsService], exports: [MarketsService], diff --git a/backend/src/markets/markets.service.ts b/backend/src/markets/markets.service.ts index d5c5ed2b..c88e903e 100644 --- a/backend/src/markets/markets.service.ts +++ b/backend/src/markets/markets.service.ts @@ -17,6 +17,8 @@ import { MarketStatus, PaginatedMarketsResponse, } from './dto/list-markets.dto'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../notifications/entities/notification.entity'; @Injectable() export class MarketsService { @@ -26,6 +28,7 @@ export class MarketsService { @InjectRepository(Market) private readonly marketsRepository: Repository, private readonly usersService: UsersService, + private readonly notificationsService: NotificationsService, ) {} /** @@ -217,4 +220,61 @@ export class MarketsService { ); } } + + /** + * Resolve a market: validate status, call Soroban contract, then update DB. + * Only unresolved, non-cancelled markets can be resolved. + */ + async resolveMarket(id: string, outcome: string): Promise { + // Step 1: Find market and validate it can be resolved + const market = await this.findByIdOrOnChainId(id); + + if (market.is_resolved) { + throw new ConflictException('Market is already resolved'); + } + + if (market.is_cancelled) { + throw new ConflictException('Cancelled markets cannot be resolved'); + } + + if (!market.outcome_options.includes(outcome)) { + throw new BadGatewayException(`Invalid outcome: ${outcome}`); + } + + // Step 2: Call Soroban contract to resolve market on-chain + try { + // TODO: Replace with real SorobanService.resolveMarket() call + this.logger.log( + `Soroban resolveMarket called for market "${market.title}" (id: ${market.id}) with outcome: ${outcome}`, + ); + } catch (err) { + this.logger.error('Soroban resolveMarket failed', err); + throw new BadGatewayException('Failed to resolve market on Soroban'); + } + + // Step 3: Update database + try { + market.is_resolved = true; + market.resolved_outcome = outcome; + const updatedMarket = await this.marketsRepository.save(market); + + // Step 4: Notify all participants about market resolution + // TODO: Get list of participants who made predictions on this market + // For now, we'll skip individual notifications as we don't have prediction data yet + + this.logger.log( + `Market "${market.title}" resolved with outcome: ${outcome}`, + ); + + return updatedMarket; + } catch (err) { + this.logger.error( + 'Failed to update market in DB after Soroban success', + err, + ); + throw new BadGatewayException( + 'Market resolved on-chain but failed to update database', + ); + } + } } diff --git a/backend/src/notifications/entities/notification.entity.ts b/backend/src/notifications/entities/notification.entity.ts index 7b87358f..7cfafe06 100644 --- a/backend/src/notifications/entities/notification.entity.ts +++ b/backend/src/notifications/entities/notification.entity.ts @@ -10,11 +10,20 @@ import { import { User } from '../../users/entities/user.entity'; export enum NotificationType { - CompetitionStarted = 'competition_started', - CompetitionEnded = 'competition_ended', - LeaderboardUpdated = 'leaderboard_updated', - MarketResolved = 'market_resolved', - System = 'system', + /** Notification sent when a market is resolved with an outcome */ + MARKET_RESOLVED = 'market_resolved', + /** Notification sent when a payout is ready for claiming */ + PAYOUT_READY = 'payout_ready', + /** Notification sent when a user's rank changes in a leaderboard */ + RANK_CHANGED = 'rank_changed', + /** Notification sent when a competition starts */ + COMPETITION_STARTED = 'competition_started', + /** Notification sent when a competition ends */ + COMPETITION_ENDED = 'competition_ended', + /** Notification sent when a user's prediction wins */ + PREDICTION_WON = 'prediction_won', + /** Notification sent when a user's prediction loses */ + PREDICTION_LOST = 'prediction_lost', } @Index(['user_id', 'is_read']) diff --git a/backend/src/notifications/notifications.service.spec.ts b/backend/src/notifications/notifications.service.spec.ts index d88b38d8..401a32d4 100644 --- a/backend/src/notifications/notifications.service.spec.ts +++ b/backend/src/notifications/notifications.service.spec.ts @@ -9,7 +9,7 @@ describe('NotificationsService', () => { const mockNotification: Partial = { id: 'notif-uuid-1', user_id: 'user-uuid-1', - type: NotificationType.System, + type: NotificationType.MARKET_RESOLVED, title: 'Test', message: 'Test message', is_read: false, @@ -49,14 +49,14 @@ describe('NotificationsService', () => { const result = await service.create( 'user-uuid-1', - NotificationType.System, + NotificationType.MARKET_RESOLVED, 'Test', 'Test message', ); expect(mockRepository.create).toHaveBeenCalledWith({ user_id: 'user-uuid-1', - type: NotificationType.System, + type: NotificationType.MARKET_RESOLVED, title: 'Test', message: 'Test message', metadata: undefined, @@ -71,7 +71,7 @@ describe('NotificationsService', () => { await service.create( 'user-uuid-1', - NotificationType.System, + NotificationType.MARKET_RESOLVED, 'T', 'M', meta, diff --git a/backend/src/predictions/predictions.module.ts b/backend/src/predictions/predictions.module.ts index ebe02ef6..aa240217 100644 --- a/backend/src/predictions/predictions.module.ts +++ b/backend/src/predictions/predictions.module.ts @@ -6,6 +6,7 @@ import { PredictionsController } from './predictions.controller'; import { UsersModule } from '../users/users.module'; import { MarketsModule } from '../markets/markets.module'; import { SorobanModule } from '../soroban/soroban.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { User } from '../users/entities/user.entity'; import { Market } from '../markets/entities/market.entity'; @@ -15,6 +16,7 @@ import { Market } from '../markets/entities/market.entity'; UsersModule, MarketsModule, SorobanModule, + NotificationsModule, ], controllers: [PredictionsController], providers: [PredictionsService], diff --git a/backend/src/predictions/predictions.service.ts b/backend/src/predictions/predictions.service.ts index ead7160e..ea61dbc1 100644 --- a/backend/src/predictions/predictions.service.ts +++ b/backend/src/predictions/predictions.service.ts @@ -18,6 +18,8 @@ import { import { User } from '../users/entities/user.entity'; import { Market } from '../markets/entities/market.entity'; import { SorobanService } from '../soroban/soroban.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../notifications/entities/notification.entity'; @Injectable() export class PredictionsService { @@ -32,6 +34,7 @@ export class PredictionsService { private readonly usersRepository: Repository, private readonly sorobanService: SorobanService, private readonly dataSource: DataSource, + private readonly notificationsService: NotificationsService, ) {} /** @@ -189,4 +192,65 @@ export class PredictionsService { } return PredictionStatus.Lost; } + + /** + * Claim payout for a winning prediction. + * Validates that the prediction won and payout hasn't been claimed, + * calls Soroban to claim payout, then updates the prediction record. + */ + async claimPayout(predictionId: string, user: User): Promise { + const prediction = await this.predictionsRepository.findOne({ + where: { id: predictionId, user: { id: user.id } }, + relations: ['market', 'user'], + }); + + if (!prediction) { + throw new NotFoundException(`Prediction "${predictionId}" not found`); + } + + if (prediction.payout_claimed) { + throw new ConflictException('Payout has already been claimed'); + } + + const market = prediction.market; + if (!market.is_resolved) { + throw new BadRequestException('Market is not yet resolved'); + } + + if (market.resolved_outcome !== prediction.chosen_outcome) { + throw new BadRequestException('Prediction did not win - no payout available'); + } + + // Call Soroban to claim payout + try { + const { payout_amount_stroops } = await this.sorobanService.claimPayout( + user.stellar_address, + market.on_chain_market_id, + prediction.tx_hash, + ); + + // Update prediction with payout details + prediction.payout_claimed = true; + prediction.payout_amount_stroops = payout_amount_stroops; + const updated = await this.predictionsRepository.save(prediction); + + // Send notification about payout + await this.notificationsService.create( + user.id, + NotificationType.PAYOUT_READY, + 'Payout Claimed Successfully', + `Your payout of ${payout_amount_stroops} stroops has been claimed for the market "${market.title}"`, + { market_id: market.id, prediction_id: prediction.id, payout_amount: payout_amount_stroops }, + ); + + this.logger.log( + `Payout claimed for prediction ${predictionId} by user ${user.id}: ${payout_amount_stroops} stroops`, + ); + + return updated; + } catch (err) { + this.logger.error('Failed to claim payout from Soroban', err); + throw new BadRequestException('Failed to claim payout from blockchain'); + } + } }