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, @InjectRepository(MapRotation) private readonly mapRotationRepo: Repository, ) {} async uploadMap( licenseId: string, dto: UploadMapDto, file: Express.Multer.File, ): Promise { 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 { 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 { return this.mapRotationRepo.find({ where: { license_id: licenseId }, relations: ['map'], order: { rotation_order: 'ASC' }, }); } async updateRotation(licenseId: string, dto: UpdateRotationDto): Promise { // 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); } }