using Extrudex.API.DTOs; using Extrudex.API.DTOs.Spools; using Extrudex.Domain.Entities; using Extrudex.Infrastructure.Data; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Extrudex.API.Controllers; /// /// Controller for managing filament spools — the core inventory unit of Extrudex. /// Supports full CRUD with pagination, filtering by material and active status, /// and soft-delete semantics (DELETE sets IsActive = false). /// [ApiController] [Route("api/spools")] public class SpoolsController : ControllerBase { private readonly ExtrudexDbContext _dbContext; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The database context for data access. /// The logger for diagnostic output. public SpoolsController(ExtrudexDbContext dbContext, ILogger logger) { _dbContext = dbContext; _logger = logger; } /// /// Gets a paginated list of spools with optional filtering by material and active status. /// Results are ordered by creation date (newest first) and include denormalized material names. /// /// Optional query parameters for pagination and filtering. /// A paginated list of spools matching the filter criteria. /// Returns the paginated list of spools. [HttpGet] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] public async Task>> GetSpools( [FromQuery] SpoolQueryParameters query) { _logger.LogDebug( "Getting spools: pageNumber={PageNumber}, pageSize={PageSize}, " + "materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, isActive={IsActive}", query.PageNumber, query.PageSize, query.MaterialBaseId, query.MaterialFinishId, query.IsActive); // Clamp pagination values var pageNumber = Math.Max(1, query.PageNumber); var pageSize = Math.Clamp(query.PageSize, 1, 100); var spoolQuery = _dbContext.Spools .Include(s => s.MaterialBase) .Include(s => s.MaterialFinish) .Include(s => s.MaterialModifier) .AsQueryable(); // Apply filters if (query.MaterialBaseId.HasValue) spoolQuery = spoolQuery.Where(s => s.MaterialBaseId == query.MaterialBaseId.Value); if (query.MaterialFinishId.HasValue) spoolQuery = spoolQuery.Where(s => s.MaterialFinishId == query.MaterialFinishId.Value); if (query.IsActive.HasValue) spoolQuery = spoolQuery.Where(s => s.IsActive == query.IsActive.Value); var totalCount = await spoolQuery.CountAsync(); var items = await spoolQuery .OrderByDescending(s => s.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .Select(s => MapToSpoolResponse(s)) .ToListAsync(); var response = new PagedResponse { Items = items, TotalCount = totalCount, PageNumber = pageNumber, PageSize = pageSize }; return Ok(response); } /// /// Gets a specific spool by its unique identifier. /// Includes denormalized material names for display. /// /// The unique identifier of the spool. /// The spool details. /// Returns the spool details. /// If the spool with the given ID is not found. [HttpGet("{id:guid}")] [ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetSpool(Guid id) { _logger.LogDebug("Getting spool {Id}", id); var spool = await _dbContext.Spools .Include(s => s.MaterialBase) .Include(s => s.MaterialFinish) .Include(s => s.MaterialModifier) .FirstOrDefaultAsync(s => s.Id == id); if (spool is null) { _logger.LogWarning("Spool {Id} not found", id); return NotFound(new { error = $"Spool with ID '{id}' not found." }); } return Ok(MapToSpoolResponse(spool)); } /// /// Creates a new spool record. Validates that all foreign keys reference existing entities /// and that the SpoolSerial is unique across all spools. /// /// The spool creation request with all required fields. /// The newly created spool with generated ID and timestamps. /// Returns the created spool with location header. /// If the request is invalid or foreign keys don't exist. [HttpPost] [ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> CreateSpool([FromBody] CreateSpoolRequest request) { _logger.LogInformation("Creating spool: {Serial} - {Brand} {Color}", request.SpoolSerial, request.Brand, request.ColorName); // Validate foreign keys exist var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId); if (materialBase is null) return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." }); var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId); if (materialFinish is null) return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." }); if (request.MaterialModifierId.HasValue) { var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value); if (modifier is null) return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." }); } // Validate serial uniqueness var serialExists = await _dbContext.Spools .AnyAsync(s => s.SpoolSerial == request.SpoolSerial); if (serialExists) return BadRequest(new { error = $"Spool with serial '{request.SpoolSerial}' already exists." }); // Validate remaining weight doesn't exceed total weight if (request.WeightRemainingGrams > request.WeightTotalGrams) return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." }); var entity = new Spool { MaterialBaseId = request.MaterialBaseId, MaterialFinishId = request.MaterialFinishId, MaterialModifierId = request.MaterialModifierId, Brand = request.Brand, ColorName = request.ColorName, ColorHex = request.ColorHex, WeightTotalGrams = request.WeightTotalGrams, WeightRemainingGrams = request.WeightRemainingGrams, FilamentDiameterMm = request.FilamentDiameterMm, SpoolSerial = request.SpoolSerial, PurchasePrice = request.PurchasePrice, PurchaseDate = request.PurchaseDate, IsActive = request.IsActive }; _dbContext.Spools.Add(entity); await _dbContext.SaveChangesAsync(); // Reload navigation properties for the response await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync(); await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync(); if (entity.MaterialModifierId.HasValue) await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync(); var response = MapToSpoolResponse(entity); return CreatedAtAction(nameof(GetSpool), new { id = entity.Id }, response); } /// /// Updates an existing spool. Validates foreign keys and serial uniqueness /// (excluding the current spool from the uniqueness check). /// /// The unique identifier of the spool to update. /// The spool update request with all required fields. /// The updated spool with current timestamps. /// Returns the updated spool. /// If the spool with the given ID is not found. /// If the request is invalid or foreign keys don't exist. [HttpPut("{id:guid}")] [ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> UpdateSpool( Guid id, [FromBody] UpdateSpoolRequest request) { _logger.LogInformation("Updating spool {Id}", id); var entity = await _dbContext.Spools.FindAsync(id); if (entity is null) { _logger.LogWarning("Spool {Id} not found for update", id); return NotFound(new { error = $"Spool with ID '{id}' not found." }); } // Validate foreign keys exist var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId); if (materialBase is null) return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." }); var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId); if (materialFinish is null) return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." }); if (request.MaterialModifierId.HasValue) { var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value); if (modifier is null) return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." }); } // Check serial uniqueness (excluding current spool) var serialExists = await _dbContext.Spools .AnyAsync(s => s.SpoolSerial == request.SpoolSerial && s.Id != id); if (serialExists) return BadRequest(new { error = $"Spool with serial '{request.SpoolSerial}' already exists." }); // Validate remaining weight doesn't exceed total weight if (request.WeightRemainingGrams > request.WeightTotalGrams) return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." }); entity.MaterialBaseId = request.MaterialBaseId; entity.MaterialFinishId = request.MaterialFinishId; entity.MaterialModifierId = request.MaterialModifierId; entity.Brand = request.Brand; entity.ColorName = request.ColorName; entity.ColorHex = request.ColorHex; entity.WeightTotalGrams = request.WeightTotalGrams; entity.WeightRemainingGrams = request.WeightRemainingGrams; entity.FilamentDiameterMm = request.FilamentDiameterMm; entity.SpoolSerial = request.SpoolSerial; entity.PurchasePrice = request.PurchasePrice; entity.PurchaseDate = request.PurchaseDate; entity.IsActive = request.IsActive; await _dbContext.SaveChangesAsync(); // Reload navigation properties await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync(); await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync(); if (entity.MaterialModifierId.HasValue) await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync(); return Ok(MapToSpoolResponse(entity)); } /// /// Soft-deletes a spool by setting IsActive = false. The spool is retained /// in the database for historical COGS and print job records. /// This is NOT a hard delete — the data is preserved. /// /// The unique identifier of the spool to soft-delete. /// No content on success. /// The spool was successfully soft-deleted. /// If the spool with the given ID is not found. [HttpDelete("{id:guid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task DeleteSpool(Guid id) { _logger.LogInformation("Soft-deleting spool {Id}", id); var entity = await _dbContext.Spools.FindAsync(id); if (entity is null) { _logger.LogWarning("Spool {Id} not found for soft-delete", id); return NotFound(new { error = $"Spool with ID '{id}' not found." }); } if (!entity.IsActive) { _logger.LogDebug("Spool {Id} is already inactive — idempotent no-op", id); return NoContent(); } entity.IsActive = false; await _dbContext.SaveChangesAsync(); _logger.LogInformation("Spool {Id} soft-deleted (IsActive = false)", id); return NoContent(); } // ── Mapping helper ───────────────────────────────────────── private static SpoolResponse MapToSpoolResponse(Spool s) => new() { Id = s.Id, MaterialBaseId = s.MaterialBaseId, MaterialBaseName = s.MaterialBase?.Name ?? string.Empty, MaterialFinishId = s.MaterialFinishId, MaterialFinishName = s.MaterialFinish?.Name ?? string.Empty, MaterialModifierId = s.MaterialModifierId, MaterialModifierName = s.MaterialModifier?.Name, Brand = s.Brand, ColorName = s.ColorName, ColorHex = s.ColorHex, WeightTotalGrams = s.WeightTotalGrams, WeightRemainingGrams = s.WeightRemainingGrams, FilamentDiameterMm = s.FilamentDiameterMm, SpoolSerial = s.SpoolSerial, PurchasePrice = s.PurchasePrice, PurchaseDate = s.PurchaseDate, IsActive = s.IsActive, CreatedAt = s.CreatedAt, UpdatedAt = s.UpdatedAt }; }