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
15 changes: 15 additions & 0 deletions backend/src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Expand All @@ -17,6 +18,7 @@ import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { BanUserDto } from './dto/ban-user.dto';
import { ActivityLogQueryDto } from './dto/activity-log-query.dto';
import { StatsResponseDto } from './dto/stats-response.dto';
import { ResolveMarketDto } from './dto/resolve-market.dto';

@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
Expand Down Expand Up @@ -62,4 +64,17 @@ export class AdminController {
) {
return this.adminService.getUserActivity(id, query);
}

@Post('markets/:id/resolve')
async resolveMarket(
@Param('id') id: string,
@Body() dto: ResolveMarketDto,
@Request() req: any,
) {
return this.adminService.adminResolveMarket(
id,
dto,
(req as { user: { id: string } }).user.id,
);
}
}
2 changes: 2 additions & 0 deletions backend/src/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Market } from '../markets/entities/market.entity';
import { Prediction } from '../predictions/entities/prediction.entity';
import { Competition } from '../competitions/entities/competition.entity';
import { ActivityLog } from '../analytics/entities/activity-log.entity';
import { NotificationsModule } from '../notifications/notifications.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';

Expand All @@ -17,6 +18,7 @@ import { AdminService } from './admin.service';
Competition,
ActivityLog,
]),
NotificationsModule,
],
controllers: [AdminController],
providers: [AdminService],
Expand Down
171 changes: 171 additions & 0 deletions backend/src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {
BadGatewayException,
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ActivityLog } from '../analytics/entities/activity-log.entity';
import { AnalyticsService } from '../analytics/analytics.service';
import { Competition } from '../competitions/entities/competition.entity';
import { Market } from '../markets/entities/market.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { Prediction } from '../predictions/entities/prediction.entity';
import { SorobanService } from '../soroban/soroban.service';
import { User } from '../users/entities/user.entity';
import { AdminService } from './admin.service';
import { ResolveMarketDto } from './dto/resolve-market.dto';

const mockRepo = () => ({
findOne: jest.fn(),
save: jest.fn(),
find: jest.fn(),
count: jest.fn(),
createQueryBuilder: jest.fn(),
});

describe('AdminService.adminResolveMarket', () => {
let service: AdminService;
let marketsRepo: ReturnType<typeof mockRepo>;
let predictionsRepo: ReturnType<typeof mockRepo>;
let sorobanService: jest.Mocked<Pick<SorobanService, 'resolveMarket'>>;
let notificationsService: jest.Mocked<Pick<NotificationsService, 'create'>>;
let analyticsService: jest.Mocked<Pick<AnalyticsService, 'logActivity'>>;

const adminId = 'admin-1';

const makeMarket = (overrides: Partial<Market> = {}): Market =>
({
id: 'market-1',
on_chain_market_id: 'on-chain-1',
title: 'Test Market',
outcome_options: ['YES', 'NO'],
is_resolved: false,
is_cancelled: false,
...overrides,
}) as Market;

const makeDto = (overrides: Partial<ResolveMarketDto> = {}): ResolveMarketDto => ({
resolved_outcome: 'YES',
...overrides,
});

beforeEach(async () => {
marketsRepo = mockRepo();
predictionsRepo = mockRepo();
sorobanService = { resolveMarket: jest.fn().mockResolvedValue({}) };
notificationsService = { create: jest.fn().mockResolvedValue({}) };
analyticsService = { logActivity: jest.fn().mockResolvedValue({}) };

const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: getRepositoryToken(User), useValue: mockRepo() },
{ provide: getRepositoryToken(Market), useValue: marketsRepo },
{ provide: getRepositoryToken(Prediction), useValue: predictionsRepo },
{ provide: getRepositoryToken(Competition), useValue: mockRepo() },
{ provide: getRepositoryToken(ActivityLog), useValue: mockRepo() },
{ provide: AnalyticsService, useValue: analyticsService },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: SorobanService, useValue: sorobanService },
],
}).compile();

