From 9240feedafdf7e00336a114314b4e2c03348a637 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 21 Feb 2026 16:01:45 -0500 Subject: [PATCH] fix: Wire real data sources across players, analytics, status, and maps services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - players: Primary data from player_sessions (online status, playtime aggregates); ban/unban status overlaid from player_actions latest action per steam_id. Register PlayerSession entity in PlayersModule. Extend NATS forwarding to include 'unban' alongside kick and ban. - analytics: Fix retention period boundary bug — sessions were queried with only a lower-bound filter (MoreThan), causing all future cycles to bleed into earlier wipe windows. Replaced with Between(wipeDate, endDate) for correct isolation. - status: Replace hardcoded player_count=0/max_players=0 with live data from most-recent server_stats row per license. Register ServerStats entity in StatusModule. Falls back to 0 gracefully when no stats exist yet. - maps: File buffer was computed and discarded — never written to disk. Now writes to /app/map_data/{licenseId}/{timestamp}_{filename} (tenant-isolated, docker volume map_data). Creates directories with mkdirSync(recursive:true). Logs success/failure via NestJS Logger. Throws 500 on disk write failure instead of silently losing data. Co-Authored-By: Claude Opus 4.6 --- .../modules/analytics/analytics.service.ts | 12 ++- backend-nest/src/modules/maps/maps.service.ts | 27 ++++- .../src/modules/players/players.module.ts | 3 +- .../src/modules/players/players.service.ts | 100 +++++++++++++----- .../src/modules/status/status.module.ts | 3 +- .../src/modules/status/status.service.ts | 15 ++- 6 files changed, 124 insertions(+), 36 deletions(-) diff --git a/backend-nest/src/modules/analytics/analytics.service.ts b/backend-nest/src/modules/analytics/analytics.service.ts index 1aa8efa..c98c19c 100644 --- a/backend-nest/src/modules/analytics/analytics.service.ts +++ b/backend-nest/src/modules/analytics/analytics.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, MoreThan } from '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'; @@ -169,13 +169,18 @@ export class AnalyticsService { const retentionData = await Promise.all( recentWipes.map(async (wipe) => { const wipeDate = wipe.started_at; - const nextWipe = recentWipes.find(w => w.started_at && wipeDate && w.started_at > wipeDate); + // 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: MoreThan(wipeDate!), + session_start: Between(wipeDate!, endDate), }, }); @@ -183,6 +188,7 @@ export class AnalyticsService { return { wipe_date: wipeDate, + end_date: endDate, unique_players: uniquePlayers, total_sessions: sessionsInPeriod.length, }; diff --git a/backend-nest/src/modules/maps/maps.service.ts b/backend-nest/src/modules/maps/maps.service.ts index a8a58af..bf6e574 100644 --- a/backend-nest/src/modules/maps/maps.service.ts +++ b/backend-nest/src/modules/maps/maps.service.ts @@ -1,14 +1,21 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { createHash } from 'crypto'; +import { mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; import { MapLibrary } from '../../entities/map-library.entity'; import { MapRotation } from '../../entities/map-rotation.entity'; import { UpdateRotationDto } from './dto/update-rotation.dto'; import { UploadMapDto } from './dto/upload-map.dto'; +// Docker volume mount point for map storage. Tenant-scoped subdirectory enforces isolation. +const MAP_DATA_ROOT = process.env.MAP_DATA_PATH || '/app/map_data'; + @Injectable() export class MapsService { + private readonly logger = new Logger(MapsService.name); + constructor( @InjectRepository(MapLibrary) private readonly mapLibraryRepo: Repository, @@ -22,7 +29,23 @@ export class MapsService { file: Express.Multer.File, ): Promise { const checksum = createHash('sha256').update(file.buffer).digest('hex'); - const storagePath = `/maps/${licenseId}/${Date.now()}_${file.originalname}`; + + // Build tenant-scoped storage path: /app/map_data/{licenseId}/{timestamp}_{filename} + const filename = `${Date.now()}_${file.originalname}`; + const tenantDir = join(MAP_DATA_ROOT, licenseId); + const absolutePath = join(tenantDir, filename); + + // Relative storage path stored in DB — avoids coupling to the absolute mount point + const storagePath = `/map_data/${licenseId}/${filename}`; + + try { + mkdirSync(tenantDir, { recursive: true }); + writeFileSync(absolutePath, file.buffer); + this.logger.log(`Map uploaded: ${absolutePath} (${file.size} bytes)`); + } catch (err) { + this.logger.error(`Failed to write map file to disk: ${absolutePath}`, err); + throw new InternalServerErrorException('Failed to save map file to storage'); + } const map = this.mapLibraryRepo.create({ license_id: licenseId, diff --git a/backend-nest/src/modules/players/players.module.ts b/backend-nest/src/modules/players/players.module.ts index 555bc53..f1122dc 100644 --- a/backend-nest/src/modules/players/players.module.ts +++ b/backend-nest/src/modules/players/players.module.ts @@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { PlayersController } from './players.controller'; import { PlayersService } from './players.service'; import { PlayerAction } from '../../entities/player-action.entity'; +import { PlayerSession } from '../../entities/player-session.entity'; import { NatsService } from '../../services/nats.service'; @Module({ - imports: [TypeOrmModule.forFeature([PlayerAction])], + imports: [TypeOrmModule.forFeature([PlayerAction, PlayerSession])], controllers: [PlayersController], providers: [PlayersService, NatsService], exports: [PlayersService], diff --git a/backend-nest/src/modules/players/players.service.ts b/backend-nest/src/modules/players/players.service.ts index 7aa9780..77a66b6 100644 --- a/backend-nest/src/modules/players/players.service.ts +++ b/backend-nest/src/modules/players/players.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PlayerAction } from '../../entities/player-action.entity'; +import { PlayerSession } from '../../entities/player-session.entity'; import { NatsService } from '../../services/nats.service'; import { PlayerActionDto } from './dto/player-action.dto'; @@ -11,6 +12,8 @@ export interface Player { status: 'online' | 'offline' | 'banned'; last_seen?: Date; ban_expires?: Date | null; + total_sessions?: number; + total_playtime_seconds?: number; } @Injectable() @@ -18,43 +21,86 @@ export class PlayersService { constructor( @InjectRepository(PlayerAction) private readonly actionRepo: Repository, + @InjectRepository(PlayerSession) + private readonly sessionRepo: Repository, private readonly natsService: NatsService, ) {} /** - * Get recent players for a license + * Get players for a license. * - * TODO: This needs a player_sessions table to track online/offline status. - * For now, we query player_actions to get a list of players who have had actions. + * Primary source: player_sessions table (tracks session lifecycles). + * Secondary source: player_actions table (determines ban status). + * A player whose most recent action is a 'ban' (not subsequently 'unban') is shown as banned. + * A player with an open session (session_end IS NULL) is shown as online. */ async getPlayers(licenseId: string): Promise<{ players: Player[] }> { - const actions = await this.actionRepo + // Get distinct players from session history + const sessions = await this.sessionRepo + .createQueryBuilder('session') + .where('session.license_id = :licenseId', { licenseId }) + .orderBy('session.session_start', 'DESC') + .getMany(); + + // Build per-player session aggregates + const playerMap = new Map(); + + for (const session of sessions) { + if (!playerMap.has(session.steam_id)) { + const isOnline = session.session_end === null; + playerMap.set(session.steam_id, { + steam_id: session.steam_id, + player_name: session.player_name, + status: isOnline ? 'online' : 'offline', + last_seen: session.session_start, + ban_expires: null, + total_sessions: 0, + total_playtime_seconds: 0, + }); + } + + const entry = playerMap.get(session.steam_id)!; + entry.total_sessions = (entry.total_sessions || 0) + 1; + entry.total_playtime_seconds = (entry.total_playtime_seconds || 0) + (session.duration_seconds || 0); + } + + // Overlay ban status from most recent action per player + const recentActions = await this.actionRepo .createQueryBuilder('action') .where('action.license_id = :licenseId', { licenseId }) .orderBy('action.created_at', 'DESC') - .take(100) .getMany(); - // Group by steam_id to get unique players - const playerMap = new Map(); + // Track the most recent action per steam_id to determine ban state + const latestActionBySteamId = new Map(); + for (const action of recentActions) { + if (!latestActionBySteamId.has(action.steam_id)) { + latestActionBySteamId.set(action.steam_id, action); + } + } - for (const action of actions) { - if (!playerMap.has(action.steam_id)) { - // Determine status based on latest action - let status: 'online' | 'offline' | 'banned' = 'offline'; - if (action.action_type === 'ban') { - status = 'banned'; - } - - playerMap.set(action.steam_id, { - steam_id: action.steam_id, - player_name: action.player_name, - status, - last_seen: action.created_at, - ban_expires: action.duration_minutes + for (const [steamId, action] of latestActionBySteamId) { + if (action.action_type === 'ban') { + // Add player from actions even if they have no sessions + if (!playerMap.has(steamId)) { + playerMap.set(steamId, { + steam_id: action.steam_id, + player_name: action.player_name, + status: 'banned', + last_seen: action.created_at, + ban_expires: action.duration_minutes + ? new Date(action.created_at.getTime() + action.duration_minutes * 60000) + : null, + total_sessions: 0, + total_playtime_seconds: 0, + }); + } else { + const entry = playerMap.get(steamId)!; + entry.status = 'banned'; + entry.ban_expires = action.duration_minutes ? new Date(action.created_at.getTime() + action.duration_minutes * 60000) - : null, - }); + : null; + } } } @@ -64,7 +110,9 @@ export class PlayersService { } /** - * Perform a moderation action on a player + * Perform a moderation action on a player. + * Supported actions: kick, ban, unban, warn, note. + * kick/ban/unban are forwarded to the game server via NATS. */ async performAction( licenseId: string, @@ -84,8 +132,8 @@ export class PlayersService { await this.actionRepo.save(action); - // For kick/ban, send NATS command to the server - if (dto.action_type === 'kick' || dto.action_type === 'ban') { + // Forward kick, ban, and unban to the game server via NATS + if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') { await this.natsService.sendServerCommand(licenseId, dto.action_type, { steam_id: dto.steam_id, reason: dto.reason, diff --git a/backend-nest/src/modules/status/status.module.ts b/backend-nest/src/modules/status/status.module.ts index 2f4e69c..099ac5c 100644 --- a/backend-nest/src/modules/status/status.module.ts +++ b/backend-nest/src/modules/status/status.module.ts @@ -4,11 +4,12 @@ import { StatusController } from './status.controller'; import { StatusService } from './status.service'; import { PublicSiteConfig } from '../../entities/public-site-config.entity'; import { ServerConnection } from '../../entities/server-connection.entity'; +import { ServerStats } from '../../entities/server-stats.entity'; import { License } from '../../entities/license.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, License]), + TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, ServerStats, License]), ], controllers: [StatusController], providers: [StatusService], diff --git a/backend-nest/src/modules/status/status.service.ts b/backend-nest/src/modules/status/status.service.ts index 8565bf6..a698fd2 100644 --- a/backend-nest/src/modules/status/status.service.ts +++ b/backend-nest/src/modules/status/status.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PublicSiteConfig } from '../../entities/public-site-config.entity'; import { ServerConnection } from '../../entities/server-connection.entity'; +import { ServerStats } from '../../entities/server-stats.entity'; import { License } from '../../entities/license.entity'; @Injectable() @@ -12,6 +13,8 @@ export class StatusService { private readonly publicSiteRepo: Repository, @InjectRepository(ServerConnection) private readonly serverConnectionRepo: Repository, + @InjectRepository(ServerStats) + private readonly serverStatsRepo: Repository, @InjectRepository(License) private readonly licenseRepo: Repository, ) {} @@ -32,12 +35,18 @@ export class StatusService { where: { license_id: config.license_id }, }); + // Fetch the most recent stats row for this server to get live player counts + const latestStats = await this.serverStatsRepo.findOne({ + where: { license_id: config.license_id }, + order: { recorded_at: 'DESC' }, + }); + return { - server_name: license?.subdomain || 'Unknown Server', + server_name: license?.server_name || license?.subdomain || 'Unknown Server', subdomain: license?.subdomain || null, status: connection?.connection_status || 'offline', - player_count: 0, // Would need real-time data - max_players: 0, + player_count: latestStats?.player_count ?? 0, + max_players: latestStats?.max_players ?? 0, steam_connect_url: config.steam_connect_url, motd: config.motd, discord_invite_url: config.discord_invite_url,