diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index dd76b9817..db4aa9f46 100644 --- a/backend/src/analytics/analytics.controller.ts +++ b/backend/src/analytics/analytics.controller.ts @@ -16,6 +16,7 @@ import { MarketAnalyticsDto } from './dto/market-analytics.dto'; import { MarketHistoryResponseDto } from './dto/market-history.dto'; import { UserTrendsDto } from './dto/user-trends.dto'; import { CategoryAnalyticsResponseDto } from './dto/category-analytics.dto'; +import { PlatformStatsDto } from './dto/platform-stats.dto'; @ApiTags('Analytics') @Controller('analytics') @@ -126,4 +127,19 @@ export class AnalyticsController { async getCategoryAnalytics(): Promise { return this.analyticsService.getCategoryAnalytics(); } + + @Get('platform') + @Public() + @UseInterceptors(CacheInterceptor) + @CacheTTL(60) + @ApiOperation({ summary: 'Get platform-wide public statistics' }) + @ApiResponse({ + status: 200, + description: + 'Platform statistics: total markets, predictions, volume, active users, and active markets', + type: PlatformStatsDto, + }) + async getPlatformStats(): Promise { + return this.analyticsService.getPlatformStats(); + } } diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts index c95f66f59..35ddd32d8 100644 --- a/backend/src/analytics/analytics.service.ts +++ b/backend/src/analytics/analytics.service.ts @@ -22,6 +22,7 @@ import { CategoryStatsDto, CategoryAnalyticsResponseDto, } from './dto/category-analytics.dto'; +import { PlatformStatsDto } from './dto/platform-stats.dto'; /** Tier thresholds: Bronze < 200, Silver < 500, Gold < 1000, Platinum ≥ 1000 */ export function predictorTierFromReputation(reputationScore: number): string { @@ -518,4 +519,31 @@ export class AnalyticsService { const activeRatio = active / total; return activeRatio > 0.5; } + + async getPlatformStats(): Promise { + const [total_markets, total_predictions, active_markets, active_users] = + await Promise.all([ + this.marketsRepository.count(), + this.predictionsRepository.count(), + this.marketsRepository.count({ + where: { is_resolved: false, is_cancelled: false }, + }), + this.usersRepository.count(), + ]); + + const volumeResult = await this.marketsRepository + .createQueryBuilder('market') + .select('SUM(CAST(market.total_pool_stroops AS BIGINT))', 'total') + .getRawOne<{ total: string | null }>(); + + const total_volume_stroops = volumeResult?.total ?? '0'; + + return { + total_markets, + total_predictions, + total_volume_stroops, + active_users, + active_markets, + }; + } } diff --git a/backend/src/analytics/dto/platform-stats.dto.ts b/backend/src/analytics/dto/platform-stats.dto.ts new file mode 100644 index 000000000..3a6dde53a --- /dev/null +++ b/backend/src/analytics/dto/platform-stats.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PlatformStatsDto { + @ApiProperty({ example: 420 }) + total_markets: number; + + @ApiProperty({ example: 8750 }) + total_predictions: number; + + @ApiProperty({ + example: '52000000000', + description: 'Sum of all market pool sizes in stroops (string bigint)', + }) + total_volume_stroops: string; + + @ApiProperty({ example: 1200 }) + active_users: number; + + @ApiProperty({ example: 38 }) + active_markets: number; +} diff --git a/backend/src/markets/dto/list-comments.dto.ts b/backend/src/markets/dto/list-comments.dto.ts new file mode 100644 index 000000000..ec5f3b465 --- /dev/null +++ b/backend/src/markets/dto/list-comments.dto.ts @@ -0,0 +1,20 @@ +import { IsOptional, IsNumber, Min, Max } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class ListCommentsDto { + @ApiPropertyOptional({ description: 'Page number', default: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: 'Items per page (max 50)', default: 20 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(50) + limit?: number = 20; +} diff --git a/backend/src/markets/markets.controller.ts b/backend/src/markets/markets.controller.ts index f7725290d..8e7342b9b 100644 --- a/backend/src/markets/markets.controller.ts +++ b/backend/src/markets/markets.controller.ts @@ -26,6 +26,7 @@ import { BulkCreateMarketsDto } from './dto/bulk-create-markets.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; import { CreateDisputeDto } from '../disputes/dto/create-dispute.dto'; import { CreateMarketDto } from './dto/create-market.dto'; +import { ListCommentsDto } from './dto/list-comments.dto'; import { UpdateMarketDto } from './dto/update-market.dto'; import { ListMarketsDto, @@ -248,15 +249,17 @@ export class MarketsController { @Get(':id/comments') @Public() - @ApiOperation({ summary: 'Get comments for a market' }) + @ApiOperation({ summary: 'Get comments for a market (paginated)' }) @ApiResponse({ status: 200, - description: 'List of comments (nested structure)', - type: [Comment], + description: 'Paginated list of comments', }) @ApiResponse({ status: 404, description: 'Market not found' }) - async getComments(@Param('id') id: string): Promise { - return this.marketsService.getComments(id); + async getComments( + @Param('id') id: string, + @Query() query: ListCommentsDto, + ): Promise<{ data: Comment[]; total: number; page: number; limit: number }> { + return this.marketsService.getComments(id, query.page, query.limit); } @Get(':id/report') diff --git a/backend/src/markets/markets.service.ts b/backend/src/markets/markets.service.ts index f13297535..964819ce7 100644 --- a/backend/src/markets/markets.service.ts +++ b/backend/src/markets/markets.service.ts @@ -608,43 +608,26 @@ export class MarketsService { } /** - * Get all comments for a market, including nested replies + * Get paginated comments for a market */ - async getComments(marketId: string): Promise { + async getComments( + marketId: string, + page = 1, + limit = 20, + ): Promise<{ data: Comment[]; total: number; page: number; limit: number }> { const market = await this.findByIdOrOnChainId(marketId); + const take = Math.min(limit, 50); + const skip = (page - 1) * take; - // Fetch all comments for this market - const comments = await this.commentsRepository.find({ + const [data, total] = await this.commentsRepository.findAndCount({ where: { market: { id: market.id } }, relations: ['author', 'parent'], order: { created_at: 'ASC' }, + skip, + take, }); - // Build nested structure - const commentMap = new Map(); - const roots: Comment[] = []; - - comments.forEach((c) => { - const commentWithReplies = { ...c, replies: [] }; - commentMap.set(c.id, commentWithReplies); - }); - - comments.forEach((c) => { - const commentWithReplies = commentMap.get(c.id)!; - if (c.parent) { - const parent = commentMap.get(c.parent.id); - if (parent) { - parent.replies.push(commentWithReplies); - } else { - // Parent might not be in this market, which shouldn't happen - roots.push(commentWithReplies); - } - } else { - roots.push(commentWithReplies); - } - }); - - return roots; + return { data, total, page, limit: take }; } /** diff --git a/backend/src/notifications/notifications.service.spec.ts b/backend/src/notifications/notifications.service.spec.ts index 158e9169f..9aa57435d 100644 --- a/backend/src/notifications/notifications.service.spec.ts +++ b/backend/src/notifications/notifications.service.spec.ts @@ -172,7 +172,7 @@ describe('NotificationsService', () => { }); describe('markAsRead', () => { - it('should update notification read to true', async () => { + it('should update notification read to true and broadcast when owner matches', async () => { mockRepository.update.mockResolvedValue({ affected: 1 }); await service.markAsRead(1, 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN'); @@ -185,6 +185,18 @@ describe('NotificationsService', () => { mockNotificationBroadcaster.broadcastNotificationRead, ).toHaveBeenCalledWith('GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', 1); }); + + it('should throw NotFoundException when notification belongs to a different user', async () => { + mockRepository.update.mockResolvedValue({ affected: 0 }); + + await expect( + service.markAsRead(1, 'GDIFFERENTADDRESS'), + ).rejects.toThrow(NotFoundException); + + expect( + mockNotificationBroadcaster.broadcastNotificationRead, + ).not.toHaveBeenCalled(); + }); }); describe('markAllAsRead', () => { diff --git a/backend/src/notifications/notifications.service.ts b/backend/src/notifications/notifications.service.ts index 60635cf45..07b5708ef 100644 --- a/backend/src/notifications/notifications.service.ts +++ b/backend/src/notifications/notifications.service.ts @@ -81,11 +81,15 @@ export class NotificationsService { } async markAsRead(id: number, userAddress: string): Promise { - await this.notificationsRepository.update( + const result = await this.notificationsRepository.update( { id, user_address: userAddress }, { read: true }, ); + if (!result.affected) { + throw new NotFoundException('Notification not found'); + } + // Broadcast read status via WebSocket this.notificationBroadcaster.broadcastNotificationRead(userAddress, id); }