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>
113 lines
3.7 KiB
TypeScript
113 lines
3.7 KiB
TypeScript
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<MapLibrary>,
|
|
@InjectRepository(MapRotation)
|
|
private readonly mapRotationRepo: Repository<MapRotation>,
|
|
) {}
|
|
|
|
async uploadMap(
|
|
licenseId: string,
|
|
dto: UploadMapDto,
|
|
file: Express.Multer.File,
|
|
): Promise<MapLibrary> {
|
|
const checksum = createHash('sha256').update(file.buffer).digest('hex');
|
|
|
|
// 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,
|
|
filename: file.originalname,
|
|
display_name: dto.display_name,
|
|
storage_path: storagePath,
|
|
file_size_bytes: file.size,
|
|
map_type: dto.map_type,
|
|
seed: dto.seed ?? null,
|
|
world_size: dto.world_size ?? null,
|
|
thumbnail_path: null,
|
|
checksum,
|
|
});
|
|
|
|
return this.mapLibraryRepo.save(map);
|
|
}
|
|
|
|
async getMaps(licenseId: string): Promise<{ maps: MapLibrary[] }> {
|
|
const maps = await this.mapLibraryRepo.find({
|
|
where: { license_id: licenseId },
|
|
order: { uploaded_at: 'DESC' },
|
|
});
|
|
return { maps };
|
|
}
|
|
|
|
async deleteMap(licenseId: string, mapId: string): Promise<void> {
|
|
const result = await this.mapLibraryRepo.delete({
|
|
id: mapId,
|
|
license_id: licenseId,
|
|
});
|
|
|
|
if (result.affected === 0) {
|
|
throw new NotFoundException(`Map ${mapId} not found`);
|
|
}
|
|
}
|
|
|
|
async getRotation(licenseId: string): Promise<MapRotation[]> {
|
|
return this.mapRotationRepo.find({
|
|
where: { license_id: licenseId },
|
|
relations: ['map'],
|
|
order: { rotation_order: 'ASC' },
|
|
});
|
|
}
|
|
|
|
async updateRotation(licenseId: string, dto: UpdateRotationDto): Promise<MapRotation[]> {
|
|
// Delete existing rotation entries for this license
|
|
await this.mapRotationRepo.delete({ license_id: licenseId });
|
|
|
|
// Create new rotation entries
|
|
const rotationEntries = dto.maps.map((entry) =>
|
|
this.mapRotationRepo.create({
|
|
license_id: licenseId,
|
|
map_id: entry.map_id,
|
|
rotation_order: entry.rotation_order,
|
|
is_active: true,
|
|
}),
|
|
);
|
|
|
|
await this.mapRotationRepo.save(rotationEntries);
|
|
|
|
// Return updated rotation with map data
|
|
return this.getRotation(licenseId);
|
|
}
|
|
}
|