feat: Complete NestJS backend scaffold — 22 modules, 39 entities, WebSocket gateway
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

Full backend rewrite from Rust/Axum to NestJS/TypeScript.
- 22 feature modules (auth, servers, wipes, maps, plugins, players, console,
  chat, team, notifications, settings, schedules, analytics, alerts, status,
  store, webstore, admin, setup, migration, users, licenses)
- 39 TypeORM entities matching PostgreSQL schema (12 migrations)
- Common infrastructure: JWT/RBAC guards, decorators, exception filter
- NATS service with pub/sub/request-reply
- Socket.IO WebSocket gateway with NATS bridge
- Docker: NestJS Dockerfile + updated docker-compose.yml
- Zero compile errors (npx tsc --noEmit clean)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 21:29:25 -05:00
parent 0f8d0dd14f
commit d20493d533
141 changed files with 13552 additions and 4 deletions

View File

@@ -0,0 +1,214 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan } 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<ServerStatsHourly>,
@InjectRepository(ServerStats)
private readonly statsRepo: Repository<ServerStats>,
@InjectRepository(WipeHistory)
private readonly wipeHistoryRepo: Repository<WipeHistory>,
@InjectRepository(PlayerSession)
private readonly playerSessionRepo: Repository<PlayerSession>,
@InjectRepository(MapLibrary)
private readonly mapLibraryRepo: Repository<MapLibrary>,
) {}
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<string, number>),
};
}
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;
const nextWipe = recentWipes.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
const endDate = nextWipe?.started_at || new Date();
const sessionsInPeriod = await this.playerSessionRepo.find({
where: {
license_id: licenseId,
session_start: MoreThan(wipeDate!),
},
});
const uniquePlayers = new Set(sessionsInPeriod.map(s => s.steam_id)).size;
return {
wipe_date: wipeDate,
unique_players: uniquePlayers,
total_sessions: sessionsInPeriod.length,
};
}),
);
return {
wipe_count: recentWipes.length,
retention_data: retentionData,
};
}
async exportData(licenseId: string, rangeHours: number): Promise<string> {
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;
}
}