import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, MoreThan, Between } from 'typeorm'; import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity'; import { ServerStats } from '../../entities/server-stats.entity'; import { WipeHistory } from '../../entities/wipe-history.entity'; import { PlayerSession } from '../../entities/player-session.entity'; import { MapLibrary } from '../../entities/map-library.entity'; @Injectable() export class AnalyticsService { constructor( @InjectRepository(ServerStatsHourly) private readonly statsHourlyRepo: Repository, @InjectRepository(ServerStats) private readonly statsRepo: Repository, @InjectRepository(WipeHistory) private readonly wipeHistoryRepo: Repository, @InjectRepository(PlayerSession) private readonly playerSessionRepo: Repository, @InjectRepository(MapLibrary) private readonly mapLibraryRepo: Repository, ) {} async getSummary(licenseId: string, rangeHours: number) { const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000); const stats = await this.statsHourlyRepo .createQueryBuilder('stats') .select('MAX(stats.max_players)', 'peak_players') .addSelect('AVG(stats.avg_players)', 'avg_players') .addSelect('AVG(stats.uptime_percentage)', 'uptime_percentage') .where('stats.license_id = :licenseId', { licenseId }) .andWhere('stats.hour >= :cutoff', { cutoff }) .getRawOne(); return { peak_players: stats?.peak_players || 0, avg_players: parseFloat(stats?.avg_players || 0), uptime_percentage: parseFloat(stats?.uptime_percentage || 0), unique_players: null, // Not implemented yet }; } async getTimeseries(licenseId: string, rangeHours: number, granularity: 'raw' | 'hourly') { const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000); if (granularity === 'hourly') { const data = await this.statsHourlyRepo.find({ where: { license_id: licenseId, hour: MoreThan(cutoff) }, order: { hour: 'ASC' }, }); return { timestamps: data.map(d => d.hour), player_count: data.map(d => d.avg_players), fps: data.map(d => d.avg_fps), entity_count: data.map(d => d.avg_entities), memory_usage_mb: data.map(() => null), // Not in schema }; } else { const data = await this.statsRepo.find({ where: { license_id: licenseId, recorded_at: MoreThan(cutoff) }, order: { recorded_at: 'ASC' }, }); return { timestamps: data.map(d => d.recorded_at), player_count: data.map(d => d.player_count), fps: data.map(d => d.fps), entity_count: data.map(d => d.entity_count), memory_usage_mb: data.map(d => d.memory_usage_mb), }; } } async getWipePerformance(licenseId: string, rangeHours: number) { const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000); const wipes = await this.wipeHistoryRepo.find({ where: { license_id: licenseId, status: 'success', started_at: MoreThan(cutoff), }, order: { started_at: 'DESC' }, }); const durations = wipes .filter(w => w.started_at && w.completed_at) .map(w => (w.completed_at!.getTime() - w.started_at!.getTime()) / 1000); return { total_wipes: wipes.length, avg_duration_seconds: durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0, min_duration_seconds: durations.length > 0 ? Math.min(...durations) : 0, max_duration_seconds: durations.length > 0 ? Math.max(...durations) : 0, wipe_types: wipes.reduce((acc, w) => { acc[w.wipe_type] = (acc[w.wipe_type] || 0) + 1; return acc; }, {} as Record), }; } async getMapAnalytics(licenseId: string, rangeHours: number) { const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000); const mapUsage = await this.wipeHistoryRepo .createQueryBuilder('wipe') .leftJoinAndSelect('wipe.map', 'map') .select('map.id', 'map_id') .addSelect('map.name', 'map_name') .addSelect('COUNT(wipe.id)', 'usage_count') .where('wipe.license_id = :licenseId', { licenseId }) .andWhere('wipe.started_at >= :cutoff', { cutoff }) .andWhere('wipe.map_id IS NOT NULL') .groupBy('map.id') .addGroupBy('map.name') .getRawMany(); return { map_usage: mapUsage.map(m => ({ map_id: m.map_id, map_name: m.map_name, usage_count: parseInt(m.usage_count), })), }; } async getPlayerAnalytics(licenseId: string, rangeHours: number, metric: 'sessions' | 'retention') { const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000); if (metric === 'sessions') { const sessions = await this.playerSessionRepo.find({ where: { license_id: licenseId, session_start: MoreThan(cutoff), }, order: { session_start: 'DESC' }, }); const totalDuration = sessions .filter(s => s.duration_seconds) .reduce((sum, s) => sum + (s.duration_seconds || 0), 0); return { total_sessions: sessions.length, avg_session_duration: sessions.length > 0 ? totalDuration / sessions.length : 0, unique_players: new Set(sessions.map(s => s.steam_id)).size, }; } return { message: 'Retention metric not implemented' }; } async getRetention(licenseId: string, wipeCount: number) { const recentWipes = await this.wipeHistoryRepo.find({ where: { license_id: licenseId, status: 'success' }, order: { started_at: 'DESC' }, take: wipeCount, }); if (recentWipes.length === 0) { return { wipe_count: 0, retention_data: [] }; } const retentionData = await Promise.all( recentWipes.map(async (wipe) => { const wipeDate = wipe.started_at; // Find the next wipe chronologically after this one (wipes are DESC ordered) const nextWipe = recentWipes .slice() .reverse() .find(w => w.started_at && wipeDate && w.started_at > wipeDate); const endDate = nextWipe?.started_at || new Date(); // Query sessions strictly within this wipe cycle: [wipeDate, endDate) const sessionsInPeriod = await this.playerSessionRepo.find({ where: { license_id: licenseId, session_start: Between(wipeDate!, endDate), }, }); const uniquePlayers = new Set(sessionsInPeriod.map(s => s.steam_id)).size; return { wipe_date: wipeDate, end_date: endDate, unique_players: uniquePlayers, total_sessions: sessionsInPeriod.length, }; }), ); return { wipe_count: recentWipes.length, retention_data: retentionData, }; } async exportData(licenseId: string, rangeHours: number): Promise { const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000); const stats = await this.statsRepo.find({ where: { license_id: licenseId, recorded_at: MoreThan(cutoff) }, order: { recorded_at: 'ASC' }, }); // Generate CSV const headers = 'timestamp,player_count,fps,entity_count,memory_mb\n'; const rows = stats.map(s => `${s.recorded_at.toISOString()},${s.player_count},${s.fps},${s.entity_count},${s.memory_usage_mb}` ).join('\n'); return headers + rows; } }