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
38 changes: 27 additions & 11 deletions backend/src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import {
Body,
Controller,
Get,
Post,
Patch,
Param,
Body,
Patch,
Post,
Query,
UseGuards,
Request,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { Role } from '../common/enums/role.enum';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { AdminService } from './admin.service';
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';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
import { BanUserDto } from './dto/ban-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ModerateCommentDto } from './dto/moderate-comment.dto';
import { ReportQueryDto } from './dto/report-query.dto';
import { ResolveMarketDto } from './dto/resolve-market.dto';
import { StatsResponseDto } from './dto/stats-response.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';

@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
Expand Down Expand Up @@ -102,6 +102,22 @@ export class AdminController {
return this.adminService.moderateComment(id, dto.is_moderated, dto.reason);
}

@Patch('markets/:id/feature')
async featureMarket(@Param('id') id: string, @Request() req: any) {
return this.adminService.featureMarket(
id,
(req as { user: { id: string } }).user.id,
);
}

@Patch('markets/:id/unfeature')
async unfeatureMarket(@Param('id') id: string, @Request() req: any) {
return this.adminService.unfeatureMarket(
id,
(req as { user: { id: string } }).user.id,
);
}

@Get('reports/activity')
async getActivityReport(@Query() query: ReportQueryDto) {
return this.adminService.getActivityReport(query);
Expand Down
181 changes: 178 additions & 3 deletions backend/src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ import {
} from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Comment } from '../markets/entities/comment.entity';
import { ActivityLog } from '../analytics/entities/activity-log.entity';
import { AnalyticsService } from '../analytics/analytics.service';
import { ActivityLog } from '../analytics/entities/activity-log.entity';
import { Role } from '../common/enums/role.enum';
import { Competition } from '../competitions/entities/competition.entity';
import { Comment } from '../markets/entities/comment.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';
import { Role } from '../common/enums/role.enum';

const mockRepo = () => ({
findOne: jest.fn(),
Expand Down Expand Up @@ -208,6 +208,181 @@ describe('AdminService.adminResolveMarket', () => {
});
});

describe('AdminService.featureMarket', () => {
let service: AdminService;
let marketsRepo: ReturnType<typeof mockRepo>;
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',
is_featured: false,
featured_at: null,
...overrides,
}) as Market;

beforeEach(async () => {
marketsRepo = mockRepo();
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(Comment), useValue: mockRepo() },
{ provide: getRepositoryToken(Prediction), useValue: mockRepo() },
{ provide: getRepositoryToken(Competition), useValue: mockRepo() },
{ provide: getRepositoryToken(ActivityLog), useValue: mockRepo() },
{ provide: AnalyticsService, useValue: analyticsService },
{ provide: NotificationsService, useValue: { create: jest.fn() } },
{ provide: SorobanService, useValue: { resolveMarket: jest.fn() } },
],
}).compile();

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

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

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

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

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

it('features market and logs admin action', async () => {
const market = makeMarket();
const featuredMarket = {
...market,
is_featured: true,
featured_at: new Date(),
};

marketsRepo.findOne.mockResolvedValue(market);
marketsRepo.save.mockResolvedValue(featuredMarket);

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

expect(marketsRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
is_featured: true,
featured_at: expect.any(Date),
}),
);
expect(analyticsService.logActivity).toHaveBeenCalledWith(
adminId,
'MARKET_FEATURED_BY_ADMIN',
expect.objectContaining({
market_id: 'market-1',
featured_at: expect.any(Date),
}),
);
expect(result.is_featured).toBe(true);
expect(result.featured_at).toBeInstanceOf(Date);
expect(result.featured_at).not.toBeNull();
});
});

describe('AdminService.unfeatureMarket', () => {
let service: AdminService;
let marketsRepo: ReturnType<typeof mockRepo>;
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',
is_featured: true,
featured_at: new Date(),
...overrides,
}) as Market;

