fix: Wire real data sources across players, analytics, status, and maps services
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

- 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 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-21 16:01:45 -05:00
parent 7bf3e5639e
commit 9240feedaf
6 changed files with 124 additions and 36 deletions

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; 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 { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
import { ServerStats } from '../../entities/server-stats.entity'; import { ServerStats } from '../../entities/server-stats.entity';
import { WipeHistory } from '../../entities/wipe-history.entity'; import { WipeHistory } from '../../entities/wipe-history.entity';
@@ -169,13 +169,18 @@ export class AnalyticsService {
const retentionData = await Promise.all( const retentionData = await Promise.all(
recentWipes.map(async (wipe) => { recentWipes.map(async (wipe) => {
const wipeDate = wipe.started_at; 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(); const endDate = nextWipe?.started_at || new Date();
// Query sessions strictly within this wipe cycle: [wipeDate, endDate)
const sessionsInPeriod = await this.playerSessionRepo.find({ const sessionsInPeriod = await this.playerSessionRepo.find({
where: { where: {
license_id: licenseId, license_id: licenseId,
session_start: MoreThan(wipeDate!), session_start: Between(wipeDate!, endDate),
}, },
}); });
@@ -183,6 +188,7 @@ export class AnalyticsService {
return { return {
wipe_date: wipeDate, wipe_date: wipeDate,
end_date: endDate,
unique_players: uniquePlayers, unique_players: uniquePlayers,
total_sessions: sessionsInPeriod.length, total_sessions: sessionsInPeriod.length,
}; };

View File

@@ -1,14 +1,21 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { MapLibrary } from '../../entities/map-library.entity'; import { MapLibrary } from '../../entities/map-library.entity';
import { MapRotation } from '../../entities/map-rotation.entity'; import { MapRotation } from '../../entities/map-rotation.entity';
import { UpdateRotationDto } from './dto/update-rotation.dto'; import { UpdateRotationDto } from './dto/update-rotation.dto';
import { UploadMapDto } from './dto/upload-map.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() @Injectable()
export class MapsService { export class MapsService {
private readonly logger = new Logger(MapsService.name);
constructor( constructor(
@InjectRepository(MapLibrary) @InjectRepository(MapLibrary)
private readonly mapLibraryRepo: Repository<MapLibrary>, private readonly mapLibraryRepo: Repository<MapLibrary>,
@@ -22,7 +29,23 @@ export class MapsService {
file: Express.Multer.File, file: Express.Multer.File,
): Promise<MapLibrary> { ): Promise<MapLibrary> {
const checksum = createHash('sha256').update(file.buffer).digest('hex'); 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({ const map = this.mapLibraryRepo.create({
license_id: licenseId, license_id: licenseId,

View File

@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { PlayersController } from './players.controller'; import { PlayersController } from './players.controller';
import { PlayersService } from './players.service'; import { PlayersService } from './players.service';
import { PlayerAction } from '../../entities/player-action.entity'; import { PlayerAction } from '../../entities/player-action.entity';
import { PlayerSession } from '../../entities/player-session.entity';
import { NatsService } from '../../services/nats.service'; import { NatsService } from '../../services/nats.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([PlayerAction])], imports: [TypeOrmModule.forFeature([PlayerAction, PlayerSession])],
controllers: [PlayersController], controllers: [PlayersController],
providers: [PlayersService, NatsService], providers: [PlayersService, NatsService],
exports: [PlayersService], exports: [PlayersService],

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { PlayerAction } from '../../entities/player-action.entity'; import { PlayerAction } from '../../entities/player-action.entity';
import { PlayerSession } from '../../entities/player-session.entity';
import { NatsService } from '../../services/nats.service'; import { NatsService } from '../../services/nats.service';
import { PlayerActionDto } from './dto/player-action.dto'; import { PlayerActionDto } from './dto/player-action.dto';
@@ -11,6 +12,8 @@ export interface Player {
status: 'online' | 'offline' | 'banned'; status: 'online' | 'offline' | 'banned';
last_seen?: Date; last_seen?: Date;
ban_expires?: Date | null; ban_expires?: Date | null;
total_sessions?: number;
total_playtime_seconds?: number;
} }
@Injectable() @Injectable()
@@ -18,43 +21,86 @@ export class PlayersService {
constructor( constructor(
@InjectRepository(PlayerAction) @InjectRepository(PlayerAction)
private readonly actionRepo: Repository<PlayerAction>, private readonly actionRepo: Repository<PlayerAction>,
@InjectRepository(PlayerSession)
private readonly sessionRepo: Repository<PlayerSession>,
private readonly natsService: NatsService, 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. * Primary source: player_sessions table (tracks session lifecycles).
* For now, we query player_actions to get a list of players who have had actions. * 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[] }> { 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<string, Player>();
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') .createQueryBuilder('action')
.where('action.license_id = :licenseId', { licenseId }) .where('action.license_id = :licenseId', { licenseId })
.orderBy('action.created_at', 'DESC') .orderBy('action.created_at', 'DESC')
.take(100)
.getMany(); .getMany();
// Group by steam_id to get unique players // Track the most recent action per steam_id to determine ban state
const playerMap = new Map<string, Player>(); const latestActionBySteamId = new Map<string, PlayerAction>();
for (const action of recentActions) {
for (const action of actions) { if (!latestActionBySteamId.has(action.steam_id)) {
if (!playerMap.has(action.steam_id)) { latestActionBySteamId.set(action.steam_id, action);
// Determine status based on latest action }
let status: 'online' | 'offline' | 'banned' = 'offline';
if (action.action_type === 'ban') {
status = 'banned';
} }
playerMap.set(action.steam_id, { 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, steam_id: action.steam_id,
player_name: action.player_name, player_name: action.player_name,
status, status: 'banned',
last_seen: action.created_at, last_seen: action.created_at,
ban_expires: action.duration_minutes ban_expires: action.duration_minutes
? new Date(action.created_at.getTime() + action.duration_minutes * 60000) ? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
: null, : 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;
}
} }
} }
@@ -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( async performAction(
licenseId: string, licenseId: string,
@@ -84,8 +132,8 @@ export class PlayersService {
await this.actionRepo.save(action); await this.actionRepo.save(action);
// For kick/ban, send NATS command to the server // Forward kick, ban, and unban to the game server via NATS
if (dto.action_type === 'kick' || dto.action_type === 'ban') { if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
await this.natsService.sendServerCommand(licenseId, dto.action_type, { await this.natsService.sendServerCommand(licenseId, dto.action_type, {
steam_id: dto.steam_id, steam_id: dto.steam_id,
reason: dto.reason, reason: dto.reason,

View File

@@ -4,11 +4,12 @@ import { StatusController } from './status.controller';
import { StatusService } from './status.service'; import { StatusService } from './status.service';
import { PublicSiteConfig } from '../../entities/public-site-config.entity'; import { PublicSiteConfig } from '../../entities/public-site-config.entity';
import { ServerConnection } from '../../entities/server-connection.entity'; import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerStats } from '../../entities/server-stats.entity';
import { License } from '../../entities/license.entity'; import { License } from '../../entities/license.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, License]), TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, ServerStats, License]),
], ],
controllers: [StatusController], controllers: [StatusController],
providers: [StatusService], providers: [StatusService],

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { PublicSiteConfig } from '../../entities/public-site-config.entity'; import { PublicSiteConfig } from '../../entities/public-site-config.entity';
import { ServerConnection } from '../../entities/server-connection.entity'; import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerStats } from '../../entities/server-stats.entity';
import { License } from '../../entities/license.entity'; import { License } from '../../entities/license.entity';
@Injectable() @Injectable()
@@ -12,6 +13,8 @@ export class StatusService {
private readonly publicSiteRepo: Repository<PublicSiteConfig>, private readonly publicSiteRepo: Repository<PublicSiteConfig>,
@InjectRepository(ServerConnection) @InjectRepository(ServerConnection)
private readonly serverConnectionRepo: Repository<ServerConnection>, private readonly serverConnectionRepo: Repository<ServerConnection>,
@InjectRepository(ServerStats)
private readonly serverStatsRepo: Repository<ServerStats>,
@InjectRepository(License) @InjectRepository(License)
private readonly licenseRepo: Repository<License>, private readonly licenseRepo: Repository<License>,
) {} ) {}
@@ -32,12 +35,18 @@ export class StatusService {
where: { license_id: config.license_id }, 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 { return {
server_name: license?.subdomain || 'Unknown Server', server_name: license?.server_name || license?.subdomain || 'Unknown Server',
subdomain: license?.subdomain || null, subdomain: license?.subdomain || null,
status: connection?.connection_status || 'offline', status: connection?.connection_status || 'offline',
player_count: 0, // Would need real-time data player_count: latestStats?.player_count ?? 0,
max_players: 0, max_players: latestStats?.max_players ?? 0,
steam_connect_url: config.steam_connect_url, steam_connect_url: config.steam_connect_url,
motd: config.motd, motd: config.motd,
discord_invite_url: config.discord_invite_url, discord_invite_url: config.discord_invite_url,