service = module.get<AdminService>(AdminService);
});

it('throws NotFoundException when market does not exist', async () => {
marketsRepo.findOne.mockResolvedValue(null);

await expect(service.adminResolveMarket('bad-id', makeDto(), adminId)).rejects.toThrow(
NotFoundException,
);
});

it('throws ConflictException when market is already resolved', async () => {
marketsRepo.findOne.mockResolvedValue(makeMarket({ is_resolved: true }));

await expect(service.adminResolveMarket('market-1', makeDto(), adminId)).rejects.toThrow(
ConflictException,
);
});

it('throws BadRequestException when market is cancelled', async () => {
marketsRepo.findOne.mockResolvedValue(makeMarket({ is_cancelled: true }));

await expect(service.adminResolveMarket('market-1', makeDto(), adminId)).rejects.toThrow(
BadRequestException,
);
});

it('throws BadRequestException for invalid outcome', async () => {
marketsRepo.findOne.mockResolvedValue(makeMarket());

await expect(
service.adminResolveMarket('market-1', makeDto({ resolved_outcome: 'MAYBE' }), adminId),
).rejects.toThrow(BadRequestException);
});

it('throws BadGatewayException when Soroban call fails', async () => {
marketsRepo.findOne.mockResolvedValue(makeMarket());
sorobanService.resolveMarket.mockRejectedValue(new Error('Soroban down'));

await expect(service.adminResolveMarket('market-1', makeDto(), adminId)).rejects.toThrow(
BadGatewayException,
);
});

it('resolves market, notifies participants, and logs admin action', async () => {
const market = makeMarket();
const participant = { id: 'user-2' } as User;
const prediction = { user: participant, chosen_outcome: 'YES', market } as Prediction;

marketsRepo.findOne.mockResolvedValue(market);
marketsRepo.save.mockResolvedValue({ ...market, is_resolved: true, resolved_outcome: 'YES' });
predictionsRepo.find.mockResolvedValue([prediction]);

const result = await service.adminResolveMarket('market-1', makeDto(), adminId);

expect(sorobanService.resolveMarket).toHaveBeenCalledWith('on-chain-1', 'YES');
expect(marketsRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ is_resolved: true, resolved_outcome: 'YES' }),
);
expect(notificationsService.create).toHaveBeenCalledWith(
'user-2',
expect.any(String),
'Market Resolved',
expect.stringContaining('YES'),
expect.objectContaining({ won: true }),
);
expect(analyticsService.logActivity).toHaveBeenCalledWith(
adminId,
'MARKET_RESOLVED_BY_ADMIN',
expect.objectContaining({ market_id: 'market-1', resolved_outcome: 'YES' }),
);
expect(result.is_resolved).toBe(true);
});

it('includes resolution_note in notification metadata when provided', async () => {
const market = makeMarket();
const prediction = { user: { id: 'user-2' } as User, chosen_outcome: 'NO', market } as Prediction;

marketsRepo.findOne.mockResolvedValue(market);
marketsRepo.save.mockResolvedValue({ ...market, is_resolved: true, resolved_outcome: 'YES' });
predictionsRepo.find.mockResolvedValue([prediction]);

await service.adminResolveMarket(
'market-1',
makeDto({ resolution_note: 'Dispute resolved by admin' }),
adminId,
);

expect(notificationsService.create).toHaveBeenCalledWith(
'user-2',
expect.any(String),
'Market Resolved',
expect.any(String),
expect.objectContaining({ resolution_note: 'Dispute resolved by admin', won: false }),
);
});
});
97 changes: 96 additions & 1 deletion backend/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
BadGatewayException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { User } from '../users/entities/user.entity';
Expand All @@ -7,12 +14,18 @@ import { Prediction } from '../predictions/entities/prediction.entity';
import { Competition } from '../competitions/entities/competition.entity';
import { ActivityLog } from '../analytics/entities/activity-log.entity';
import { AnalyticsService } from '../analytics/analytics.service';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../notifications/entities/notification.entity';
import { SorobanService } from '../soroban/soroban.service';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ActivityLogQueryDto } from './dto/activity-log-query.dto';
import { StatsResponseDto } from './dto/stats-response.dto';
import { ResolveMarketDto } from './dto/resolve-market.dto';

