Files
corrosion-admin-panel/backend-nest/src/modules/maps/maps.service.ts
Vantz Stockwell 9240feedaf
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
fix: Wire real data sources across players, analytics, status, and maps services
- 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>
2026-02-21 16:01:45 -05:00

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);
}
}