using Extrudex.API.DTOs;
using Extrudex.API.DTOs.Filaments;
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.
/// Exposes spool data under the user-facing "filaments" route while mapping
/// to the Spool domain entity internally.
///
[ApiController]
[Route("api/filaments")]
public class FilamentsController : 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 FilamentsController(ExtrudexDbContext dbContext, ILogger logger)
{
_dbContext = dbContext;
_logger = logger;
}
///
/// Gets a paginated list of filament spools with optional filtering by material,
/// brand, and active status. Results are ordered by creation date (newest first)
/// and include denormalized material names for display.
///
/// Optional query parameters for pagination and filtering.
/// A paginated list of filament spools matching the filter criteria.
/// Returns the paginated list of filament spools.
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> GetFilaments(
[FromQuery] FilamentQueryParameters query)
{
_logger.LogDebug(
"Getting filaments: pageNumber={PageNumber}, pageSize={PageSize}, " +
"materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, " +
"materialModifierId={MaterialModifierId}, brand={Brand}, isActive={IsActive}, " +
"includeArchived={IncludeArchived}, storageLocation={StorageLocation}",
query.PageNumber, query.PageSize, query.MaterialBaseId,
query.MaterialFinishId, query.MaterialModifierId, query.Brand, query.IsActive,
query.IncludeArchived, query.StorageLocation);
// 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.MaterialModifierId.HasValue)
spoolQuery = spoolQuery.Where(s => s.MaterialModifierId == query.MaterialModifierId.Value);
if (!string.IsNullOrWhiteSpace(query.Brand))
spoolQuery = spoolQuery.Where(s =>
s.Brand.ToLower().Contains(query.Brand.ToLower()));
if (query.IsActive.HasValue)
spoolQuery = spoolQuery.Where(s => s.IsActive == query.IsActive.Value);
// Exclude archived spools by default; include when explicitly requested
if (query.IncludeArchived != true)
spoolQuery = spoolQuery.Where(s => !s.IsArchived);
if (!string.IsNullOrWhiteSpace(query.StorageLocation))
spoolQuery = spoolQuery.Where(s =>
s.StorageLocation != null &&
s.StorageLocation.ToLower().Contains(query.StorageLocation.ToLower()));
var totalCount = await spoolQuery.CountAsync();
var items = await spoolQuery
.OrderByDescending(s => s.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(s => MapToFilamentResponse(s))
.ToListAsync();
var response = new PagedResponse
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
return Ok(response);
}
///
/// Gets a specific filament spool by its unique identifier.
/// Includes denormalized material names for display.
///
/// The unique identifier of the filament spool.
/// The filament spool details.
/// Returns the filament spool details.
/// If the filament spool with the given ID is not found.
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> GetFilament(Guid id)
{
_logger.LogDebug("Getting filament {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("Filament {Id} not found", id);
return NotFound(new { error = $"Filament with ID '{id}' not found." });
}
return Ok(MapToFilamentResponse(spool));
}
///
/// Creates a new filament spool. Validates that all foreign keys reference
/// existing material entities and that the SpoolSerial is unique.
/// WeightRemainingGrams must not exceed WeightTotalGrams.
///
/// The filament creation request with all required fields.
/// The newly created filament spool with generated ID and timestamps.
/// Returns the created filament spool with location header.
/// If the request is invalid or foreign keys don't exist.
[HttpPost]
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task> CreateFilament(
[FromBody] CreateFilamentRequest request)
{
_logger.LogInformation("Creating filament: {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 = $"Filament 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,
IsArchived = request.IsArchived,
StorageLocation = request.StorageLocation
};
_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 = MapToFilamentResponse(entity);
return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response);
}
///
/// Updates an existing filament spool. Validates foreign keys and serial uniqueness
/// (excluding the current spool from the uniqueness check).
/// WeightRemainingGrams must not exceed WeightTotalGrams.
///
/// The unique identifier of the filament spool to update.
/// The filament update request with all required fields.
/// The updated filament spool with current timestamps.
/// Returns the updated filament spool.
/// If the filament spool with the given ID is not found.
/// If the request is invalid or foreign keys don't exist.
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task> UpdateFilament(
Guid id, [FromBody] UpdateFilamentRequest request)
{
_logger.LogInformation("Updating filament {Id}", id);
var entity = await _dbContext.Spools.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Filament {Id} not found for update", id);
return NotFound(new { error = $"Filament 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 = $"Filament 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;
entity.IsArchived = request.IsArchived;
entity.StorageLocation = request.StorageLocation;
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(MapToFilamentResponse(entity));
}
// ── Mapping helper ─────────────────────────────────────────
///
/// Maps a Spool domain entity to a FilamentResponse DTO.
/// Denormalizes material names for display convenience.
/// Populates the QrCodeUrl for easy frontend access to the spool's QR code.
///
/// The spool entity to map.
/// A FilamentResponse DTO with denormalized material names and QR code URL.
private static FilamentResponse MapToFilamentResponse(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,
IsArchived = s.IsArchived,
StorageLocation = s.StorageLocation,
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt,
QrCodeUrl = $"/api/qr/spool/{s.Id}"
};
}