@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);

constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
Expand All @@ -25,6 +38,8 @@ export class AdminService {
@InjectRepository(ActivityLog)
private readonly activityLogsRepository: Repository<ActivityLog>,
private readonly analyticsService: AnalyticsService,
private readonly notificationsService: NotificationsService,
private readonly sorobanService: SorobanService,
) {}

async getStats(): Promise<StatsResponseDto> {
Expand Down Expand Up @@ -189,4 +204,84 @@ export class AdminService {
},
};
}

async adminResolveMarket(
id: string,
dto: ResolveMarketDto,
adminId: string,
): Promise<Market> {
const market = await this.marketsRepository.findOne({
where: [{ id }, { on_chain_market_id: id }],
});

if (!market) {
throw new NotFoundException(`Market "${id}" not found`);
}

if (market.is_resolved) {
throw new ConflictException('Market is already resolved');
}

if (market.is_cancelled) {
throw new BadRequestException('Cannot resolve a cancelled market');
}

if (!market.outcome_options.includes(dto.resolved_outcome)) {
throw new BadRequestException(
`Invalid outcome "${dto.resolved_outcome}". Valid options: ${market.outcome_options.join(', ')}`,
);
}

// Trigger payout distribution on-chain
try {
await this.sorobanService.resolveMarket(
market.on_chain_market_id,
dto.resolved_outcome,
);
} catch (err) {
this.logger.error('Soroban resolveMarket failed during admin resolution', err);
throw new BadGatewayException('Failed to resolve market on Soroban');
}

market.is_resolved = true;
market.resolved_outcome = dto.resolved_outcome;
const saved = await this.marketsRepository.save(market);

// Notify all participants
const predictions = await this.predictionsRepository.find({
where: { market: { id: market.id } },
relations: ['user'],
});

await Promise.all(
predictions.map((p) =>
this.notificationsService.create(
p.user.id,
NotificationType.MarketResolved,
'Market Resolved',
`The market "${market.title}" has been resolved. Winning outcome: ${dto.resolved_outcome}.`,
{
market_id: market.id,
resolved_outcome: dto.resolved_outcome,
your_prediction: p.chosen_outcome,
won: p.chosen_outcome === dto.resolved_outcome,
...(dto.resolution_note ? { resolution_note: dto.resolution_note } : {}),
},
),
),
);

// Log admin action
await this.analyticsService.logActivity(adminId, 'MARKET_RESOLVED_BY_ADMIN', {
market_id: market.id,
resolved_outcome: dto.resolved_outcome,
resolution_note: dto.resolution_note ?? null,
});

this.logger.log(
`Admin ${adminId} resolved market "${market.title}" (${market.id}) with outcome "${dto.resolved_outcome}"`,
);

return saved;
}
}
15 changes: 15 additions & 0 deletions backend/src/admin/dto/resolve-market.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class ResolveMarketDto {
@ApiProperty({ description: 'The winning outcome for the market' })
@IsString()
@IsNotEmpty()
resolved_outcome: string;

@ApiPropertyOptional({ description: 'Optional note explaining the resolution' })
@IsString()
@IsOptional()
@MaxLength(1000)
resolution_note?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
return 'USER_BANNED';
if (url.includes('/admin/users') && url.includes('/unban'))
return 'USER_UNBANNED';
if (url.includes('/admin/markets') && url.includes('/resolve'))
return 'MARKET_RESOLVED_BY_ADMIN';
return null;
}

Expand Down
Loading
Loading