using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using RobotNet10.MapEditor.Shared.DTOs.Layout; using RobotNet10.MapEditor.Shared.DTOs.Requests; using RobotNet10.MapEditor.Shared.DTOs.Responses; using RobotNet10.MapEditor.Shared.Models; using RobotNet10.MapManager.Services; namespace RobotNet10.MapManager.Controllers; /// /// Controller for managing layouts, versions, and levels /// [ApiController] [Route("api/layouts")] [Authorize] public class LayoutManagerController( ILayoutService layoutService, IImageStorageService imageStorageService, ILogger logger) : ControllerBase { private readonly ILayoutService _layoutService = layoutService; private readonly IImageStorageService _imageStorageService = imageStorageService; private readonly ILogger _logger = logger; // ========================================== // LAYOUT OPERATIONS // ========================================== /// /// Create a new layout /// [HttpPost] [ProducesResponseType(typeof(LayoutDto), 201)] [ProducesResponseType(400)] public async Task> CreateLayout([FromBody] CreateLayoutRequest request) { try { var layout = await _layoutService.CreateLayoutAsync(request); var dto = MapLayoutToDto(layout); return CreatedAtAction( nameof(GetLayout), new { layoutId = layout.Id }, dto); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create layout: {LayoutId}", request.LayoutId); return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR")); } } /// /// Search layouts by text /// [HttpGet] [ProducesResponseType(typeof(List), 200)] public async Task>> SearchLayouts([FromQuery] string? search) { var layouts = await _layoutService.SearchLayoutsAsync(search); var dtos = layouts.Select(MapLayoutToDto).ToList(); return Ok(dtos); } /// /// Get layout by database ID /// [HttpGet("{layoutId:guid}")] [ProducesResponseType(typeof(LayoutDto), 200)] [ProducesResponseType(404)] public async Task> GetLayout(Guid layoutId) { var layout = await _layoutService.GetLayoutByIdAsync(layoutId); if (layout == null) { return NotFound(CreateErrorResponse($"Layout with ID '{layoutId}' not found", "LAYOUT_NOT_FOUND")); } return Ok(MapLayoutToDto(layout)); } /// /// Get layout by layout ID string /// [HttpGet("by-id/{layoutId}")] [ProducesResponseType(typeof(LayoutDto), 200)] [ProducesResponseType(404)] public async Task> GetLayoutByLayoutId(string layoutId) { var layout = await _layoutService.GetLayoutByLayoutIdAsync(layoutId); if (layout == null) { return NotFound(CreateErrorResponse($"Layout with ID '{layoutId}' not found", "LAYOUT_NOT_FOUND")); } return Ok(MapLayoutToDto(layout)); } /// /// Get layout by name /// [HttpGet("by-name/{layoutName}")] [ProducesResponseType(typeof(LayoutDto), 200)] [ProducesResponseType(404)] public async Task> GetLayoutByName(string layoutName) { var layout = await _layoutService.GetLayoutByNameAsync(layoutName); if (layout == null) { return NotFound(CreateErrorResponse($"Layout with name '{layoutName}' not found", "LAYOUT_NOT_FOUND")); } return Ok(MapLayoutToDto(layout)); } /// /// Update layout /// [HttpPut("{layoutId:guid}")] [ProducesResponseType(typeof(LayoutDto), 200)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task> UpdateLayout( Guid layoutId, [FromBody] UpdateLayoutRequest request) { try { var layout = await _layoutService.UpdateLayoutAsync(layoutId, request); return Ok(MapLayoutToDto(layout)); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update layout: {LayoutId}", layoutId); if (ex.Message.Contains("not found")) return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND")); return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR")); } } /// /// Delete layout (must be deactivated first) /// Hard delete with cascade /// [HttpDelete("{layoutId:guid}")] [ProducesResponseType(204)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task DeleteLayout(Guid layoutId) { try { var deleted = await _layoutService.DeleteLayoutAsync(layoutId); if (!deleted) { return NotFound(CreateErrorResponse($"Layout with ID '{layoutId}' not found", "LAYOUT_NOT_FOUND")); } return NoContent(); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete layout: {LayoutId}", layoutId); return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR")); } } /// /// Activate layout /// [HttpPost("{layoutId:guid}/activate")] [ProducesResponseType(typeof(LayoutDto), 200)] [ProducesResponseType(404)] public async Task> ActivateLayout(Guid layoutId) { try { var layout = await _layoutService.ActivateLayoutAsync(layoutId); return Ok(MapLayoutToDto(layout)); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to activate layout: {LayoutId}", layoutId); return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND")); } } /// /// Deactivate layout /// [HttpPost("{layoutId:guid}/deactivate")] [ProducesResponseType(typeof(LayoutDto), 200)] [ProducesResponseType(404)] public async Task> DeactivateLayout(Guid layoutId) { try { var layout = await _layoutService.DeactivateLayoutAsync(layoutId); return Ok(MapLayoutToDto(layout)); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to deactivate layout: {LayoutId}", layoutId); return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND")); } } // ========================================== // VERSION OPERATIONS // ========================================== /// /// Create new version for a layout /// [HttpPost("{layoutId:guid}/versions")] [ProducesResponseType(typeof(LayoutVersionDto), 201)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task> CreateVersion( Guid layoutId, [FromBody] CreateLayoutVersionRequest request) { try { var version = await _layoutService.CreateVersionAsync(layoutId, request); var dto = MapVersionToDto(version); return CreatedAtAction( nameof(GetVersion), new { versionId = version.Id }, dto); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create version for layout: {LayoutId}", layoutId); if (ex.Message.Contains("not found")) return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND")); return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR")); } } /// /// Get all versions for a layout /// [HttpGet("{layoutId:guid}/versions")] [ProducesResponseType(typeof(List), 200)] public async Task>> GetVersions(Guid layoutId) { var versions = await _layoutService.GetVersionsAsync(layoutId); var dtos = versions.Select(MapVersionToDto).ToList(); return Ok(dtos); } /// /// Get version by ID /// [HttpGet("versions/{versionId:guid}")] [ProducesResponseType(typeof(LayoutVersionDto), 200)] [ProducesResponseType(404)] public async Task> GetVersion(Guid versionId) { var version = await _layoutService.GetVersionAsync(versionId); if (version == null) { return NotFound(CreateErrorResponse($"Version with ID '{versionId}' not found", "VERSION_NOT_FOUND")); } return Ok(MapVersionToDto(version)); } /// /// Update version /// [HttpPut("versions/{versionId:guid}")] [ProducesResponseType(typeof(LayoutVersionDto), 200)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task> UpdateVersion( Guid versionId, [FromBody] UpdateLayoutRequest request) { try { var version = await _layoutService.UpdateVersionAsync(versionId, request); return Ok(MapVersionToDto(version)); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update version: {VersionId}", versionId); if (ex.Message.Contains("not found")) return NotFound(CreateErrorResponse(ex.Message, "VERSION_NOT_FOUND")); return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR")); } } /// /// Delete version (layout must be deactivated first) /// [HttpDelete("versions/{versionId:guid}")] [ProducesResponseType(204)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task DeleteVersion(Guid versionId) { try { var deleted = await _layoutService.DeleteVersionAsync(versionId); if (!deleted) { return NotFound(CreateErrorResponse($"Version with ID '{versionId}' not found", "VERSION_NOT_FOUND")); } return NoContent(); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete version: {VersionId}", versionId); return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR")); } } // ========================================== // LEVEL OPERATIONS // ========================================== /// /// Create new level for a version /// [HttpPost("versions/{versionId:guid}/levels")] [ProducesResponseType(typeof(LayoutLevelDto), 201)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task> CreateLevel( Guid versionId, [FromBody] CreateLayoutLevelRequest request) { try { var level = await _layoutService.CreateLevelAsync(versionId, request); var dto = MapLevelToDto(level); return CreatedAtAction( nameof(GetLevel), new { levelId = level.Id }, dto); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create level for version: {VersionId}", versionId); if (ex.Message.Contains("not found")) return NotFound(CreateErrorResponse(ex.Message, "VERSION_NOT_FOUND")); return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR")); } } /// /// Create a new layout level with background image in a single request /// /// Version ID /// Layout level identifier string /// Level order /// Resolution in meters per pixel /// Origin X coordinate in meters /// Origin Y coordinate in meters /// Background image file (PNG format, required) /// Created level with image metadata [HttpPost("versions/{versionId:guid}/levels/with-image")] [ProducesResponseType(typeof(LayoutLevelDto), 201)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task> CreateLevelWithImage( Guid versionId, [FromForm] string layoutLevelId, [FromForm] int levelOrder, [FromForm] double resolution, [FromForm] double originX, [FromForm] double originY, IFormFile file) { // Validate file if (file == null || file.Length == 0) { return BadRequest(CreateErrorResponse("Image file is required", "VALIDATION_ERROR")); } // Validate file type if (!file.ContentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) { return BadRequest(CreateErrorResponse("Only PNG images are supported", "VALIDATION_ERROR")); } // Validate file size (max 10MB) const long maxFileSize = 10 * 1024 * 1024; if (file.Length > maxFileSize) { return BadRequest(CreateErrorResponse($"File size exceeds maximum of {maxFileSize / 1024 / 1024}MB", "VALIDATION_ERROR")); } try { // Step 1: Extract image dimensions int imageWidth, imageHeight; using (var stream = file.OpenReadStream()) { (imageWidth, imageHeight) = await _imageStorageService.GetImageDimensionsAsync(stream); } // Step 2: Create level with complete coordinate system info var request = new CreateLayoutLevelRequest { LayoutLevelId = layoutLevelId, LevelOrder = levelOrder, CoordinateSystem = new CoordinateSystemInfo { Resolution = resolution, OriginX = originX, OriginY = originY, ImageWidth = imageWidth, ImageHeight = imageHeight, // Calculate bounds based on image size BoundsMinX = originX, BoundsMaxX = imageWidth * resolution + originX, BoundsMinY = originY, BoundsMaxY = imageHeight * resolution + originY } }; var level = await _layoutService.CreateLevelAsync(versionId, request); // Step 3: Upload image try { using var stream = file.OpenReadStream(); await _imageStorageService.SaveImageAsync(level.Id, stream); } catch (Exception ex) { if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to save image after creating level {LevelId}, attempting rollback", level.Id); // Attempt to delete the created level to maintain consistency try { await _layoutService.DeleteLevelAsync(level.Id); } catch (Exception rollbackEx) { if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(rollbackEx, "Failed to rollback level creation for {LevelId}", level.Id); } return StatusCode(500, CreateErrorResponse("Failed to save image. Level creation was rolled back.", "INTERNAL_ERROR")); } var dto = MapLevelToDto(level); return CreatedAtAction( nameof(GetLevel), new { levelId = level.Id }, dto); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create level with image for version: {VersionId}", versionId); if (ex.Message.Contains("not found")) return NotFound(CreateErrorResponse(ex.Message, "VERSION_NOT_FOUND")); if (ex.Message.Contains("Invalid image") || ex.Message.Contains("corrupted")) return BadRequest(CreateErrorResponse("Invalid or corrupted image file", "VALIDATION_ERROR")); return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR")); } catch (Exception ex) { if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Unexpected error creating level with image for version {VersionId}", versionId); return StatusCode(500, CreateErrorResponse("Internal server error while creating level with image", "INTERNAL_ERROR")); } } /// /// Get all levels for a version /// [HttpGet("versions/{versionId:guid}/levels")] [ProducesResponseType(typeof(List), 200)] public async Task>> GetLevels(Guid versionId) { var levels = await _layoutService.GetLevelsAsync(versionId); var dtos = levels.Select(MapLevelToDto).ToList(); return Ok(dtos); } /// /// Get level by ID /// [HttpGet("levels/{levelId:guid}")] [ProducesResponseType(typeof(LayoutLevelDto), 200)] [ProducesResponseType(404)] public async Task> GetLevel(Guid levelId) { var level = await _layoutService.GetLevelAsync(levelId); if (level == null) { return NotFound(CreateErrorResponse($"Level with ID '{levelId}' not found", "LEVEL_NOT_FOUND")); } return Ok(MapLevelToDto(level)); } /// /// Update level /// [HttpPut("levels/{levelId:guid}")] [ProducesResponseType(typeof(LayoutLevelDto), 200)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task> UpdateLevel( Guid levelId, [FromBody] UpdateLayoutLevelRequest request) { try { var level = await _layoutService.UpdateLevelAsync(levelId, request); return Ok(MapLevelToDto(level)); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update level: {LevelId}", levelId); if (ex.Message.Contains("not found")) return NotFound(CreateErrorResponse(ex.Message, "LEVEL_NOT_FOUND")); return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR")); } } /// /// Delete level (layout must be deactivated first) /// [HttpDelete("levels/{levelId:guid}")] [ProducesResponseType(204)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task DeleteLevel(Guid levelId) { try { var deleted = await _layoutService.DeleteLevelAsync(levelId); if (!deleted) { return NotFound(CreateErrorResponse($"Level with ID '{levelId}' not found", "LEVEL_NOT_FOUND")); } return NoContent(); } catch (InvalidOperationException ex) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete level: {LevelId}", levelId); return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR")); } } // ========================================== // HELPER METHODS // ========================================== private static LayoutDto MapLayoutToDto(Data.Layout layout) { return new LayoutDto { Id = layout.Id, LayoutId = layout.LayoutId, LayoutName = layout.LayoutName, Description = layout.Description, IsActive = layout.IsActive, CreatedDate = layout.CreatedDate, ModifiedDate = layout.ModifiedDate, CreatedBy = layout.CreatedBy, ModifiedBy = layout.ModifiedBy, Versions = layout.Versions?.Select(MapVersionToDto).ToList() }; } private static LayoutVersionDto MapVersionToDto(Data.LayoutVersion version) { return new LayoutVersionDto { Id = version.Id, LayoutId = version.LayoutId, Version = version.Version, LayoutDescription = version.LayoutDescription, CreatedBy = version.CreatedBy, CreatedDate = version.CreatedDate, IsActive = version.IsActive, Levels = version.Levels?.Select(MapLevelToDto).ToList() }; } private static LayoutLevelDto MapLevelToDto(Data.LayoutLevel level) { return new LayoutLevelDto { Id = level.Id, VersionId = level.VersionId, LayoutLevelId = level.LayoutLevelId, LevelOrder = level.LevelOrder, EditorSettings = level.EditorSettings != null ? MapEditorSettingsToDto(level.EditorSettings) : null }; } private static LayoutLevelEditorSettingsDto MapEditorSettingsToDto(Data.LayoutLevelEditorSettings settings) { return new LayoutLevelEditorSettingsDto { Id = settings.Id, LevelId = settings.LevelId, EdgeMinLengthCreate = settings.EdgeMinLengthCreate, EdgeNameAutoGenerate = settings.EdgeNameAutoGenerate, NodeNameAutoGenerate = settings.NodeNameAutoGenerate, NodeProximityRadius = settings.NodeProximityRadius, OriginX = settings.OriginX, OriginY = settings.OriginY, Resolution = settings.Resolution, BoundsMinX = settings.BoundsMinX, BoundsMaxX = settings.BoundsMaxX, BoundsMinY = settings.BoundsMinY, BoundsMaxY = settings.BoundsMaxY, ImageWidth = settings.ImageWidth, ImageHeight = settings.ImageHeight, CreatedDate = settings.CreatedDate, ModifiedDate = settings.ModifiedDate }; } private static ErrorResponseDto CreateErrorResponse( string error, string? errorCode = null, Dictionary? details = null) { return new ErrorResponseDto { Error = error, ErrorCode = errorCode, Details = details }; } }