beforeEach(async () => {
marketsRepo = mockRepo();
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(Comment), useValue: mockRepo() },
{ provide: getRepositoryToken(Prediction), useValue: mockRepo() },
{ provide: getRepositoryToken(Competition), useValue: mockRepo() },
{ provide: getRepositoryToken(ActivityLog), useValue: mockRepo() },
{ provide: AnalyticsService, useValue: analyticsService },
{ provide: NotificationsService, useValue: { create: jest.fn() } },
{ provide: SorobanService, useValue: { resolveMarket: jest.fn() } },
],
}).compile();

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

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

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

it('throws ConflictException when market is not featured', async () => {
marketsRepo.findOne.mockResolvedValue(makeMarket({ is_featured: false }));

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

it('unfeatures market and logs admin action', async () => {
const market = makeMarket();
const unfeaturedMarket = {
...market,
is_featured: false,
featured_at: null,
};

marketsRepo.findOne.mockResolvedValue(market);
marketsRepo.save.mockResolvedValue(unfeaturedMarket);

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

expect(marketsRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
is_featured: false,
featured_at: null,
}),
);
expect(analyticsService.logActivity).toHaveBeenCalledWith(
adminId,
'MARKET_UNFEATURED_BY_ADMIN',
expect.objectContaining({
market_id: 'market-1',
unfeatured_at: expect.any(Date),
}),
);
expect(result.is_featured).toBe(false);
expect(result.featured_at).toBeNull();
});
});

describe('AdminService.updateUserRole', () => {
let service: AdminService;
let usersRepo: ReturnType<typeof mockRepo>;
Expand Down
100 changes: 84 additions & 16 deletions backend/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
BadGatewayException,
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { User } from '../users/entities/user.entity';
import { Market } from '../markets/entities/market.entity';
import { Comment } from '../markets/entities/comment.entity';
import { Prediction } from '../predictions/entities/prediction.entity';
import { Competition } from '../competitions/entities/competition.entity';
import { ActivityLog } from '../analytics/entities/activity-log.entity';
import { Between, Repository } from 'typeorm';
import { AnalyticsService } from '../analytics/analytics.service';
import { NotificationsService } from '../notifications/notifications.service';
import { ActivityLog } from '../analytics/entities/activity-log.entity';
import { Competition } from '../competitions/entities/competition.entity';
import { Comment } from '../markets/entities/comment.entity';
import { Market } from '../markets/entities/market.entity';
import { NotificationType } from '../notifications/entities/notification.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { Prediction } from '../predictions/entities/prediction.entity';
import { SorobanService } from '../soroban/soroban.service';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { User } from '../users/entities/user.entity';
import { ActivityLogQueryDto } from './dto/activity-log-query.dto';
import { StatsResponseDto } from './dto/stats-response.dto';
import { ResolveMarketDto } from './dto/resolve-market.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import {
ReportFormat,
ReportQueryDto,
ReportTimeframe,
} from './dto/report-query.dto';
import { ResolveMarketDto } from './dto/resolve-market.dto';
import { StatsResponseDto } from './dto/stats-response.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';

@Injectable()
export class AdminService {
Expand Down Expand Up @@ -352,6 +352,74 @@ export class AdminService {
return await this.commentsRepository.save(comment);
}

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

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

if (market.is_featured) {
throw new ConflictException('Market is already featured');
}

market.is_featured = true;
market.featured_at = new Date();
const saved = await this.marketsRepository.save(market);

// Log admin action
await this.analyticsService.logActivity(
adminId,
'MARKET_FEATURED_BY_ADMIN',
{
market_id: market.id,
featured_at: market.featured_at,
},
);

this.logger.log(
`Admin ${adminId} featured market "${market.title}" (${market.id})`,
);

return saved;
}

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

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

if (!market.is_featured) {
throw new ConflictException('Market is not featured');
}

market.is_featured = false;
market.featured_at = null;
const saved = await this.marketsRepository.save(market);

// Log admin action
await this.analyticsService.logActivity(
adminId,
'MARKET_UNFEATURED_BY_ADMIN',
{
market_id: market.id,
unfeatured_at: new Date(),
},
);

this.logger.log(
`Admin ${adminId} unfeatured market "${market.title}" (${market.id})`,
);

return saved;
}

async getActivityReport(query: ReportQueryDto) {
const { timeframe, format } = query;
const now = new Date();
Expand Down
Loading
Loading