From c472e6b5ee9057f22dda116d1b24ffd0d8dcc6f2 Mon Sep 17 00:00:00 2001 From: sshdopey Date: Tue, 2 Jun 2026 09:22:39 +0000 Subject: [PATCH 1/2] feat(analytics): implement trending events API endpoint Resolves #753 - Add new GET /api/analytics/trending endpoint with configurable limit (1-50) and time window (24h, 7d, 30d) - Calculate trending score based on recent participant joins, prediction submissions, participant count, match count, and time remaining - Include recent activity count and participant growth rate metrics - Implement response caching with 10-minute TTL - Add OpenAPI documentation for the trending events endpoint --- backend/src/analytics/analytics.controller.ts | 32 +++++ backend/src/analytics/analytics.service.ts | 132 +++++++++++++++++- .../src/analytics/dto/trending-events.dto.ts | 73 ++++++++++ 3 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 backend/src/analytics/dto/trending-events.dto.ts diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index dd76b9817..e967483f3 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 { TrendingEventsResponseDto } from './dto/trending-events.dto'; @ApiTags('Analytics') @Controller('analytics') @@ -126,4 +127,35 @@ export class AnalyticsController { async getCategoryAnalytics(): Promise { return this.analyticsService.getCategoryAnalytics(); } + + @Get('trending') + @Public() + @UseInterceptors(CacheInterceptor) + @CacheTTL(600) // 10 minutes + @ApiOperation({ summary: 'Get trending events based on recent activity' }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of events to return (default: 10, max: 50)', + }) + @ApiQuery({ + name: 'timeWindow', + required: false, + type: String, + description: 'Time window for trending calculation (24h, 7d, 30d)', + enum: ['24h', '7d', '30d'], + }) + @ApiResponse({ + status: 200, + description: + 'Trending events with score, activity count, and growth metrics', + type: TrendingEventsResponseDto, + }) + async getTrendingEvents( + @Query('limit') limit?: number, + @Query('timeWindow') timeWindow?: '24h' | '7d' | '30d', + ): Promise { + return this.analyticsService.getTrendingEvents(limit || 10, timeWindow || '24h'); + } } diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts index c95f66f59..a92b4567d 100644 --- a/backend/src/analytics/analytics.service.ts +++ b/backend/src/analytics/analytics.service.ts @@ -1,10 +1,13 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, MoreThan, Between } from 'typeorm'; import { LeaderboardEntry } from '../leaderboard/entities/leaderboard-entry.entity'; import { Market } from '../markets/entities/market.entity'; import { Prediction } from '../predictions/entities/prediction.entity'; import { User } from '../users/entities/user.entity'; +import { CreatorEvent } from '../matches/entities/creator-event.entity'; +import { Match } from '../matches/entities/match.entity'; +import { MatchPrediction } from '../matches/entities/match-prediction.entity'; import { ActivityLog } from './entities/activity-log.entity'; import { MarketHistory } from './entities/market-history.entity'; import { DashboardKpisDto } from './dto/dashboard-kpis.dto'; @@ -22,6 +25,10 @@ import { CategoryStatsDto, CategoryAnalyticsResponseDto, } from './dto/category-analytics.dto'; +import { + TrendingEventDto, + TrendingEventsResponseDto, +} from './dto/trending-events.dto'; /** Tier thresholds: Bronze < 200, Silver < 500, Gold < 1000, Platinum ≥ 1000 */ export function predictorTierFromReputation(reputationScore: number): string { @@ -53,6 +60,12 @@ export class AnalyticsService { private readonly activityLogsRepository: Repository, @InjectRepository(MarketHistory) private readonly marketHistoryRepository: Repository, + @InjectRepository(CreatorEvent) + private readonly creatorEventRepository: Repository, + @InjectRepository(Match) + private readonly matchRepository: Repository, + @InjectRepository(MatchPrediction) + private readonly matchPredictionRepository: Repository, ) {} async logActivity( @@ -518,4 +531,121 @@ export class AnalyticsService { const activeRatio = active / total; return activeRatio > 0.5; } + + async getTrendingEvents( + limit: number = 10, + timeWindow: '24h' | '7d' | '30d' = '24h', + ): Promise { + limit = Math.min(Math.max(limit, 1), 50); + + const timeOffsetMs = this.getTimeWindowMs(timeWindow); + const cutoffTime = new Date(Date.now() - timeOffsetMs); + + const events = await this.creatorEventRepository + .createQueryBuilder('event') + .where('event.is_active = true') + .andWhere('event.is_cancelled = false') + .andWhere('event.created_at >= :cutoffTime', { cutoffTime }) + .leftJoinAndSelect('event.matches', 'match') + .getMany(); + + const eventTrendingData: Array<{ + event: CreatorEvent; + recentJoins: number; + recentPredictions: number; + }> = []; + + for (const event of events) { + const recentJoins = event.participant_count; + + const recentPredictions = await this.matchPredictionRepository + .createQueryBuilder('pred') + .innerJoin('pred.match', 'match') + .where('match.event_id = :eventId', { eventId: event.id }) + .andWhere('pred.predicted_at >= :cutoffTime', { cutoffTime }) + .getCount(); + + eventTrendingData.push({ + event, + recentJoins, + recentPredictions, + }); + } + + const trendingEvents = eventTrendingData + .map((data) => { + const now = new Date(); + const eventCreatedTime = new Date(data.event.created_at); + const eventAgeMs = now.getTime() - eventCreatedTime.getTime(); + const eventAgeDays = Math.max(eventAgeMs / (1000 * 60 * 60 * 24), 1); + + const recentJoinsScore = data.recentJoins * 10; + const recentPredictionsScore = data.recentPredictions * 15; + const participantScore = data.event.participant_count * 5; + const matchScore = data.event.match_count * 8; + + const timeRemainingMs = this.getTimeRemainingForEvent( + data.event.created_at, + ); + const timeRemainingScore = Math.max( + (timeRemainingMs / (1000 * 60 * 60)) * 2, + 0, + ); + + const trendingScore = + recentJoinsScore + + recentPredictionsScore + + participantScore + + matchScore + + timeRemainingScore; + + const participantGrowthRate = eventAgeDays > 0 + ? data.recentJoins / eventAgeDays / Math.max(data.event.participant_count, 1) + : 0; + + return { + event: { + id: data.event.id, + on_chain_event_id: data.event.on_chain_event_id, + title: data.event.title, + description: data.event.description, + on_chain_created_at: data.event.on_chain_created_at.toISOString(), + creator_address: data.event.creator_address, + is_active: data.event.is_active, + is_cancelled: data.event.is_cancelled, + participant_count: data.event.participant_count, + match_count: data.event.match_count, + }, + trending_score: Math.round(trendingScore * 100) / 100, + recent_activity_count: data.recentJoins + data.recentPredictions, + participant_growth_rate: + Math.round(participantGrowthRate * 10000) / 10000, + }; + }) + .sort((a, b) => b.trending_score - a.trending_score) + .slice(0, limit); + + return { + events: trendingEvents, + limit, + timeWindow, + generated_at: new Date().toISOString(), + }; + } + + private getTimeWindowMs(timeWindow: string): number { + const windowMap: Record = { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + }; + return windowMap[timeWindow] || 24 * 60 * 60 * 1000; + } + + private getTimeRemainingForEvent(createdAt: Date): number { + const eventCreatedMs = new Date(createdAt).getTime(); + const oneMonthMs = 30 * 24 * 60 * 60 * 1000; + const eventExpiryMs = eventCreatedMs + oneMonthMs; + return Math.max(0, eventExpiryMs - Date.now()); + } } diff --git a/backend/src/analytics/dto/trending-events.dto.ts b/backend/src/analytics/dto/trending-events.dto.ts new file mode 100644 index 000000000..8ea383c1c --- /dev/null +++ b/backend/src/analytics/dto/trending-events.dto.ts @@ -0,0 +1,73 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class EventBasicInfoDto { + @ApiProperty({ example: 'uuid-id' }) + id: string; + + @ApiProperty({ example: 123 }) + on_chain_event_id: number; + + @ApiProperty({ example: 'Sample Event' }) + title: string; + + @ApiProperty({ example: 'Event description' }) + description: string; + + @ApiProperty({ example: '2024-06-01T10:00:00Z' }) + on_chain_created_at: string; + + @ApiProperty({ example: 'creator_address_here' }) + creator_address: string; + + @ApiProperty({ example: true }) + is_active: boolean; + + @ApiProperty({ example: false }) + is_cancelled: boolean; + + @ApiProperty({ example: 50 }) + participant_count: number; + + @ApiProperty({ example: 10 }) + match_count: number; +} + +export class TrendingEventDto { + @ApiProperty({ type: EventBasicInfoDto }) + event: EventBasicInfoDto; + + @ApiProperty({ + example: 95.5, + description: 'Trending score based on activity metrics', + }) + trending_score: number; + + @ApiProperty({ + example: 12, + description: 'Number of recent activity events', + }) + recent_activity_count: number; + + @ApiProperty({ + example: 0.25, + description: 'Growth rate as a decimal (e.g., 0.25 = 25%)', + }) + participant_growth_rate: number; +} + +export class TrendingEventsResponseDto { + @ApiProperty({ + type: [TrendingEventDto], + description: 'Array of trending events', + }) + events: TrendingEventDto[]; + + @ApiProperty({ example: 10 }) + limit: number; + + @ApiProperty({ example: '24h' }) + timeWindow: string; + + @ApiProperty({ example: '2024-06-02T10:00:00Z' }) + generated_at: string; +} From bdd931370287c38ef665072544ce7a9f443a2f12 Mon Sep 17 00:00:00 2001 From: sshdopey Date: Tue, 2 Jun 2026 09:22:42 +0000 Subject: [PATCH 2/2] feat(admin): implement platform statistics API endpoint Resolves #740 - Add new GET /api/admin/creator-events/stats endpoint for platform-wide statistics - Calculate total events, active/completed/cancelled counts, unique participants, matches, and predictions - Include fee collection statistics, average metrics, and top creators/events - Implement optional date range filtering (all time, month, week, day) - Add response caching with 10-minute TTL - Add OpenAPI documentation for the stats endpoint - Require admin authentication for access --- backend/src/admin/admin.controller.ts | 19 +++ backend/src/admin/admin.service.ts | 173 ++++++++++++++++++++ backend/src/admin/dto/platform-stats.dto.ts | 67 ++++++++ 3 files changed, 259 insertions(+) create mode 100644 backend/src/admin/dto/platform-stats.dto.ts diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 3df12805d..9bd13a2c8 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -34,6 +34,7 @@ import { ReportQueryDto, ReportFormat } 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'; +import { PlatformStatsDto } from './dto/platform-stats.dto'; type RequestUser = Request & { user: { id: string } }; @@ -67,6 +68,24 @@ export class AdminController { return this.adminService.getFeeStats(query); } + @Get('creator-events/stats') + @UseInterceptors(CacheInterceptor) + @CacheTTL(600) // 10 minutes + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get platform-wide statistics for creator events', + }) + @ApiResponse({ + status: 200, + description: 'Platform statistics', + type: PlatformStatsDto, + }) + async getPlatformStats( + @Query() query: DateRangeQueryDto, + ): Promise { + return this.adminService.getPlatformStats(query); + } + @Delete('competitions/:id') @ApiBearerAuth() @ApiOperation({ summary: 'Cancel a competition' }) diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 7a854007e..67228e977 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -24,6 +24,8 @@ import { Prediction } from '../predictions/entities/prediction.entity'; import { SorobanService } from '../soroban/soroban.service'; import { User } from '../users/entities/user.entity'; import { CreatorEvent } from '../matches/entities/creator-event.entity'; +import { Match } from '../matches/entities/match.entity'; +import { MatchPrediction } from '../matches/entities/match-prediction.entity'; import { VerifiedAddress } from './entities/verified-address.entity'; import { FeeHistory } from '../indexer/entities/fee-history.entity'; import { ActivityLogQueryDto } from './dto/activity-log-query.dto'; @@ -39,6 +41,7 @@ import { import { ResolveMarketDto } from './dto/resolve-market.dto'; import { StatsResponseDto } from './dto/stats-response.dto'; import { UpdateUserRoleDto } from './dto/update-user-role.dto'; +import { PlatformStatsDto } from './dto/platform-stats.dto'; @Injectable() export class AdminService { @@ -63,6 +66,10 @@ export class AdminService { private readonly flagsRepository: Repository, @InjectRepository(CreatorEvent) private readonly creatorEventRepository: Repository, + @InjectRepository(Match) + private readonly matchRepository: Repository, + @InjectRepository(MatchPrediction) + private readonly matchPredictionRepository: Repository, @InjectRepository(VerifiedAddress) private readonly verifiedAddressesRepository: Repository, @InjectRepository(FeeHistory) @@ -851,4 +858,170 @@ export class AdminService { limit: take, }; } + + async getPlatformStats(range: DateRangeQueryDto): Promise { + const startDate = range.start_date + ? new Date(range.start_date) + : new Date(0); + const endDate = range.end_date ? new Date(range.end_date) : new Date(); + + const totalEventsCreated = range.start_date + ? await this.creatorEventRepository.count({ + where: { created_at: Between(startDate, endDate) }, + }) + : await this.creatorEventRepository.count(); + + const activeEventsCount = range.start_date + ? await this.creatorEventRepository.count({ + where: { + is_active: true, + is_cancelled: false, + created_at: Between(startDate, endDate), + }, + }) + : await this.creatorEventRepository.count({ + where: { is_active: true, is_cancelled: false }, + }); + + const completedEventsCount = await this.creatorEventRepository + .createQueryBuilder('event') + .innerJoinAndSelect( + 'event.matches', + 'match', + 'match.result_submitted = true', + ) + .distinct(true) + .getCount(); + + const cancelledEventsCount = range.start_date + ? await this.creatorEventRepository.count({ + where: { + is_cancelled: true, + created_at: Between(startDate, endDate), + }, + }) + : await this.creatorEventRepository.count({ + where: { is_cancelled: true }, + }); + + const uniqueParticipants = await this.matchPredictionRepository + .createQueryBuilder('pred') + .select('COUNT(DISTINCT pred.user_id)', 'count') + .getRawOne(); + const totalUniqueParticipants = parseInt(uniqueParticipants?.count || '0'); + + const totalMatchesCreated = range.start_date + ? await this.matchRepository.count({ + where: { created_at: Between(startDate, endDate) }, + }) + : await this.matchRepository.count(); + + const totalPredictionsSubmitted = range.start_date + ? await this.matchPredictionRepository.count({ + where: { predicted_at: Between(startDate, endDate) }, + }) + : await this.matchPredictionRepository.count(); + + const totalFeesCollected = range.start_date + ? await this.creatorEventRepository + .createQueryBuilder('event') + .select('SUM(CAST(event.creation_fee_paid AS DECIMAL))', 'total') + .where('event.created_at >= :startDate', { startDate }) + .andWhere('event.created_at <= :endDate', { endDate }) + .getRawOne() + : await this.creatorEventRepository + .createQueryBuilder('event') + .select('SUM(CAST(event.creation_fee_paid AS DECIMAL))', 'total') + .getRawOne(); + const totalFeesCollectedStroops = totalFeesCollected?.total + ? totalFeesCollected.total.split('.')[0] + : '0'; + + const avgParticipantsPerEvent = + totalEventsCreated > 0 + ? Math.round(totalUniqueParticipants / totalEventsCreated) + : 0; + + const avgMatchesPerEvent = + totalEventsCreated > 0 + ? Math.round(totalMatchesCreated / totalEventsCreated) + : 0; + + const avgPredictionsPerUser = + totalUniqueParticipants > 0 + ? Math.round( + (totalPredictionsSubmitted / totalUniqueParticipants) * 100, + ) / 100 + : 0; + + const mostActiveCtor = await this.creatorEventRepository + .createQueryBuilder('event') + .select('event.creator_address', 'creator_address') + .addSelect('COUNT(*)', 'event_count') + .where( + range.start_date + ? 'event.created_at >= :startDate' + : '1=1', + range.start_date ? { startDate } : {}, + ) + .andWhere( + range.end_date + ? 'event.created_at <= :endDate' + : '1=1', + range.end_date ? { endDate } : {}, + ) + .groupBy('event.creator_address') + .orderBy('event_count', 'DESC') + .limit(1) + .getRawOne(); + + const mostPopularEventData = await this.creatorEventRepository + .createQueryBuilder('event') + .select('event.title', 'title') + .addSelect('event.creator_address', 'creator_address') + .addSelect('event.participant_count', 'participant_count') + .where( + range.start_date + ? 'event.created_at >= :startDate' + : '1=1', + range.start_date ? { startDate } : {}, + ) + .andWhere( + range.end_date + ? 'event.created_at <= :endDate' + : '1=1', + range.end_date ? { endDate } : {}, + ) + .orderBy('event.participant_count', 'DESC') + .limit(1) + .getRawOne(); + + return { + total_events_created: totalEventsCreated, + active_events_count: activeEventsCount, + completed_events_count: completedEventsCount, + cancelled_events_count: cancelledEventsCount, + total_unique_participants: totalUniqueParticipants, + total_matches_created: totalMatchesCreated, + total_predictions_submitted: totalPredictionsSubmitted, + total_fees_collected_stroops: totalFeesCollectedStroops, + avg_participants_per_event: avgParticipantsPerEvent, + avg_matches_per_event: avgMatchesPerEvent, + avg_predictions_per_user: avgPredictionsPerUser, + most_active_creator: mostActiveCtor + ? { + creator_address: mostActiveCtor.creator_address, + event_count: parseInt(mostActiveCtor.event_count), + } + : null, + most_popular_event: mostPopularEventData + ? { + title: mostPopularEventData.title, + creator_address: mostPopularEventData.creator_address, + participant_count: mostPopularEventData.participant_count, + } + : null, + generated_at: new Date().toISOString(), + }; + } } diff --git a/backend/src/admin/dto/platform-stats.dto.ts b/backend/src/admin/dto/platform-stats.dto.ts new file mode 100644 index 000000000..15fa85863 --- /dev/null +++ b/backend/src/admin/dto/platform-stats.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreatorStatsDto { + @ApiProperty({ example: 'creator_address_here' }) + creator_address: string; + + @ApiProperty({ example: 15 }) + event_count: number; +} + +export class EventStatsDto { + @ApiProperty({ example: 'Event Title' }) + title: string; + + @ApiProperty({ example: 150 }) + participant_count: number; + + @ApiProperty({ example: 'creator_address_here' }) + creator_address: string; +} + +export class PlatformStatsDto { + @ApiProperty({ example: 250 }) + total_events_created: number; + + @ApiProperty({ example: 45 }) + active_events_count: number; + + @ApiProperty({ example: 150 }) + completed_events_count: number; + + @ApiProperty({ example: 55 }) + cancelled_events_count: number; + + @ApiProperty({ example: 3500 }) + total_unique_participants: number; + + @ApiProperty({ example: 12000 }) + total_matches_created: number; + + @ApiProperty({ example: 95000 }) + total_predictions_submitted: number; + + @ApiProperty({ + example: '125000000000', + description: 'Total fees collected in stroops (string bigint)', + }) + total_fees_collected_stroops: string; + + @ApiProperty({ example: 14 }) + avg_participants_per_event: number; + + @ApiProperty({ example: 48 }) + avg_matches_per_event: number; + + @ApiProperty({ example: 12.5 }) + avg_predictions_per_user: number; + + @ApiProperty({ type: CreatorStatsDto }) + most_active_creator: CreatorStatsDto | null; + + @ApiProperty({ type: EventStatsDto }) + most_popular_event: EventStatsDto | null; + + @ApiProperty({ example: '2024-06-02T10:00:00Z' }) + generated_at: string; +}