Skip to content
Open
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
3 changes: 2 additions & 1 deletion backend/src/leaderboard/leaderboard.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
7 changes: 6 additions & 1 deletion backend/src/leaderboard/leaderboard.scheduler.ts
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 5 in backend/src/leaderboard/leaderboard.scheduler.ts

View workflow job for this annotation

GitHub Actions / Lint

'NotificationType' is defined but never used

@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<void> {
Expand Down
40 changes: 36 additions & 4 deletions backend/src/leaderboard/leaderboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
LeaderboardEntryResponse,
PaginatedLeaderboardResponse,
} from './dto/leaderboard-query.dto';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../notifications/entities/notification.entity';

@Injectable()
export class LeaderboardService {
Expand All @@ -18,6 +20,7 @@
private readonly leaderboardRepository: Repository<LeaderboardEntry>,
private readonly usersService: UsersService,
private readonly dataSource: DataSource,
private readonly notificationsService: NotificationsService,
) {}

async getLeaderboard(
Expand Down Expand Up @@ -82,10 +85,13 @@
(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')
Expand All @@ -94,12 +100,14 @@
})
.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,
Expand All @@ -110,7 +118,7 @@
} 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,
Expand All @@ -119,12 +127,36 @@
});
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,

Check failure on line 146 in backend/src/leaderboard/leaderboard.service.ts

View workflow job for this annotation

GitHub Actions / Lint

'direction' is assigned a value but never used
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`,
);
}
}
3 changes: 2 additions & 1 deletion backend/src/markets/markets.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
60 changes: 60 additions & 0 deletions backend/src/markets/markets.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
MarketStatus,
PaginatedMarketsResponse,
} from './dto/list-markets.dto';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../notifications/entities/notification.entity';

Check failure on line 21 in backend/src/markets/markets.service.ts

View workflow job for this annotation

GitHub Actions / Lint

'NotificationType' is defined but never used

@Injectable()
export class MarketsService {
Expand All @@ -26,6 +28,7 @@
@InjectRepository(Market)
private readonly marketsRepository: Repository<Market>,
private readonly usersService: UsersService,
private readonly notificationsService: NotificationsService,
) {}

/**
Expand Down Expand Up @@ -217,4 +220,61 @@
);
}
}

/**
* 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<Market> {
// 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',
);
}
}
}
19 changes: 14 additions & 5 deletions backend/src/notifications/entities/notification.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
8 changes: 4 additions & 4 deletions backend/src/notifications/notifications.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('NotificationsService', () => {
const mockNotification: Partial<Notification> = {
id: 'notif-uuid-1',
user_id: 'user-uuid-1',
type: NotificationType.System,
type: NotificationType.MARKET_RESOLVED,
title: 'Test',
message: 'Test message',
is_read: false,
Expand Down Expand Up @@ -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,
Expand All @@ -71,7 +71,7 @@ describe('NotificationsService', () => {

await service.create(
'user-uuid-1',
NotificationType.System,
NotificationType.MARKET_RESOLVED,
'T',
'M',
meta,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/predictions/predictions.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -15,6 +16,7 @@ import { Market } from '../markets/entities/market.entity';
UsersModule,
MarketsModule,
SorobanModule,
NotificationsModule,
],
controllers: [PredictionsController],
providers: [PredictionsService],
Expand Down
64 changes: 64 additions & 0 deletions backend/src/predictions/predictions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
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 {
Expand All @@ -32,6 +34,7 @@
private readonly usersRepository: Repository<User>,
private readonly sorobanService: SorobanService,
private readonly dataSource: DataSource,
private readonly notificationsService: NotificationsService,
) {}

/**
Expand Down Expand Up @@ -189,4 +192,65 @@
}
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<Prediction> {
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,

Check failure on line 228 in backend/src/predictions/predictions.service.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe call of a type that could not be resolved

Check failure on line 228 in backend/src/predictions/predictions.service.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an error typed value
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);

Check failure on line 236 in backend/src/predictions/predictions.service.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an error typed value
// 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');
}
}
}
Loading