diff --git a/.gitignore b/.gitignore index eb19a1e..939f7d9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,9 @@ Thumbs.db docker/pg_data/ docker/nats_data/ -# Maps and backups (runtime data) -maps/ -backups/ +# Maps and backups (runtime data, top-level only) +/maps/ +/backups/ # Logs *.log diff --git a/backend-nest/src/modules/maps/dto/update-rotation.dto.ts b/backend-nest/src/modules/maps/dto/update-rotation.dto.ts new file mode 100644 index 0000000..e5a0667 --- /dev/null +++ b/backend-nest/src/modules/maps/dto/update-rotation.dto.ts @@ -0,0 +1,22 @@ +import { IsArray, ValidateNested, IsUUID, IsInt, Min } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class RotationEntryDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) + @IsUUID() + map_id: string; + + @ApiProperty({ example: 1 }) + @IsInt() + @Min(0) + rotation_order: number; +} + +export class UpdateRotationDto { + @ApiProperty({ type: [RotationEntryDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RotationEntryDto) + maps: RotationEntryDto[]; +} diff --git a/backend-nest/src/modules/maps/dto/upload-map.dto.ts b/backend-nest/src/modules/maps/dto/upload-map.dto.ts new file mode 100644 index 0000000..6090b30 --- /dev/null +++ b/backend-nest/src/modules/maps/dto/upload-map.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsEnum, IsInt, MaxLength, Min, Max } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class UploadMapDto { + @ApiProperty({ example: 'Custom PvP Map', maxLength: 255 }) + @IsString() + @MaxLength(255) + display_name: string; + + @ApiProperty({ example: 'custom', enum: ['custom', 'procedural'] }) + @IsEnum(['custom', 'procedural']) + map_type: 'custom' | 'procedural'; + + @ApiPropertyOptional({ example: 123456, description: 'Procedural map seed' }) + @IsOptional() + @IsInt() + seed?: number; + + @ApiPropertyOptional({ example: 4000, description: 'World size in meters' }) + @IsOptional() + @IsInt() + @Min(1000) + @Max(6000) + world_size?: number; +} diff --git a/backend-nest/src/modules/maps/maps.controller.ts b/backend-nest/src/modules/maps/maps.controller.ts new file mode 100644 index 0000000..efc3f53 --- /dev/null +++ b/backend-nest/src/modules/maps/maps.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Delete, Put, Body, Param, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { MapsService } from './maps.service'; +import { UpdateRotationDto } from './dto/update-rotation.dto'; +import { CurrentTenant } from '../../common/decorators/current-tenant.decorator'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; + +@ApiTags('maps') +@ApiBearerAuth() +@Controller('maps') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class MapsController { + constructor(private readonly mapsService: MapsService) {} + + @Get() + @RequirePermission('map.view') + @ApiOperation({ summary: 'Get all maps for tenant' }) + getMaps(@CurrentTenant() licenseId: string) { + return this.mapsService.getMaps(licenseId); + } + + @Delete(':id') + @RequirePermission('map.manage') + @ApiOperation({ summary: 'Delete map from library' }) + async deleteMap(@CurrentTenant() licenseId: string, @Param('id') mapId: string) { + await this.mapsService.deleteMap(licenseId, mapId); + return { deleted: true }; + } + + @Get('rotation') + @RequirePermission('map.view') + @ApiOperation({ summary: 'Get current map rotation' }) + getRotation(@CurrentTenant() licenseId: string) { + return this.mapsService.getRotation(licenseId); + } + + @Put('rotation') + @RequirePermission('map.manage') + @ApiOperation({ summary: 'Update map rotation order' }) + updateRotation(@CurrentTenant() licenseId: string, @Body() dto: UpdateRotationDto) { + return this.mapsService.updateRotation(licenseId, dto); + } +} diff --git a/backend-nest/src/modules/maps/maps.module.ts b/backend-nest/src/modules/maps/maps.module.ts new file mode 100644 index 0000000..d7e5efb --- /dev/null +++ b/backend-nest/src/modules/maps/maps.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MapsController } from './maps.controller'; +import { MapsService } from './maps.service'; +import { MapLibrary } from '../../entities/map-library.entity'; +import { MapRotation } from '../../entities/map-rotation.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([MapLibrary, MapRotation])], + controllers: [MapsController], + providers: [MapsService], + exports: [MapsService], +}) +export class MapsModule {} diff --git a/backend-nest/src/modules/maps/maps.service.ts b/backend-nest/src/modules/maps/maps.service.ts new file mode 100644 index 0000000..ae259bc --- /dev/null +++ b/backend-nest/src/modules/maps/maps.service.ts @@ -0,0 +1,63 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MapLibrary } from '../../entities/map-library.entity'; +import { MapRotation } from '../../entities/map-rotation.entity'; +import { UpdateRotationDto } from './dto/update-rotation.dto'; + +@Injectable() +export class MapsService { + constructor( + @InjectRepository(MapLibrary) + private readonly mapLibraryRepo: Repository, + @InjectRepository(MapRotation) + private readonly mapRotationRepo: Repository, + ) {} + + 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); + } +}