initial commit

This commit is contained in:
cubecraft-agents[bot]
2026-04-25 18:51:05 +00:00
commit 230c3b295d
78 changed files with 8093 additions and 0 deletions

View File

@@ -0,0 +1,314 @@
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;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/filaments")]
public class FilamentsController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<FilamentsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FilamentsController"/> class.
/// </summary>
/// <param name="dbContext">The database context for data access.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public FilamentsController(ExtrudexDbContext dbContext, ILogger<FilamentsController> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="query">Optional query parameters for pagination and filtering.</param>
/// <returns>A paginated list of filament spools matching the filter criteria.</returns>
/// <response code="200">Returns the paginated list of filament spools.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<FilamentResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<FilamentResponse>>> GetFilaments(
[FromQuery] FilamentQueryParameters query)
{
_logger.LogDebug(
"Getting filaments: pageNumber={PageNumber}, pageSize={PageSize}, " +
"materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, " +
"materialModifierId={MaterialModifierId}, brand={Brand}, isActive={IsActive}",
query.PageNumber, query.PageSize, query.MaterialBaseId,
query.MaterialFinishId, query.MaterialModifierId, query.Brand, 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.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);
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<FilamentResponse>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
return Ok(response);
}
/// <summary>
/// Gets a specific filament spool by its unique identifier.
/// Includes denormalized material names for display.
/// </summary>
/// <param name="id">The unique identifier of the filament spool.</param>
/// <returns>The filament spool details.</returns>
/// <response code="200">Returns the filament spool details.</response>
/// <response code="404">If the filament spool with the given ID is not found.</response>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<FilamentResponse>> 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));
}
/// <summary>
/// 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.
/// </summary>
/// <param name="request">The filament creation request with all required fields.</param>
/// <returns>The newly created filament spool with generated ID and timestamps.</returns>
/// <response code="201">Returns the created filament spool with location header.</response>
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
[HttpPost]
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<FilamentResponse>> 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
};
_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);
}
/// <summary>
/// Updates an existing filament spool. Validates foreign keys and serial uniqueness
/// (excluding the current spool from the uniqueness check).
/// WeightRemainingGrams must not exceed WeightTotalGrams.
/// </summary>
/// <param name="id">The unique identifier of the filament spool to update.</param>
/// <param name="request">The filament update request with all required fields.</param>
/// <returns>The updated filament spool with current timestamps.</returns>
/// <response code="200">Returns the updated filament spool.</response>
/// <response code="404">If the filament spool with the given ID is not found.</response>
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<FilamentResponse>> 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;
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 ─────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
/// <param name="s">The spool entity to map.</param>
/// <returns>A FilamentResponse DTO with denormalized material names and QR code URL.</returns>
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,
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt,
QrCodeUrl = $"/api/qr/spool/{s.Id}"
};
}

View File

@@ -0,0 +1,158 @@
using Extrudex.API.DTOs.Materials;
using Extrudex.Domain.Entities;
using Extrudex.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Extrudex.API.Controllers;
/// <summary>
/// Controller for managing material bases — the core polymer/material type
/// (e.g., PLA, PETG, ABS). Enforces consistent material naming across all spools.
/// </summary>
[ApiController]
[Route("api/material-bases")]
public class MaterialBasesController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<MaterialBasesController> _logger;
public MaterialBasesController(
ExtrudexDbContext dbContext,
ILogger<MaterialBasesController> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
/// Gets all material bases ordered by name.
/// </summary>
/// <returns>A list of all material bases with their densities.</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<MaterialBaseResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MaterialBaseResponse>>> GetMaterialBases()
{
_logger.LogDebug("Getting all material bases");
var bases = await _dbContext.MaterialBases
.OrderBy(mb => mb.Name)
.Select(mb => MapToResponse(mb))
.ToListAsync();
return Ok(bases);
}
/// <summary>
/// Gets a specific material base by ID.
/// </summary>
/// <param name="id">The unique identifier of the material base.</param>
/// <returns>The material base details.</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MaterialBaseResponse>> GetMaterialBase(Guid id)
{
_logger.LogDebug("Getting material base {Id}", id);
var mb = await _dbContext.MaterialBases.FindAsync(id);
if (mb is null)
{
_logger.LogWarning("Material base {Id} not found", id);
return NotFound(new { error = $"Material base with ID '{id}' not found." });
}
return Ok(MapToResponse(mb));
}
/// <summary>
/// Creates a new material base.
/// </summary>
/// <param name="request">The material base creation request.</param>
/// <returns>The created material base.</returns>
[HttpPost]
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MaterialBaseResponse>> CreateMaterialBase(
[FromBody] CreateMaterialBaseRequest request)
{
_logger.LogInformation("Creating material base: {Name}", request.Name);
var entity = new MaterialBase
{
Name = request.Name,
DensityGperCm3 = request.DensityGperCm3
};
_dbContext.MaterialBases.Add(entity);
await _dbContext.SaveChangesAsync();
var response = MapToResponse(entity);
return CreatedAtAction(nameof(GetMaterialBase), new { id = entity.Id }, response);
}
/// <summary>
/// Updates an existing material base.
/// </summary>
/// <param name="id">The unique identifier of the material base to update.</param>
/// <param name="request">The material base update request.</param>
/// <returns>The updated material base.</returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MaterialBaseResponse>> UpdateMaterialBase(
Guid id, [FromBody] UpdateMaterialBaseRequest request)
{
_logger.LogInformation("Updating material base {Id}", id);
var entity = await _dbContext.MaterialBases.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material base {Id} not found for update", id);
return NotFound(new { error = $"Material base with ID '{id}' not found." });
}
entity.Name = request.Name;
entity.DensityGperCm3 = request.DensityGperCm3;
await _dbContext.SaveChangesAsync();
return Ok(MapToResponse(entity));
}
/// <summary>
/// Deletes a material base.
/// </summary>
/// <param name="id">The unique identifier of the material base to delete.</param>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteMaterialBase(Guid id)
{
_logger.LogInformation("Deleting material base {Id}", id);
var entity = await _dbContext.MaterialBases.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material base {Id} not found for deletion", id);
return NotFound(new { error = $"Material base with ID '{id}' not found." });
}
_dbContext.MaterialBases.Remove(entity);
await _dbContext.SaveChangesAsync();
return NoContent();
}
// ── Mapping helper ──────────────────────────────────────────
private static MaterialBaseResponse MapToResponse(MaterialBase mb) => new()
{
Id = mb.Id,
Name = mb.Name,
DensityGperCm3 = mb.DensityGperCm3,
CreatedAt = mb.CreatedAt,
UpdatedAt = mb.UpdatedAt
};
}

View File

@@ -0,0 +1,191 @@
using Extrudex.API.DTOs.Materials;
using Extrudex.Domain.Entities;
using Extrudex.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Extrudex.API.Controllers;
/// <summary>
/// Controller for managing material finishes — the surface finish descriptor
/// for a material. This is REQUIRED on every spool record. Default: "Basic".
/// </summary>
[ApiController]
[Route("api/material-finishes")]
public class MaterialFinishesController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<MaterialFinishesController> _logger;
public MaterialFinishesController(
ExtrudexDbContext dbContext,
ILogger<MaterialFinishesController> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
/// Gets all material finishes, optionally filtered by material base.
/// </summary>
/// <param name="materialBaseId">Optional filter: return finishes for this material base only.</param>
/// <returns>A list of material finishes ordered by base material name, then finish name.</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<MaterialFinishResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MaterialFinishResponse>>> GetMaterialFinishes(
[FromQuery] Guid? materialBaseId)
{
_logger.LogDebug("Getting material finishes, filter: {MaterialBaseId}", materialBaseId);
var query = _dbContext.MaterialFinishes
.Include(mf => mf.MaterialBase)
.AsQueryable();
if (materialBaseId.HasValue)
query = query.Where(mf => mf.MaterialBaseId == materialBaseId.Value);
var finishes = await query
.OrderBy(mf => mf.MaterialBase.Name)
.ThenBy(mf => mf.Name)
.Select(mf => MapToResponse(mf))
.ToListAsync();
return Ok(finishes);
}
/// <summary>
/// Gets a specific material finish by ID.
/// </summary>
/// <param name="id">The unique identifier of the finish.</param>
/// <returns>The material finish details.</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MaterialFinishResponse>> GetMaterialFinish(Guid id)
{
_logger.LogDebug("Getting material finish {Id}", id);
var mf = await _dbContext.MaterialFinishes
.Include(mf => mf.MaterialBase)
.FirstOrDefaultAsync(mf => mf.Id == id);
if (mf is null)
{
_logger.LogWarning("Material finish {Id} not found", id);
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
}
return Ok(MapToResponse(mf));
}
/// <summary>
/// Creates a new material finish.
/// </summary>
/// <param name="request">The material finish creation request.</param>
/// <returns>The created material finish.</returns>
[HttpPost]
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MaterialFinishResponse>> CreateMaterialFinish(
[FromBody] CreateMaterialFinishRequest request)
{
_logger.LogInformation("Creating material finish: {Name} for base {MaterialBaseId}",
request.Name, request.MaterialBaseId);
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
if (materialBase is null)
{
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
}
var entity = new MaterialFinish
{
Name = request.Name,
MaterialBaseId = request.MaterialBaseId
};
_dbContext.MaterialFinishes.Add(entity);
await _dbContext.SaveChangesAsync();
// Reload with navigation for response mapping
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
var response = MapToResponse(entity);
return CreatedAtAction(nameof(GetMaterialFinish), new { id = entity.Id }, response);
}
/// <summary>
/// Updates an existing material finish.
/// </summary>
/// <param name="id">The unique identifier of the finish to update.</param>
/// <param name="request">The material finish update request.</param>
/// <returns>The updated material finish.</returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MaterialFinishResponse>> UpdateMaterialFinish(
Guid id, [FromBody] UpdateMaterialFinishRequest request)
{
_logger.LogInformation("Updating material finish {Id}", id);
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material finish {Id} not found for update", id);
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
}
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
if (materialBase is null)
{
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
}
entity.Name = request.Name;
entity.MaterialBaseId = request.MaterialBaseId;
await _dbContext.SaveChangesAsync();
// Reload with navigation for response mapping
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
return Ok(MapToResponse(entity));
}
/// <summary>
/// Deletes a material finish.
/// </summary>
/// <param name="id">The unique identifier of the finish to delete.</param>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteMaterialFinish(Guid id)
{
_logger.LogInformation("Deleting material finish {Id}", id);
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material finish {Id} not found for deletion", id);
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
}
_dbContext.MaterialFinishes.Remove(entity);
await _dbContext.SaveChangesAsync();
return NoContent();
}
// ── Mapping helper ──────────────────────────────────────────
private static MaterialFinishResponse MapToResponse(MaterialFinish mf) => new()
{
Id = mf.Id,
Name = mf.Name,
MaterialBaseId = mf.MaterialBaseId,
MaterialBaseName = mf.MaterialBase?.Name ?? string.Empty,
CreatedAt = mf.CreatedAt,
UpdatedAt = mf.UpdatedAt
};
}

View File

@@ -0,0 +1,533 @@
using Extrudex.API.DTOs.Materials;
using Extrudex.API.Validators;
using Extrudex.Domain.Entities;
using Extrudex.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Extrudex.API.Controllers;
/// <summary>
/// Controller for querying material metadata — bases, finishes, and modifiers.
/// Provides lookup/reference data for the spool inventory system.
/// </summary>
[ApiController]
[Route("api/materials")]
public class MaterialLookupsController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<MaterialLookupsController> _logger;
public MaterialLookupsController(ExtrudexDbContext dbContext, ILogger<MaterialLookupsController> logger)
{
_dbContext = dbContext;
_logger = logger;
}
// ── MaterialBase ──────────────────────────────────────────
/// <summary>
/// Gets all material bases (PLA, PETG, ABS, etc.).
/// </summary>
/// <returns>A list of all material bases with their densities.</returns>
[HttpGet("bases")]
[ProducesResponseType(typeof(IEnumerable<MaterialBaseResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MaterialBaseResponse>>> GetMaterialBases()
{
_logger.LogDebug("Getting all material bases");
var bases = await _dbContext.MaterialBases
.OrderBy(mb => mb.Name)
.Select(mb => new MaterialBaseResponse
{
Id = mb.Id,
Name = mb.Name,
DensityGperCm3 = mb.DensityGperCm3,
CreatedAt = mb.CreatedAt,
UpdatedAt = mb.UpdatedAt
})
.ToListAsync();
return Ok(bases);
}
/// <summary>
/// Gets a specific material base by ID.
/// </summary>
/// <param name="id">The unique identifier of the material base.</param>
/// <returns>The material base details.</returns>
[HttpGet("bases/{id:guid}")]
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MaterialBaseResponse>> GetMaterialBase(Guid id)
{
_logger.LogDebug("Getting material base {Id}", id);
var mb = await _dbContext.MaterialBases.FindAsync(id);
if (mb is null)
{
_logger.LogWarning("Material base {Id} not found", id);
return NotFound(new { error = $"Material base with ID '{id}' not found." });
}
return Ok(MapToMaterialBaseResponse(mb));
}
/// <summary>
/// Creates a new material base.
/// </summary>
/// <param name="request">The material base creation request.</param>
/// <returns>The created material base.</returns>
[HttpPost("bases")]
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MaterialBaseResponse>> CreateMaterialBase(
[FromBody] CreateMaterialBaseRequest request)
{
_logger.LogInformation("Creating material base: {Name}", request.Name);
var entity = new MaterialBase
{
Name = request.Name,
DensityGperCm3 = request.DensityGperCm3
};
_dbContext.MaterialBases.Add(entity);
await _dbContext.SaveChangesAsync();
var response = MapToMaterialBaseResponse(entity);
return CreatedAtAction(nameof(GetMaterialBase), new { id = entity.Id }, response);
}
/// <summary>
/// Updates an existing material base.
/// </summary>
/// <param name="id">The unique identifier of the material base to update.</param>
/// <param name="request">The material base update request.</param>
/// <returns>The updated material base.</returns>
[HttpPut("bases/{id:guid}")]
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MaterialBaseResponse>> UpdateMaterialBase(
Guid id, [FromBody] UpdateMaterialBaseRequest request)
{
_logger.LogInformation("Updating material base {Id}", id);
var entity = await _dbContext.MaterialBases.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material base {Id} not found for update", id);
return NotFound(new { error = $"Material base with ID '{id}' not found." });
}
entity.Name = request.Name;
entity.DensityGperCm3 = request.DensityGperCm3;
await _dbContext.SaveChangesAsync();
return Ok(MapToMaterialBaseResponse(entity));
}
/// <summary>
/// Deletes a material base.
/// </summary>
/// <param name="id">The unique identifier of the material base to delete.</param>
[HttpDelete("bases/{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteMaterialBase(Guid id)
{
_logger.LogInformation("Deleting material base {Id}", id);
var entity = await _dbContext.MaterialBases.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material base {Id} not found for deletion", id);
return NotFound(new { error = $"Material base with ID '{id}' not found." });
}
_dbContext.MaterialBases.Remove(entity);
await _dbContext.SaveChangesAsync();
return NoContent();
}
// ── MaterialFinish ────────────────────────────────────────
/// <summary>
/// Gets all material finishes, optionally filtered by material base.
/// </summary>
/// <param name="materialBaseId">Optional filter: return finishes for this material base only.</param>
/// <returns>A list of material finishes.</returns>
[HttpGet("finishes")]
[ProducesResponseType(typeof(IEnumerable<MaterialFinishResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MaterialFinishResponse>>> GetMaterialFinishes(
[FromQuery] Guid? materialBaseId)
{
_logger.LogDebug("Getting material finishes, filter: {MaterialBaseId}", materialBaseId);
var query = _dbContext.MaterialFinishes
.Include(mf => mf.MaterialBase)
.AsQueryable();
if (materialBaseId.HasValue)
{
query = query.Where(mf => mf.MaterialBaseId == materialBaseId.Value);
}
var finishes = await query
.OrderBy(mf => mf.MaterialBase.Name)
.ThenBy(mf => mf.Name)
.Select(mf => new MaterialFinishResponse
{
Id = mf.Id,
Name = mf.Name,
MaterialBaseId = mf.MaterialBaseId,
MaterialBaseName = mf.MaterialBase.Name,
CreatedAt = mf.CreatedAt,
UpdatedAt = mf.UpdatedAt
})
.ToListAsync();
return Ok(finishes);
}
/// <summary>
/// Gets a specific material finish by ID.
/// </summary>
/// <param name="id">The unique identifier of the finish.</param>
/// <returns>The material finish details.</returns>
[HttpGet("finishes/{id:guid}")]
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MaterialFinishResponse>> GetMaterialFinish(Guid id)
{
_logger.LogDebug("Getting material finish {Id}", id);
var mf = await _dbContext.MaterialFinishes
.Include(mf => mf.MaterialBase)
.FirstOrDefaultAsync(mf => mf.Id == id);
if (mf is null)
{
_logger.LogWarning("Material finish {Id} not found", id);
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
}
return Ok(MapToMaterialFinishResponse(mf));
}
/// <summary>
/// Creates a new material finish.
/// </summary>
/// <param name="request">The material finish creation request.</param>
/// <returns>The created material finish.</returns>
[HttpPost("finishes")]
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MaterialFinishResponse>> CreateMaterialFinish(
[FromBody] CreateMaterialFinishRequest request)
{
_logger.LogInformation("Creating material finish: {Name} for base {MaterialBaseId}",
request.Name, request.MaterialBaseId);
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
if (materialBase is null)
{
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
}
var entity = new MaterialFinish
{
Name = request.Name,
MaterialBaseId = request.MaterialBaseId
};
_dbContext.MaterialFinishes.Add(entity);
await _dbContext.SaveChangesAsync();
var response = MapToMaterialFinishResponse(entity, materialBase.Name);
return CreatedAtAction(nameof(GetMaterialFinish), new { id = entity.Id }, response);
}
/// <summary>
/// Updates an existing material finish.
/// </summary>
/// <param name="id">The unique identifier of the finish to update.</param>
/// <param name="request">The material finish update request.</param>
/// <returns>The updated material finish.</returns>
[HttpPut("finishes/{id:guid}")]
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MaterialFinishResponse>> UpdateMaterialFinish(
Guid id, [FromBody] UpdateMaterialFinishRequest request)
{
_logger.LogInformation("Updating material finish {Id}", id);
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material finish {Id} not found for update", id);
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
}
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
if (materialBase is null)
{
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
}
entity.Name = request.Name;
entity.MaterialBaseId = request.MaterialBaseId;
await _dbContext.SaveChangesAsync();
return Ok(MapToMaterialFinishResponse(entity, materialBase.Name));
}
/// <summary>
/// Deletes a material finish.
/// </summary>
/// <param name="id">The unique identifier of the finish to delete.</param>
[HttpDelete("finishes/{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteMaterialFinish(Guid id)
{
_logger.LogInformation("Deleting material finish {Id}", id);
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material finish {Id} not found for deletion", id);
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
}
_dbContext.MaterialFinishes.Remove(entity);
await _dbContext.SaveChangesAsync();
return NoContent();
}
// ── MaterialModifier ──────────────────────────────────────
/// <summary>
/// Gets all material modifiers, optionally filtered by material base.
/// </summary>
/// <param name="materialBaseId">Optional filter: return modifiers for this material base only.</param>
/// <returns>A list of material modifiers.</returns>
[HttpGet("modifiers")]
[ProducesResponseType(typeof(IEnumerable<MaterialModifierResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MaterialModifierResponse>>> GetMaterialModifiers(
[FromQuery] Guid? materialBaseId)
{
_logger.LogDebug("Getting material modifiers, filter: {MaterialBaseId}", materialBaseId);
var query = _dbContext.MaterialModifiers
.Include(mm => mm.MaterialBase)
.AsQueryable();
if (materialBaseId.HasValue)
{
query = query.Where(mm => mm.MaterialBaseId == materialBaseId.Value);
}
var modifiers = await query
.OrderBy(mm => mm.MaterialBase.Name)
.ThenBy(mm => mm.Name)
.Select(mm => new MaterialModifierResponse
{
Id = mm.Id,
Name = mm.Name,
MaterialBaseId = mm.MaterialBaseId,
MaterialBaseName = mm.MaterialBase.Name,
CreatedAt = mm.CreatedAt,
UpdatedAt = mm.UpdatedAt
})
.ToListAsync();
return Ok(modifiers);
}
/// <summary>
/// Gets a specific material modifier by ID.
/// </summary>
/// <param name="id">The unique identifier of the modifier.</param>
/// <returns>The material modifier details.</returns>
[HttpGet("modifiers/{id:guid}")]
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MaterialModifierResponse>> GetMaterialModifier(Guid id)
{
_logger.LogDebug("Getting material modifier {Id}", id);
var mm = await _dbContext.MaterialModifiers
.Include(mm => mm.MaterialBase)
.FirstOrDefaultAsync(mm => mm.Id == id);
if (mm is null)
{
_logger.LogWarning("Material modifier {Id} not found", id);
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
}
return Ok(MapToMaterialModifierResponse(mm));
}
/// <summary>
/// Creates a new material modifier.
/// </summary>
/// <param name="request">The material modifier creation request.</param>
/// <returns>The created material modifier.</returns>
[HttpPost("modifiers")]
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MaterialModifierResponse>> CreateMaterialModifier(
[FromBody] CreateMaterialModifierRequest request)
{
_logger.LogInformation("Creating material modifier: {Name} for base {MaterialBaseId}",
request.Name, request.MaterialBaseId);
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
if (materialBase is null)
{
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
}
var entity = new MaterialModifier
{
Name = request.Name,
MaterialBaseId = request.MaterialBaseId
};
_dbContext.MaterialModifiers.Add(entity);
await _dbContext.SaveChangesAsync();
var response = MapToMaterialModifierResponse(entity, materialBase.Name);
return CreatedAtAction(nameof(GetMaterialModifier), new { id = entity.Id }, response);
}
/// <summary>
/// Updates an existing material modifier.
/// </summary>
/// <param name="id">The unique identifier of the modifier to update.</param>
/// <param name="request">The material modifier update request.</param>
/// <returns>The updated material modifier.</returns>
[HttpPut("modifiers/{id:guid}")]
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MaterialModifierResponse>> UpdateMaterialModifier(
Guid id, [FromBody] UpdateMaterialModifierRequest request)
{
_logger.LogInformation("Updating material modifier {Id}", id);
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material modifier {Id} not found for update", id);
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
}
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
if (materialBase is null)
{
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
}
entity.Name = request.Name;
entity.MaterialBaseId = request.MaterialBaseId;
await _dbContext.SaveChangesAsync();
return Ok(MapToMaterialModifierResponse(entity, materialBase.Name));
}
/// <summary>
/// Deletes a material modifier.
/// </summary>
/// <param name="id">The unique identifier of the modifier to delete.</param>
[HttpDelete("modifiers/{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteMaterialModifier(Guid id)
{
_logger.LogInformation("Deleting material modifier {Id}", id);
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material modifier {Id} not found for deletion", id);
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
}
_dbContext.MaterialModifiers.Remove(entity);
await _dbContext.SaveChangesAsync();
return NoContent();
}
// ── Density convenience endpoint ─────────────────────────
/// <summary>
/// Gets all material density data (shorthand for bases with density info).
/// </summary>
/// <returns>A list of material bases with their density values.</returns>
[HttpGet("densities")]
[ProducesResponseType(typeof(IEnumerable<MaterialBaseResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MaterialBaseResponse>>> GetMaterialDensities()
{
_logger.LogDebug("Getting material densities");
var bases = await _dbContext.MaterialBases
.OrderBy(mb => mb.Name)
.Select(mb => new MaterialBaseResponse
{
Id = mb.Id,
Name = mb.Name,
DensityGperCm3 = mb.DensityGperCm3,
CreatedAt = mb.CreatedAt,
UpdatedAt = mb.UpdatedAt
})
.ToListAsync();
return Ok(bases);
}
// ── Mapping helpers ───────────────────────────────────────
private static MaterialBaseResponse MapToMaterialBaseResponse(MaterialBase mb) => new()
{
Id = mb.Id,
Name = mb.Name,
DensityGperCm3 = mb.DensityGperCm3,
CreatedAt = mb.CreatedAt,
UpdatedAt = mb.UpdatedAt
};
private static MaterialFinishResponse MapToMaterialFinishResponse(
MaterialFinish mf, string? materialBaseName = null) => new()
{
Id = mf.Id,
Name = mf.Name,
MaterialBaseId = mf.MaterialBaseId,
MaterialBaseName = materialBaseName ?? mf.MaterialBase?.Name ?? string.Empty,
CreatedAt = mf.CreatedAt,
UpdatedAt = mf.UpdatedAt
};
private static MaterialModifierResponse MapToMaterialModifierResponse(
MaterialModifier mm, string? materialBaseName = null) => new()
{
Id = mm.Id,
Name = mm.Name,
MaterialBaseId = mm.MaterialBaseId,
MaterialBaseName = materialBaseName ?? mm.MaterialBase?.Name ?? string.Empty,
CreatedAt = mm.CreatedAt,
UpdatedAt = mm.UpdatedAt
};
}

View File

@@ -0,0 +1,192 @@
using Extrudex.API.DTOs.Materials;
using Extrudex.Domain.Entities;
using Extrudex.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Extrudex.API.Controllers;
/// <summary>
/// Controller for managing material modifiers — optional additives or
/// fillers for a material (e.g., "Carbon Fiber", "Wood Fill", "Glow-in-the-Dark").
/// Not every spool has a modifier.
/// </summary>
[ApiController]
[Route("api/material-modifiers")]
public class MaterialModifiersController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<MaterialModifiersController> _logger;
public MaterialModifiersController(
ExtrudexDbContext dbContext,
ILogger<MaterialModifiersController> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
/// Gets all material modifiers, optionally filtered by material base.
/// </summary>
/// <param name="materialBaseId">Optional filter: return modifiers for this material base only.</param>
/// <returns>A list of material modifiers ordered by base material name, then modifier name.</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<MaterialModifierResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MaterialModifierResponse>>> GetMaterialModifiers(
[FromQuery] Guid? materialBaseId)
{
_logger.LogDebug("Getting material modifiers, filter: {MaterialBaseId}", materialBaseId);
var query = _dbContext.MaterialModifiers
.Include(mm => mm.MaterialBase)
.AsQueryable();
if (materialBaseId.HasValue)
query = query.Where(mm => mm.MaterialBaseId == materialBaseId.Value);
var modifiers = await query
.OrderBy(mm => mm.MaterialBase.Name)
.ThenBy(mm => mm.Name)
.Select(mm => MapToResponse(mm))
.ToListAsync();
return Ok(modifiers);
}
/// <summary>
/// Gets a specific material modifier by ID.
/// </summary>
/// <param name="id">The unique identifier of the modifier.</param>
/// <returns>The material modifier details.</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MaterialModifierResponse>> GetMaterialModifier(Guid id)
{
_logger.LogDebug("Getting material modifier {Id}", id);
var mm = await _dbContext.MaterialModifiers
.Include(mm => mm.MaterialBase)
.FirstOrDefaultAsync(mm => mm.Id == id);
if (mm is null)
{
_logger.LogWarning("Material modifier {Id} not found", id);
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
}
return Ok(MapToResponse(mm));
}
/// <summary>
/// Creates a new material modifier.
/// </summary>
/// <param name="request">The material modifier creation request.</param>
/// <returns>The created material modifier.</returns>
[HttpPost]
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MaterialModifierResponse>> CreateMaterialModifier(
[FromBody] CreateMaterialModifierRequest request)
{
_logger.LogInformation("Creating material modifier: {Name} for base {MaterialBaseId}",
request.Name, request.MaterialBaseId);
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
if (materialBase is null)
{
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
}
var entity = new MaterialModifier
{
Name = request.Name,
MaterialBaseId = request.MaterialBaseId
};
_dbContext.MaterialModifiers.Add(entity);
await _dbContext.SaveChangesAsync();
// Reload with navigation for response mapping
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
var response = MapToResponse(entity);
return CreatedAtAction(nameof(GetMaterialModifier), new { id = entity.Id }, response);
}
/// <summary>
/// Updates an existing material modifier.
/// </summary>
/// <param name="id">The unique identifier of the modifier to update.</param>
/// <param name="request">The material modifier update request.</param>
/// <returns>The updated material modifier.</returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MaterialModifierResponse>> UpdateMaterialModifier(
Guid id, [FromBody] UpdateMaterialModifierRequest request)
{
_logger.LogInformation("Updating material modifier {Id}", id);
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material modifier {Id} not found for update", id);
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
}
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
if (materialBase is null)
{
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
}
entity.Name = request.Name;
entity.MaterialBaseId = request.MaterialBaseId;
await _dbContext.SaveChangesAsync();
// Reload with navigation for response mapping
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
return Ok(MapToResponse(entity));
}
/// <summary>
/// Deletes a material modifier.
/// </summary>
/// <param name="id">The unique identifier of the modifier to delete.</param>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteMaterialModifier(Guid id)
{
_logger.LogInformation("Deleting material modifier {Id}", id);
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Material modifier {Id} not found for deletion", id);
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
}
_dbContext.MaterialModifiers.Remove(entity);
await _dbContext.SaveChangesAsync();
return NoContent();
}
// ── Mapping helper ──────────────────────────────────────────
private static MaterialModifierResponse MapToResponse(MaterialModifier mm) => new()
{
Id = mm.Id,
Name = mm.Name,
MaterialBaseId = mm.MaterialBaseId,
MaterialBaseName = mm.MaterialBase?.Name ?? string.Empty,
CreatedAt = mm.CreatedAt,
UpdatedAt = mm.UpdatedAt
};
}

View File

@@ -0,0 +1,512 @@
using Extrudex.API.DTOs;
using Extrudex.API.DTOs.PrintJobs;
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
using Extrudex.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Extrudex.API.Controllers;
/// <summary>
/// Controller for managing print jobs — the core operational record of Extrudex.
/// Supports full CRUD with pagination, filtering by printer/spool/status,
/// status transition validation, gram derivation from filament parameters,
/// and soft-delete (sets Status to Cancelled).
/// </summary>
[ApiController]
[Route("api/printjobs")]
public class PrintJobsController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<PrintJobsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="PrintJobsController"/> class.
/// </summary>
/// <param name="dbContext">The database context for data access.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public PrintJobsController(ExtrudexDbContext dbContext, ILogger<PrintJobsController> logger)
{
_dbContext = dbContext;
_logger = logger;
}
// ── GET /api/printjobs ────────────────────────────────────────
/// <summary>
/// Gets a paginated list of print jobs with optional filtering by
/// printer, spool, and status. Results are ordered by creation date
/// (newest first) and include denormalized printer name and spool serial.
/// </summary>
/// <param name="query">Optional query parameters for pagination and filtering.</param>
/// <returns>A paginated list of print jobs matching the filter criteria.</returns>
/// <response code="200">Returns the paginated list of print jobs.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PrintJobResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PrintJobResponse>>> GetPrintJobs(
[FromQuery] PrintJobQueryParameters query)
{
_logger.LogDebug(
"Getting print jobs: pageNumber={PageNumber}, pageSize={PageSize}, " +
"printerId={PrinterId}, spoolId={SpoolId}, status={Status}",
query.PageNumber, query.PageSize, query.PrinterId, query.SpoolId, query.Status);
// Clamp pagination values
var pageNumber = Math.Max(1, query.PageNumber);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var jobQuery = _dbContext.PrintJobs
.Include(j => j.Printer)
.Include(j => j.Spool)
.AsQueryable();
// Apply filters
if (query.PrinterId.HasValue)
jobQuery = jobQuery.Where(j => j.PrinterId == query.PrinterId.Value);
if (query.SpoolId.HasValue)
jobQuery = jobQuery.Where(j => j.SpoolId == query.SpoolId.Value);
if (!string.IsNullOrWhiteSpace(query.Status) &&
Enum.TryParse<JobStatus>(query.Status, ignoreCase: true, out var statusFilter))
{
jobQuery = jobQuery.Where(j => j.Status == statusFilter);
}
var totalCount = await jobQuery.CountAsync();
var items = await jobQuery
.OrderByDescending(j => j.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(j => MapToResponse(j))
.ToListAsync();
var response = new PagedResponse<PrintJobResponse>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
return Ok(response);
}
// ── GET /api/printjobs/{id} ───────────────────────────────────
/// <summary>
/// Gets a specific print job by its unique identifier.
/// Includes denormalized printer name and spool serial for display.
/// </summary>
/// <param name="id">The unique identifier of the print job.</param>
/// <returns>The print job details.</returns>
/// <response code="200">Returns the print job details.</response>
/// <response code="404">If the print job with the given ID is not found.</response>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PrintJobResponse>> GetPrintJob(Guid id)
{
_logger.LogDebug("Getting print job {Id}", id);
var job = await _dbContext.PrintJobs
.Include(j => j.Printer)
.Include(j => j.Spool)
.FirstOrDefaultAsync(j => j.Id == id);
if (job is null)
{
_logger.LogWarning("Print job {Id} not found", id);
return NotFound(new { error = $"Print job with ID '{id}' not found." });
}
return Ok(MapToResponse(job));
}
// ── POST /api/printjobs ───────────────────────────────────────
/// <summary>
/// Creates a new print job record. Validates that PrinterId and SpoolId
/// reference existing entities. When AutoDeriveGrams is true, the gram
/// derivation formula is computed from the spool's material data:
/// grams = mm_extruded × π × (diameter/2)² × (density/1000).
/// </summary>
/// <param name="request">The print job creation request with all required fields.</param>
/// <returns>The newly created print job with generated ID and timestamps.</returns>
/// <response code="201">Returns the created print job with location header.</response>
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
[HttpPost]
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PrintJobResponse>> CreatePrintJob([FromBody] CreatePrintJobRequest request)
{
_logger.LogInformation("Creating print job: {PrintName} on printer {PrinterId} with spool {SpoolId}",
request.PrintName, request.PrinterId, request.SpoolId);
// Validate foreign keys exist
var printer = await _dbContext.Printers.FindAsync(request.PrinterId);
if (printer is null)
return BadRequest(new { error = $"Printer with ID '{request.PrinterId}' not found." });
var spool = await _dbContext.Spools
.Include(s => s.MaterialBase)
.FirstOrDefaultAsync(s => s.Id == request.SpoolId);
if (spool is null)
return BadRequest(new { error = $"Spool with ID '{request.SpoolId}' not found." });
// Resolve gram derivation parameters
decimal filamentDiameter = request.FilamentDiameterAtPrintMm;
decimal materialDensity = request.MaterialDensityAtPrint;
decimal gramsDerived = request.GramsDerived;
if (request.AutoDeriveGrams)
{
filamentDiameter = spool.FilamentDiameterMm;
materialDensity = spool.MaterialBase.DensityGperCm3;
gramsDerived = DeriveGrams(request.MmExtruded, filamentDiameter, materialDensity);
}
else if (request.MmExtruded > 0 && gramsDerived == 0 && materialDensity > 0)
{
// Auto-derive if mm_extruded is provided but gramsDerived is zero
// and we have enough info to compute it
gramsDerived = DeriveGrams(request.MmExtruded, filamentDiameter, materialDensity);
}
// Parse data source enum
if (!Enum.TryParse<DataSource>(request.DataSource, ignoreCase: true, out var dataSource))
return BadRequest(new { error = "DataSource must be 'Mqtt', 'Moonraker', or 'Manual'." });
var entity = new PrintJob
{
PrinterId = request.PrinterId,
SpoolId = request.SpoolId,
PrintName = request.PrintName,
GcodeFilePath = request.GcodeFilePath,
MmExtruded = request.MmExtruded,
GramsDerived = gramsDerived,
CostPerPrint = request.CostPerPrint,
StartedAt = request.StartedAt,
Status = JobStatus.Queued,
DataSource = dataSource,
FilamentDiameterAtPrintMm = filamentDiameter,
MaterialDensityAtPrint = materialDensity,
Notes = request.Notes
};
_dbContext.PrintJobs.Add(entity);
await _dbContext.SaveChangesAsync();
// Reload navigation properties for the response
await _dbContext.Entry(entity).Reference(j => j.Printer).LoadAsync();
await _dbContext.Entry(entity).Reference(j => j.Spool).LoadAsync();
var response = MapToResponse(entity);
return CreatedAtAction(nameof(GetPrintJob), new { id = entity.Id }, response);
}
// ── PUT /api/printjobs/{id} ────────────────────────────────────
/// <summary>
/// Updates an existing print job. Validates that PrinterId and SpoolId
/// reference existing entities. When AutoDeriveGrams is true, gram
/// derivation is recomputed from the spool's current material data.
/// </summary>
/// <param name="id">The unique identifier of the print job to update.</param>
/// <param name="request">The print job update request with all required fields.</param>
/// <returns>The updated print job with current timestamps.</returns>
/// <response code="200">Returns the updated print job.</response>
/// <response code="404">If the print job with the given ID is not found.</response>
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PrintJobResponse>> UpdatePrintJob(
Guid id, [FromBody] UpdatePrintJobRequest request)
{
_logger.LogInformation("Updating print job {Id}", id);
var entity = await _dbContext.PrintJobs.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Print job {Id} not found for update", id);
return NotFound(new { error = $"Print job with ID '{id}' not found." });
}
// Validate foreign keys exist
var printer = await _dbContext.Printers.FindAsync(request.PrinterId);
if (printer is null)
return BadRequest(new { error = $"Printer with ID '{request.PrinterId}' not found." });
var spool = await _dbContext.Spools
.Include(s => s.MaterialBase)
.FirstOrDefaultAsync(s => s.Id == request.SpoolId);
if (spool is null)
return BadRequest(new { error = $"Spool with ID '{request.SpoolId}' not found." });
// Resolve gram derivation
decimal gramsDerived = request.GramsDerived;
decimal filamentDiameter = entity.FilamentDiameterAtPrintMm;
decimal materialDensity = entity.MaterialDensityAtPrint;
if (request.AutoDeriveGrams)
{
filamentDiameter = spool.FilamentDiameterMm;
materialDensity = spool.MaterialBase.DensityGperCm3;
gramsDerived = DeriveGrams(request.MmExtruded, filamentDiameter, materialDensity);
}
// Update entity fields
entity.PrinterId = request.PrinterId;
entity.SpoolId = request.SpoolId;
entity.PrintName = request.PrintName;
entity.GcodeFilePath = request.GcodeFilePath;
entity.MmExtruded = request.MmExtruded;
entity.GramsDerived = gramsDerived;
entity.CostPerPrint = request.CostPerPrint;
entity.FilamentDiameterAtPrintMm = filamentDiameter;
entity.MaterialDensityAtPrint = materialDensity;
entity.Notes = request.Notes;
// If auto-deriving and the spool changed, also recompute COGS
if (request.AutoDeriveGrams && spool.PurchasePrice.HasValue && gramsDerived > 0)
{
var pricePerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams;
entity.CostPerPrint = Math.Round(gramsDerived * pricePerGram, 4);
}
await _dbContext.SaveChangesAsync();
// Reload navigation properties
await _dbContext.Entry(entity).Reference(j => j.Printer).LoadAsync();
await _dbContext.Entry(entity).Reference(j => j.Spool).LoadAsync();
return Ok(MapToResponse(entity));
}
// ── PATCH /api/printjobs/{id}/status ──────────────────────────
/// <summary>
/// Updates the status of a print job with business rule validation.
/// Allowed transitions:
/// - Queued → Printing (job starts)
/// - Printing → Completed (job finishes successfully)
/// - Any → Cancelled (user cancels)
/// - Any → Failed (job fails)
/// - Queued → Queued, Printing → Printing (idempotent)
///
/// Forbidden transitions:
/// - Completed → Queued, Printing (cannot reopen a completed job)
/// - Cancelled → Queued, Printing, Completed (cannot reactivate a cancelled job)
/// - Failed → Queued, Printing, Completed (cannot reactivate a failed job)
/// </summary>
/// <param name="id">The unique identifier of the print job.</param>
/// <param name="request">The status update request containing the new status.</param>
/// <returns>The updated print job with the new status.</returns>
/// <response code="200">Returns the print job with updated status.</response>
/// <response code="404">If the print job with the given ID is not found.</response>
/// <response code="400">If the status transition is invalid.</response>
[HttpPatch("{id:guid}/status")]
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PrintJobResponse>> UpdatePrintJobStatus(
Guid id, [FromBody] UpdatePrintJobStatusRequest request)
{
_logger.LogInformation("Updating print job {Id} status to {NewStatus}", id, request.Status);
var entity = await _dbContext.PrintJobs.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Print job {Id} not found for status update", id);
return NotFound(new { error = $"Print job with ID '{id}' not found." });
}
// Parse the target status
if (!Enum.TryParse<JobStatus>(request.Status, ignoreCase: true, out var newStatus))
{
return BadRequest(new { error = "Status must be one of: Queued, Printing, Completed, Cancelled, Failed." });
}
// Validate the transition
var transitionError = ValidateStatusTransition(entity.Status, newStatus);
if (transitionError is not null)
{
_logger.LogWarning(
"Invalid status transition for print job {Id}: {CurrentStatus} → {NewStatus}. Reason: {Reason}",
id, entity.Status, newStatus, transitionError);
return BadRequest(new { error = transitionError });
}
// Apply the transition
var oldStatus = entity.Status;
entity.Status = newStatus;
// Set timestamps based on transition
if (newStatus == JobStatus.Printing && entity.StartedAt is null)
entity.StartedAt = DateTime.UtcNow;
if (newStatus is JobStatus.Completed or JobStatus.Failed or JobStatus.Cancelled)
entity.CompletedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"Print job {Id} status updated: {OldStatus} → {NewStatus}",
id, oldStatus, newStatus);
// Reload navigation properties for the response
await _dbContext.Entry(entity).Reference(j => j.Printer).LoadAsync();
await _dbContext.Entry(entity).Reference(j => j.Spool).LoadAsync();
return Ok(MapToResponse(entity));
}
// ── DELETE /api/printjobs/{id} ─────────────────────────────────
/// <summary>
/// Soft-deletes a print job by setting its status to Cancelled.
/// This preserves the job record for historical COGS reporting and audit trails.
/// If the job is already in a terminal state (Cancelled, Failed, Completed),
/// the operation is idempotent and returns 204.
/// </summary>
/// <param name="id">The unique identifier of the print job to soft-delete.</param>
/// <returns>No content on success.</returns>
/// <response code="204">The print job was successfully soft-deleted.</response>
/// <response code="404">If the print job with the given ID is not found.</response>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeletePrintJob(Guid id)
{
_logger.LogInformation("Soft-deleting print job {Id}", id);
var entity = await _dbContext.PrintJobs.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Print job {Id} not found for soft-delete", id);
return NotFound(new { error = $"Print job with ID '{id}' not found." });
}
// If already in a terminal/cancelled state, idempotent no-op
if (entity.Status == JobStatus.Cancelled)
{
_logger.LogDebug("Print job {Id} is already cancelled — idempotent no-op", id);
return NoContent();
}
// Only cancel if it makes sense — don't override Completed status
if (entity.Status == JobStatus.Completed)
{
_logger.LogWarning(
"Print job {Id} is Completed — cannot soft-delete. Use explicit status change instead.", id);
return BadRequest(new { error = "Cannot soft-delete a completed print job. Use PATCH /status to change status explicitly." });
}
entity.Status = JobStatus.Cancelled;
entity.CompletedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Print job {Id} soft-deleted (Status set to Cancelled)", id);
return NoContent();
}
// ── Gram Derivation Formula ────────────────────────────────────
/// <summary>
/// Derives grams consumed from millimeters extruded, filament diameter, and material density.
/// Formula: grams = mm_extruded × cross_section_area_mm² × (density_g_per_cm³ / 1000)
/// where cross_section_area = π × (diameter_mm / 2)²
/// The density division by 1000 converts g/cm³ to g/mm³.
/// </summary>
/// <param name="mmExtruded">Total millimeters of filament extruded.</param>
/// <param name="filamentDiameterMm">Filament diameter in mm (typically 1.75mm).</param>
/// <param name="densityGPerCm3">Material density in g/cm³ (e.g., 1.24 for PLA).</param>
/// <returns>Derived grams consumed, rounded to 2 decimal places.</returns>
private static decimal DeriveGrams(decimal mmExtruded, decimal filamentDiameterMm, decimal densityGPerCm3)
{
// Cross-section area in mm²: π × r² where r = diameter/2
var radiusMm = filamentDiameterMm / 2m;
var crossSectionAreaMm2 = Math.Round(Math.PI * (double)(radiusMm * radiusMm), 8);
// Convert density from g/cm³ to g/mm³ by dividing by 1000
var densityGMm3 = (double)densityGPerCm3 / 1000.0;
// grams = mm × area × density
var grams = (double)mmExtruded * crossSectionAreaMm2 * densityGMm3;
return Math.Round((decimal)grams, 2);
}
// ── Status Transition Validation ───────────────────────────────
/// <summary>
/// Validates that a status transition is allowed by business rules.
/// Returns null if the transition is valid, or an error message if not.
/// </summary>
/// <param name="currentStatus">The current status of the print job.</param>
/// <param name="newStatus">The desired new status.</param>
/// <returns>null if valid; an error message string if invalid.</returns>
private static string? ValidateStatusTransition(JobStatus currentStatus, JobStatus newStatus)
{
// Idempotent transitions are always allowed
if (currentStatus == newStatus)
return null;
// Any state can transition to Cancelled or Failed
if (newStatus is JobStatus.Cancelled or JobStatus.Failed)
return null;
// Queued can transition to Printing
if (currentStatus == JobStatus.Queued && newStatus == JobStatus.Printing)
return null;
// Printing can transition to Completed
if (currentStatus == JobStatus.Printing && newStatus == JobStatus.Completed)
return null;
// All other transitions are forbidden
return currentStatus switch
{
JobStatus.Completed =>
$"Cannot change status from '{currentStatus}' to '{newStatus}'. Completed jobs cannot be reactivated.",
JobStatus.Cancelled =>
$"Cannot change status from '{currentStatus}' to '{newStatus}'. Cancelled jobs cannot be reactivated.",
JobStatus.Failed =>
$"Cannot change status from '{currentStatus}' to '{newStatus}'. Failed jobs cannot be reactivated.",
JobStatus.Printing =>
$"Cannot change status from '{currentStatus}' to '{newStatus}'. A printing job can only transition to Completed, Cancelled, or Failed.",
JobStatus.Queued =>
$"Cannot change status from '{currentStatus}' to '{newStatus}'. A queued job can only transition to Printing, Cancelled, or Failed.",
_ =>
$"Invalid status transition from '{currentStatus}' to '{newStatus}'."
};
}
// ── Mapping Helper ─────────────────────────────────────────────
private static PrintJobResponse MapToResponse(PrintJob j) => new()
{
Id = j.Id,
PrinterId = j.PrinterId,
PrinterName = j.Printer?.Name ?? string.Empty,
SpoolId = j.SpoolId,
SpoolSerial = j.Spool?.SpoolSerial ?? string.Empty,
PrintName = j.PrintName,
GcodeFilePath = j.GcodeFilePath,
MmExtruded = j.MmExtruded,
GramsDerived = j.GramsDerived,
CostPerPrint = j.CostPerPrint,
StartedAt = j.StartedAt,
CompletedAt = j.CompletedAt,
Status = j.Status.ToString(),
DataSource = j.DataSource.ToString(),
FilamentDiameterAtPrintMm = j.FilamentDiameterAtPrintMm,
MaterialDensityAtPrint = j.MaterialDensityAtPrint,
Notes = j.Notes,
CreatedAt = j.CreatedAt,
UpdatedAt = j.UpdatedAt
};
}

View File

@@ -0,0 +1,297 @@
using Extrudex.API.DTOs.Printers;
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
using Extrudex.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Extrudex.API.Controllers;
/// <summary>
/// Controller for managing 3D printers in the fleet.
/// Provides CRUD operations, a lightweight status endpoint, and soft-delete.
/// Real-time printer status is also available via the SignalR PrinterHub.
/// </summary>
[ApiController]
[Route("api/printers")]
public class PrintersController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<PrintersController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="PrintersController"/> class.
/// </summary>
/// <param name="dbContext">The database context for data access.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public PrintersController(ExtrudexDbContext dbContext, ILogger<PrintersController> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
/// Gets all printers, optionally filtered by active status.
/// Results are ordered by printer name alphabetically.
/// </summary>
/// <param name="isActive">
/// Optional filter: <c>true</c> for active printers only,
/// <c>false</c> for inactive (soft-deleted) printers,
/// omit for all printers.
/// </param>
/// <returns>A list of printers matching the filter criteria.</returns>
/// <response code="200">Returns the list of printers.</response>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<PrinterResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<PrinterResponse>>> GetPrinters(
[FromQuery] bool? isActive = null)
{
_logger.LogDebug("Getting printers, isActive filter: {IsActive}", isActive);
var query = _dbContext.Printers.AsQueryable();
if (isActive.HasValue)
query = query.Where(p => p.IsActive == isActive.Value);
var printers = await query
.OrderBy(p => p.Name)
.Select(p => MapToPrinterResponse(p))
.ToListAsync();
return Ok(printers);
}
/// <summary>
/// Gets a specific printer by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the printer.</param>
/// <returns>The full printer details including connection configuration.</returns>
/// <response code="200">Returns the printer details.</response>
/// <response code="404">The printer was not found.</response>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PrinterResponse>> GetPrinter(Guid id)
{
_logger.LogDebug("Getting printer {Id}", id);
var printer = await _dbContext.Printers.FindAsync(id);
if (printer is null)
{
_logger.LogWarning("Printer {Id} not found", id);
return NotFound(new { error = $"Printer with ID '{id}' not found." });
}
return Ok(MapToPrinterResponse(printer));
}
/// <summary>
/// Gets the real-time connection/health status of a specific printer.
/// This is a lightweight endpoint suitable for polling or dashboard displays.
/// For push-based real-time updates, subscribe to the SignalR PrinterHub instead.
/// </summary>
/// <param name="id">The unique identifier of the printer.</param>
/// <returns>The printer's current status, last-seen timestamp, and active flag.</returns>
/// <response code="200">Returns the printer status.</response>
/// <response code="404">The printer was not found.</response>
[HttpGet("{id:guid}/status")]
[ProducesResponseType(typeof(PrinterStatusResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PrinterStatusResponse>> GetPrinterStatus(Guid id)
{
_logger.LogDebug("Getting status for printer {Id}", id);
var printer = await _dbContext.Printers.FindAsync(id);
if (printer is null)
{
_logger.LogWarning("Printer {Id} not found for status check", id);
return NotFound(new { error = $"Printer with ID '{id}' not found." });
}
return Ok(new PrinterStatusResponse
{
Id = printer.Id,
Name = printer.Name,
Status = printer.Status.ToString(),
LastSeenAt = printer.LastSeenAt,
IsActive = printer.IsActive
});
}
/// <summary>
/// Registers a new printer in the fleet.
/// The printer is created with an initial status of Offline.
/// The port defaults to 8883 for MQTT or 7125 for Moonraker if not specified.
/// </summary>
/// <param name="request">The printer registration request with connection details.</param>
/// <returns>The newly created printer.</returns>
/// <response code="201">The printer was created successfully.</response>
/// <response code="400">The request body is invalid or validation failed.</response>
[HttpPost]
[ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PrinterResponse>> CreatePrinter(
[FromBody] CreatePrinterRequest request)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
_logger.LogInformation("Creating printer: {Name} ({Manufacturer} {Model})",
request.Name, request.Manufacturer, request.Model);
// Parse enums from string values
if (!Enum.TryParse<PrinterType>(request.PrinterType, true, out var printerType))
{
return BadRequest(new { error = $"Invalid PrinterType: '{request.PrinterType}'. Must be 'Fdm' or 'Resin'." });
}
if (!Enum.TryParse<ConnectionType>(request.ConnectionType, true, out var connectionType))
{
return BadRequest(new { error = $"Invalid ConnectionType: '{request.ConnectionType}'. Must be 'Mqtt' or 'Moonraker'." });
}
// Default port based on connection type
var port = request.Port > 0
? request.Port
: connectionType == ConnectionType.Mqtt ? 8883 : 7125;
var entity = new Printer
{
Name = request.Name,
Manufacturer = request.Manufacturer,
Model = request.Model,
PrinterType = printerType,
ConnectionType = connectionType,
HostnameOrIp = request.HostnameOrIp,
Port = port,
MqttUsername = request.MqttUsername,
MqttPassword = request.MqttPassword,
MqttUseTls = request.MqttUseTls,
ApiKey = request.ApiKey,
IsActive = request.IsActive,
Status = PrinterStatus.Offline
};
_dbContext.Printers.Add(entity);
await _dbContext.SaveChangesAsync();
var response = MapToPrinterResponse(entity);
return CreatedAtAction(nameof(GetPrinter), new { id = entity.Id }, response);
}
/// <summary>
/// Updates an existing printer's configuration and connection info.
/// Uses full replacement semantics — all fields are written.
/// The port defaults to 8883 for MQTT or 7125 for Moonraker if not specified.
/// </summary>
/// <param name="id">The unique identifier of the printer to update.</param>
/// <param name="request">The printer update request with new values.</param>
/// <returns>The updated printer.</returns>
/// <response code="200">The printer was updated successfully.</response>
/// <response code="400">The request body is invalid or validation failed.</response>
/// <response code="404">The printer was not found.</response>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PrinterResponse>> UpdatePrinter(
Guid id, [FromBody] UpdatePrinterRequest request)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
_logger.LogInformation("Updating printer {Id}", id);
var entity = await _dbContext.Printers.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Printer {Id} not found for update", id);
return NotFound(new { error = $"Printer with ID '{id}' not found." });
}
// Parse enums from string values
if (!Enum.TryParse<PrinterType>(request.PrinterType, true, out var printerType))
{
return BadRequest(new { error = $"Invalid PrinterType: '{request.PrinterType}'. Must be 'Fdm' or 'Resin'." });
}
if (!Enum.TryParse<ConnectionType>(request.ConnectionType, true, out var connectionType))
{
return BadRequest(new { error = $"Invalid ConnectionType: '{request.ConnectionType}'. Must be 'Mqtt' or 'Moonraker'." });
}
var port = request.Port > 0
? request.Port
: connectionType == ConnectionType.Mqtt ? 8883 : 7125;
entity.Name = request.Name;
entity.Manufacturer = request.Manufacturer;
entity.Model = request.Model;
entity.PrinterType = printerType;
entity.ConnectionType = connectionType;
entity.HostnameOrIp = request.HostnameOrIp;
entity.Port = port;
entity.MqttUsername = request.MqttUsername;
entity.MqttPassword = request.MqttPassword;
entity.MqttUseTls = request.MqttUseTls;
entity.ApiKey = request.ApiKey;
entity.IsActive = request.IsActive;
await _dbContext.SaveChangesAsync();
return Ok(MapToPrinterResponse(entity));
}
/// <summary>
/// Soft-deletes a printer by setting <c>IsActive</c> to <c>false</c>.
/// The printer record is retained for historical and audit purposes.
/// Soft-deleted printers can be recovered via PUT with <c>IsActive = true</c>.
/// </summary>
/// <param name="id">The unique identifier of the printer to soft-delete.</param>
/// <returns>No content on success.</returns>
/// <response code="204">The printer was soft-deleted successfully.</response>
/// <response code="404">The printer was not found.</response>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeletePrinter(Guid id)
{
_logger.LogInformation("Soft-deleting printer {Id}", id);
var entity = await _dbContext.Printers.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Printer {Id} not found for soft-delete", id);
return NotFound(new { error = $"Printer with ID '{id}' not found." });
}
entity.IsActive = false;
await _dbContext.SaveChangesAsync();
return NoContent();
}
// ── Mapping helper ───────────────────────────────────────────────
/// <summary>
/// Maps a <see cref="Printer"/> domain entity to a <see cref="PrinterResponse"/> DTO.
/// </summary>
/// <param name="p">The printer entity to map.</param>
/// <returns>A response DTO suitable for API output.</returns>
private static PrinterResponse MapToPrinterResponse(Printer p) => new()
{
Id = p.Id,
Status = p.Status.ToString(),
Name = p.Name,
Manufacturer = p.Manufacturer,
Model = p.Model,
PrinterType = p.PrinterType.ToString(),
ConnectionType = p.ConnectionType.ToString(),
HostnameOrIp = p.HostnameOrIp,
Port = p.Port,
IsActive = p.IsActive,
LastSeenAt = p.LastSeenAt,
CreatedAt = p.CreatedAt,
UpdatedAt = p.UpdatedAt
};
}

View File

@@ -0,0 +1,101 @@
using Extrudex.Domain.Enums;
using Extrudex.Domain.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Extrudex.API.Controllers;
/// <summary>
/// Controller for generating QR codes for Extrudex resources (spools, printers, locations).
/// Returns QR codes as PNG or SVG images optimized for small label printing.
/// QR codes encode deep links that resolve to the resource's detail page.
/// </summary>
[ApiController]
[Route("api/qr")]
public class QrController : ControllerBase
{
private readonly IQrCodeService _qrCodeService;
private readonly ILogger<QrController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="QrController"/> class.
/// </summary>
/// <param name="qrCodeService">The QR code generation service.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public QrController(IQrCodeService qrCodeService, ILogger<QrController> logger)
{
_qrCodeService = qrCodeService;
_logger = logger;
}
/// <summary>
/// Generates a QR code for the specified resource type and ID.
/// Returns the QR code as a PNG image by default, or as SVG when
/// the <c>format</c> query parameter is set to "svg".
/// </summary>
/// <param name="resourceType">
/// The type of resource: <c>spool</c>, <c>printer</c>, or <c>location</c>.
/// </param>
/// <param name="id">The unique identifier of the resource.</param>
/// <param name="format">
/// Optional output format: <c>png</c> (default) or <c>svg</c>.
/// SVG is resolution-independent and ideal for printing at any scale.
/// </param>
/// <param name="size">
/// Optional pixel density per QR module for PNG output (default: 20).
/// Ignored for SVG output. Range: 440.
/// </param>
/// <returns>The QR code image in the requested format.</returns>
/// <response code="200">Returns the QR code image.</response>
/// <response code="400">If the resource type is invalid or parameters are out of range.</response>
[HttpGet("{resourceType}/{id:guid}")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK, "image/png")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK, "image/svg+xml")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult GetQrCode(
string resourceType,
Guid id,
[FromQuery] string format = "png",
[FromQuery] int size = 20)
{
_logger.LogDebug("Generating QR code: resourceType={ResourceType}, id={Id}, format={Format}, size={Size}",
resourceType, id, format, size);
// Parse resource type
if (!Enum.TryParse<QrResourceType>(resourceType, ignoreCase: true, out var parsedType))
{
return BadRequest(new
{
error = $"Invalid resource type: '{resourceType}'. " +
$"Valid values are: spool, printer, location."
});
}
// Validate format
format = format.ToLowerInvariant();
if (format is not ("png" or "svg"))
{
return BadRequest(new
{
error = $"Invalid format: '{format}'. Valid values are: png, svg."
});
}
// Validate size for PNG
if (format == "png" && (size < 4 || size > 40))
{
return BadRequest(new
{
error = $"Size must be between 4 and 40. Received: {size}."
});
}
if (format == "svg")
{
var svg = _qrCodeService.GenerateSvg(parsedType, id);
return Content(svg, "image/svg+xml");
}
var pngBytes = _qrCodeService.GeneratePng(parsedType, id, size);
return File(pngBytes, "image/png");
}
}

View File

@@ -0,0 +1,329 @@
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;
/// <summary>
/// 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).
/// </summary>
[ApiController]
[Route("api/spools")]
public class SpoolsController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<SpoolsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SpoolsController"/> class.
/// </summary>
/// <param name="dbContext">The database context for data access.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public SpoolsController(ExtrudexDbContext dbContext, ILogger<SpoolsController> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="query">Optional query parameters for pagination and filtering.</param>
/// <returns>A paginated list of spools matching the filter criteria.</returns>
/// <response code="200">Returns the paginated list of spools.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SpoolResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SpoolResponse>>> 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<SpoolResponse>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
return Ok(response);
}
/// <summary>
/// Gets a specific spool by its unique identifier.
/// Includes denormalized material names for display.
/// </summary>
/// <param name="id">The unique identifier of the spool.</param>
/// <returns>The spool details.</returns>
/// <response code="200">Returns the spool details.</response>
/// <response code="404">If the spool with the given ID is not found.</response>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SpoolResponse>> 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));
}
/// <summary>
/// Creates a new spool record. Validates that all foreign keys reference existing entities
/// and that the SpoolSerial is unique across all spools.
/// </summary>
/// <param name="request">The spool creation request with all required fields.</param>
/// <returns>The newly created spool with generated ID and timestamps.</returns>
/// <response code="201">Returns the created spool with location header.</response>
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
[HttpPost]
[ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<SpoolResponse>> 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);
}
/// <summary>
/// Updates an existing spool. Validates foreign keys and serial uniqueness
/// (excluding the current spool from the uniqueness check).
/// </summary>
/// <param name="id">The unique identifier of the spool to update.</param>
/// <param name="request">The spool update request with all required fields.</param>
/// <returns>The updated spool with current timestamps.</returns>
/// <response code="200">Returns the updated spool.</response>
/// <response code="404">If the spool with the given ID is not found.</response>
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<SpoolResponse>> 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));
}
/// <summary>
/// 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.
/// </summary>
/// <param name="id">The unique identifier of the spool to soft-delete.</param>
/// <returns>No content on success.</returns>
/// <response code="204">The spool was successfully soft-deleted.</response>
/// <response code="404">If the spool with the given ID is not found.</response>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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
};
}

View File

@@ -0,0 +1,199 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.Filaments;
/// <summary>
/// Response DTO for a filament spool — the core inventory unit of Extrudex.
/// Contains all spool details including denormalized material names for display.
/// </summary>
public class FilamentResponse
{
/// <summary>Unique identifier for the filament spool.</summary>
public Guid Id { get; set; }
/// <summary>Foreign key to the base material.</summary>
public Guid MaterialBaseId { get; set; }
/// <summary>Name of the base material (e.g., "PLA", "PETG").</summary>
public string MaterialBaseName { get; set; } = string.Empty;
/// <summary>Foreign key to the material finish.</summary>
public Guid MaterialFinishId { get; set; }
/// <summary>Name of the material finish (e.g., "Basic", "Matte").</summary>
public string MaterialFinishName { get; set; } = string.Empty;
/// <summary>Foreign key to the optional material modifier. Null if none.</summary>
public Guid? MaterialModifierId { get; set; }
/// <summary>Name of the material modifier (e.g., "Carbon Fiber"). Null if none.</summary>
public string? MaterialModifierName { get; set; }
/// <summary>Brand name (e.g., "Bambu Lab", "Polymaker").</summary>
public string Brand { get; set; } = string.Empty;
/// <summary>Human-readable color name (e.g., "Fire Engine Red").</summary>
public string ColorName { get; set; } = string.Empty;
/// <summary>Hex color code (e.g., "#FF0000").</summary>
public string ColorHex { get; set; } = string.Empty;
/// <summary>Total spool weight in grams when full.</summary>
public decimal WeightTotalGrams { get; set; }
/// <summary>Current remaining weight in grams.</summary>
public decimal WeightRemainingGrams { get; set; }
/// <summary>Filament diameter in millimeters. Typically 1.75mm.</summary>
public decimal FilamentDiameterMm { get; set; }
/// <summary>Manufacturer-assigned serial number. Must be unique.</summary>
public string SpoolSerial { get; set; } = string.Empty;
/// <summary>Purchase price per spool. Null if not tracked.</summary>
public decimal? PurchasePrice { get; set; }
/// <summary>Date the spool was purchased or received.</summary>
public DateTime? PurchaseDate { get; set; }
/// <summary>Whether the spool is currently active and available.</summary>
public bool IsActive { get; set; }
/// <summary>Timestamp when this record was created (UTC).</summary>
public DateTime CreatedAt { get; set; }
/// <summary>Timestamp when this record was last updated (UTC).</summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// URL to the QR code image for this spool.
/// Encodes a deep link to the spool's detail page.
/// </summary>
public string QrCodeUrl { get; set; } = string.Empty;
}
/// <summary>
/// Request DTO for creating a new filament spool.
/// All required fields must be provided. MaterialFinish is required — use "Basic" as the default.
/// </summary>
public class CreateFilamentRequest
{
/// <summary>Foreign key to the base material. Required.</summary>
[Required(ErrorMessage = "MaterialBaseId is required.")]
public Guid MaterialBaseId { get; set; }
/// <summary>Foreign key to the material finish. Required — default is "Basic".</summary>
[Required(ErrorMessage = "MaterialFinishId is required.")]
public Guid MaterialFinishId { get; set; }
/// <summary>Foreign key to the optional material modifier. Null if none applies.</summary>
public Guid? MaterialModifierId { get; set; }
/// <summary>Brand name (e.g., "Bambu Lab", "Polymaker"). Required, max 200 characters.</summary>
[Required(ErrorMessage = "Brand is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "Brand must be between 1 and 200 characters.")]
public string Brand { get; set; } = string.Empty;
/// <summary>Human-readable color name (e.g., "Fire Engine Red"). Required, max 200 characters.</summary>
[Required(ErrorMessage = "ColorName is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "ColorName must be between 1 and 200 characters.")]
public string ColorName { get; set; } = string.Empty;
/// <summary>Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex.</summary>
[Required(ErrorMessage = "ColorHex is required.")]
[RegularExpression(@"^#[0-9A-Fa-f]{6}$", ErrorMessage = "ColorHex must be a valid hex color code (e.g., #FF0000).")]
[StringLength(7, MinimumLength = 7, ErrorMessage = "ColorHex must be exactly 7 characters (e.g., #FF0000).")]
public string ColorHex { get; set; } = string.Empty;
/// <summary>Total spool weight in grams when full. Must be greater than zero.</summary>
[Required(ErrorMessage = "WeightTotalGrams is required.")]
[Range(0.01, 100000, ErrorMessage = "Total weight must be between 0.01 and 100,000 grams.")]
public decimal WeightTotalGrams { get; set; }
/// <summary>Current remaining weight in grams. Must be non-negative.</summary>
[Required(ErrorMessage = "WeightRemainingGrams is required.")]
[Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")]
public decimal WeightRemainingGrams { get; set; }
/// <summary>Filament diameter in mm. Defaults to 1.75. Must be greater than zero.</summary>
[Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")]
public decimal FilamentDiameterMm { get; set; } = 1.75m;
/// <summary>Manufacturer-assigned serial number. Must be unique, max 200 characters.</summary>
[Required(ErrorMessage = "SpoolSerial is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "SpoolSerial must be between 1 and 200 characters.")]
public string SpoolSerial { get; set; } = string.Empty;
/// <summary>Optional purchase price per spool. Must be non-negative if provided.</summary>
[Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")]
public decimal? PurchasePrice { get; set; }
/// <summary>Optional purchase date. Must be a valid date if provided.</summary>
public DateTime? PurchaseDate { get; set; }
/// <summary>Whether the spool is active. Defaults to true.</summary>
public bool IsActive { get; set; } = true;
}
/// <summary>
/// Request DTO for updating an existing filament spool.
/// All required fields must be provided for a full update.
/// </summary>
public class UpdateFilamentRequest
{
/// <summary>Foreign key to the base material. Required.</summary>
[Required(ErrorMessage = "MaterialBaseId is required.")]
public Guid MaterialBaseId { get; set; }
/// <summary>Foreign key to the material finish. Required.</summary>
[Required(ErrorMessage = "MaterialFinishId is required.")]
public Guid MaterialFinishId { get; set; }
/// <summary>Foreign key to the optional material modifier. Null if none applies.</summary>
public Guid? MaterialModifierId { get; set; }
/// <summary>Brand name. Required, max 200 characters.</summary>
[Required(ErrorMessage = "Brand is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "Brand must be between 1 and 200 characters.")]
public string Brand { get; set; } = string.Empty;
/// <summary>Human-readable color name. Required, max 200 characters.</summary>
[Required(ErrorMessage = "ColorName is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "ColorName must be between 1 and 200 characters.")]
public string ColorName { get; set; } = string.Empty;
/// <summary>Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex.</summary>
[Required(ErrorMessage = "ColorHex is required.")]
[RegularExpression(@"^#[0-9A-Fa-f]{6}$", ErrorMessage = "ColorHex must be a valid hex color code (e.g., #FF0000).")]
[StringLength(7, MinimumLength = 7, ErrorMessage = "ColorHex must be exactly 7 characters (e.g., #FF0000).")]
public string ColorHex { get; set; } = string.Empty;
/// <summary>Total spool weight in grams when full. Must be greater than zero.</summary>
[Required(ErrorMessage = "WeightTotalGrams is required.")]
[Range(0.01, 100000, ErrorMessage = "Total weight must be between 0.01 and 100,000 grams.")]
public decimal WeightTotalGrams { get; set; }
/// <summary>Current remaining weight in grams. Must be non-negative.</summary>
[Required(ErrorMessage = "WeightRemainingGrams is required.")]
[Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")]
public decimal WeightRemainingGrams { get; set; }
/// <summary>Filament diameter in mm. Must be greater than zero.</summary>
[Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")]
public decimal FilamentDiameterMm { get; set; } = 1.75m;
/// <summary>Manufacturer-assigned serial number. Must be unique, max 200 characters.</summary>
[Required(ErrorMessage = "SpoolSerial is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "SpoolSerial must be between 1 and 200 characters.")]
public string SpoolSerial { get; set; } = string.Empty;
/// <summary>Optional purchase price per spool. Must be non-negative if provided.</summary>
[Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")]
public decimal? PurchasePrice { get; set; }
/// <summary>Optional purchase date.</summary>
public DateTime? PurchaseDate { get; set; }
/// <summary>Whether the spool is active.</summary>
public bool IsActive { get; set; } = true;
}

View File

@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.Filaments;
/// <summary>
/// Query parameters for filtering and paginating the filament list endpoint.
/// All parameters are optional — defaults are applied when not provided.
/// </summary>
public class FilamentQueryParameters
{
/// <summary>Page number (1-based). Defaults to 1.</summary>
[Range(1, int.MaxValue, ErrorMessage = "PageNumber must be at least 1.")]
public int PageNumber { get; set; } = 1;
/// <summary>Number of items per page. Defaults to 20, max 100.</summary>
[Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")]
public int PageSize { get; set; } = 20;
/// <summary>Optional filter by material base ID.</summary>
public Guid? MaterialBaseId { get; set; }
/// <summary>Optional filter by material finish ID.</summary>
public Guid? MaterialFinishId { get; set; }
/// <summary>Optional filter by material modifier ID.</summary>
public Guid? MaterialModifierId { get; set; }
/// <summary>Optional filter by brand name (case-insensitive partial match).</summary>
public string? Brand { get; set; }
/// <summary>Optional filter by active status. True = active only, False = inactive only.</summary>
public bool? IsActive { get; set; }
}

View File

@@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.Materials;
/// <summary>
/// Response DTO for MaterialBase entity.
/// </summary>
public class MaterialBaseResponse
{
/// <summary>Unique identifier for the material base.</summary>
public Guid Id { get; set; }
/// <summary>Human-readable name (e.g., "PLA", "PETG", "ABS").</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Density in g/cm³ used for grams-derived calculations.</summary>
public decimal DensityGperCm3 { get; set; }
/// <summary>Timestamp when this record was created (UTC).</summary>
public DateTime CreatedAt { get; set; }
/// <summary>Timestamp when this record was last updated (UTC).</summary>
public DateTime UpdatedAt { get; set; }
}
/// <summary>
/// Request DTO for creating a new MaterialBase.
/// </summary>
public class CreateMaterialBaseRequest
{
/// <summary>Human-readable name (e.g., "PLA", "PETG", "ABS"). Required, max 50 characters.</summary>
[Required(ErrorMessage = "Name is required.")]
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
public string Name { get; set; } = string.Empty;
/// <summary>Density in g/cm³. Must be greater than zero.</summary>
[Required(ErrorMessage = "Density is required.")]
[Range(0.001, 100.0, ErrorMessage = "Density must be between 0.001 and 100.0 g/cm³.")]
public decimal DensityGperCm3 { get; set; }
}
/// <summary>
/// Request DTO for updating an existing MaterialBase.
/// </summary>
public class UpdateMaterialBaseRequest
{
/// <summary>Human-readable name (e.g., "PLA", "PETG", "ABS"). Required, max 50 characters.</summary>
[Required(ErrorMessage = "Name is required.")]
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
public string Name { get; set; } = string.Empty;
/// <summary>Density in g/cm³. Must be greater than zero.</summary>
[Required(ErrorMessage = "Density is required.")]
[Range(0.001, 100.0, ErrorMessage = "Density must be between 0.001 and 100.0 g/cm³.")]
public decimal DensityGperCm3 { get; set; }
}

View File

@@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.Materials;
/// <summary>
/// Response DTO for MaterialFinish entity.
/// </summary>
public class MaterialFinishResponse
{
/// <summary>Unique identifier for the finish.</summary>
public Guid Id { get; set; }
/// <summary>Human-readable name (e.g., "Basic", "Matte", "Silk").</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Foreign key to the parent MaterialBase.</summary>
public Guid MaterialBaseId { get; set; }
/// <summary>Name of the parent material base (for display).</summary>
public string MaterialBaseName { get; set; } = string.Empty;
/// <summary>Timestamp when this record was created (UTC).</summary>
public DateTime CreatedAt { get; set; }
/// <summary>Timestamp when this record was last updated (UTC).</summary>
public DateTime UpdatedAt { get; set; }
}
/// <summary>
/// Request DTO for creating a new MaterialFinish.
/// </summary>
public class CreateMaterialFinishRequest
{
/// <summary>Human-readable name (e.g., "Basic", "Matte", "Silk"). Required, max 50 characters.</summary>
[Required(ErrorMessage = "Name is required.")]
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
public string Name { get; set; } = string.Empty;
/// <summary>Foreign key to the parent MaterialBase. Required.</summary>
[Required(ErrorMessage = "MaterialBaseId is required.")]
public Guid MaterialBaseId { get; set; }
}
/// <summary>
/// Request DTO for updating an existing MaterialFinish.
/// </summary>
public class UpdateMaterialFinishRequest
{
/// <summary>Human-readable name (e.g., "Basic", "Matte", "Silk"). Required, max 50 characters.</summary>
[Required(ErrorMessage = "Name is required.")]
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
public string Name { get; set; } = string.Empty;
/// <summary>Foreign key to the parent MaterialBase. Required.</summary>
[Required(ErrorMessage = "MaterialBaseId is required.")]
public Guid MaterialBaseId { get; set; }
}

View File

@@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.Materials;
/// <summary>
/// Response DTO for MaterialModifier entity.
/// </summary>
public class MaterialModifierResponse
{
/// <summary>Unique identifier for the modifier.</summary>
public Guid Id { get; set; }
/// <summary>Human-readable name (e.g., "Carbon Fiber", "Wood Fill").</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Foreign key to the parent MaterialBase.</summary>
public Guid MaterialBaseId { get; set; }
/// <summary>Name of the parent material base (for display).</summary>
public string MaterialBaseName { get; set; } = string.Empty;
/// <summary>Timestamp when this record was created (UTC).</summary>
public DateTime CreatedAt { get; set; }
/// <summary>Timestamp when this record was last updated (UTC).</summary>
public DateTime UpdatedAt { get; set; }
}
/// <summary>
/// Request DTO for creating a new MaterialModifier.
/// </summary>
public class CreateMaterialModifierRequest
{
/// <summary>Human-readable name (e.g., "Carbon Fiber", "Wood Fill"). Required, max 50 characters.</summary>
[Required(ErrorMessage = "Name is required.")]
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
public string Name { get; set; } = string.Empty;
/// <summary>Foreign key to the parent MaterialBase. Required.</summary>
[Required(ErrorMessage = "MaterialBaseId is required.")]
public Guid MaterialBaseId { get; set; }
}
/// <summary>
/// Request DTO for updating an existing MaterialModifier.
/// </summary>
public class UpdateMaterialModifierRequest
{
/// <summary>Human-readable name (e.g., "Carbon Fiber", "Wood Fill"). Required, max 50 characters.</summary>
[Required(ErrorMessage = "Name is required.")]
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
public string Name { get; set; } = string.Empty;
/// <summary>Foreign key to the parent MaterialBase. Required.</summary>
[Required(ErrorMessage = "MaterialBaseId is required.")]
public Guid MaterialBaseId { get; set; }
}

View File

@@ -0,0 +1,30 @@
namespace Extrudex.API.DTOs;
/// <summary>
/// Generic paginated response wrapper for list endpoints.
/// Provides pagination metadata alongside the result items.
/// </summary>
/// <typeparam name="T">The type of items in the page.</typeparam>
public class PagedResponse<T>
{
/// <summary>The items in the current page.</summary>
public IReadOnlyList<T> Items { get; set; } = [];
/// <summary>Total number of items across all pages.</summary>
public int TotalCount { get; set; }
/// <summary>Current page number (1-based).</summary>
public int PageNumber { get; set; }
/// <summary>Number of items per page.</summary>
public int PageSize { get; set; }
/// <summary>Total number of pages.</summary>
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling(TotalCount / (double)PageSize) : 0;
/// <summary>Whether there is a next page.</summary>
public bool HasNextPage => PageNumber < TotalPages;
/// <summary>Whether there is a previous page.</summary>
public bool HasPreviousPage => PageNumber > 1;
}

View File

@@ -0,0 +1,223 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.PrintJobs;
/// <summary>
/// Response DTO for PrintJob entity. Contains all job details including
/// denormalized printer name and spool serial for display.
/// Audit snapshots (filament diameter and material density) preserve COGS accuracy
/// even if the source data changes after the print.
/// </summary>
public class PrintJobResponse
{
/// <summary>Unique identifier for the print job.</summary>
public Guid Id { get; set; }
/// <summary>Foreign key to the printer that executed this job.</summary>
public Guid PrinterId { get; set; }
/// <summary>Name of the printer that executed this job.</summary>
public string PrinterName { get; set; } = string.Empty;
/// <summary>Foreign key to the spool that provided filament.</summary>
public Guid SpoolId { get; set; }
/// <summary>Serial number of the spool that provided filament.</summary>
public string SpoolSerial { get; set; } = string.Empty;
/// <summary>Human-readable name or identifier for the print job.</summary>
public string PrintName { get; set; } = string.Empty;
/// <summary>Path or filename of the G-code file.</summary>
public string? GcodeFilePath { get; set; }
/// <summary>Total millimeters of filament extruded during this print.</summary>
public decimal MmExtruded { get; set; }
/// <summary>
/// Derived grams consumed for this print, calculated as:
/// mm_extruded × cross_section_area × material_density.
/// Cross-section area = π × (filament_diameter / 2)² in mm².
/// Density converted from g/cm³ to g/mm³ by dividing by 1000.
/// </summary>
public decimal GramsDerived { get; set; }
/// <summary>Calculated cost of goods sold (COGS) for this print job.</summary>
public decimal? CostPerPrint { get; set; }
/// <summary>Timestamp when the print job started (UTC).</summary>
public DateTime? StartedAt { get; set; }
/// <summary>Timestamp when the print job completed or failed (UTC).</summary>
public DateTime? CompletedAt { get; set; }
/// <summary>Current status of the print job (Queued, Printing, Completed, Cancelled, Failed).</summary>
public string Status { get; set; } = string.Empty;
/// <summary>Data source that provided this job (Mqtt, Moonraker, Manual).</summary>
public string DataSource { get; set; } = string.Empty;
/// <summary>Audit snapshot: filament diameter (mm) recorded at time of print.</summary>
public decimal FilamentDiameterAtPrintMm { get; set; }
/// <summary>Audit snapshot: material density (g/cm³) recorded at time of print.</summary>
public decimal MaterialDensityAtPrint { get; set; }
/// <summary>Optional notes about the print job.</summary>
public string? Notes { get; set; }
/// <summary>Timestamp when this record was created (UTC).</summary>
public DateTime CreatedAt { get; set; }
/// <summary>Timestamp when this record was last updated (UTC).</summary>
public DateTime UpdatedAt { get; set; }
}
/// <summary>
/// Request DTO for creating a new print job. The gram derivation formula
/// (grams = mm_extruded × cross_section_area × material_density) can be
/// auto-computed server-side when MmExtruded, FilamentDiameterAtPrintMm,
/// and MaterialDensityAtPrint are provided. Alternatively, set AutoDeriveGrams
/// to true and provide a SpoolId to pull density from the material base.
/// </summary>
public class CreatePrintJobRequest
{
/// <summary>Foreign key to the printer that will execute this job. Required.</summary>
[Required(ErrorMessage = "PrinterId is required.")]
public Guid PrinterId { get; set; }
/// <summary>Foreign key to the spool providing filament. Required.</summary>
[Required(ErrorMessage = "SpoolId is required.")]
public Guid SpoolId { get; set; }
/// <summary>Human-readable name for the print job. Required, max 200 characters.</summary>
[Required(ErrorMessage = "PrintName is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "PrintName must be between 1 and 200 characters.")]
public string PrintName { get; set; } = string.Empty;
/// <summary>Optional path or filename of the G-code file. Max 500 characters.</summary>
[StringLength(500, ErrorMessage = "GcodeFilePath must not exceed 500 characters.")]
public string? GcodeFilePath { get; set; }
/// <summary>Total millimeters of filament extruded. Must be non-negative. Defaults to 0.</summary>
[Range(0, double.MaxValue, ErrorMessage = "MmExtruded must be non-negative.")]
public decimal MmExtruded { get; set; }
/// <summary>
/// Derived grams consumed. If AutoDeriveGrams is true, this is computed
/// server-side and the provided value is ignored. Must be non-negative if manually set.
/// </summary>
[Range(0, double.MaxValue, ErrorMessage = "GramsDerived must be non-negative.")]
public decimal GramsDerived { get; set; }
/// <summary>Optional calculated COGS. Must be non-negative if provided.</summary>
[Range(0, double.MaxValue, ErrorMessage = "CostPerPrint must be non-negative.")]
public decimal? CostPerPrint { get; set; }
/// <summary>Optional timestamp when the job started (UTC).</summary>
public DateTime? StartedAt { get; set; }
/// <summary>
/// Data source for this job. Must be "Mqtt", "Moonraker", or "Manual".
/// Defaults to "Manual".
/// </summary>
[Required(ErrorMessage = "DataSource is required.")]
[RegularExpression("^(Mqtt|Moonraker|Manual)$", ErrorMessage = "DataSource must be 'Mqtt', 'Moonraker', or 'Manual'.")]
public string DataSource { get; set; } = "Manual";
/// <summary>
/// Audit snapshot: filament diameter in mm at time of print. Must be greater than zero.
/// Defaults to 1.75mm if not specified and AutoDeriveGrams is false.
/// </summary>
[Range(0.01, 100, ErrorMessage = "FilamentDiameterAtPrintMm must be between 0.01 and 100 mm.")]
public decimal FilamentDiameterAtPrintMm { get; set; } = 1.75m;
/// <summary>
/// Audit snapshot: material density in g/cm³ at time of print. Must be greater than zero.
/// If AutoDeriveGrams is true, this is populated from the spool's material base.
/// </summary>
[Range(0.001, 100, ErrorMessage = "MaterialDensityAtPrint must be between 0.001 and 100 g/cm³.")]
public decimal MaterialDensityAtPrint { get; set; }
/// <summary>Optional notes about the print job. Max 2000 characters.</summary>
[StringLength(2000, ErrorMessage = "Notes must not exceed 2000 characters.")]
public string? Notes { get; set; }
/// <summary>
/// When true, the server auto-derives GramsDerived, FilamentDiameterAtPrintMm,
/// and MaterialDensityAtPrint from the spool's material data.
/// MmExtruded must still be provided. Overrides manual GramsDerived.
/// </summary>
public bool AutoDeriveGrams { get; set; }
}
/// <summary>
/// Request DTO for updating an existing print job. Full replacement semantics —
/// all required fields must be provided. Gram derivation can be recomputed
/// by setting AutoDeriveGrams to true.
/// </summary>
public class UpdatePrintJobRequest
{
/// <summary>Foreign key to the printer. Required.</summary>
[Required(ErrorMessage = "PrinterId is required.")]
public Guid PrinterId { get; set; }
/// <summary>Foreign key to the spool. Required.</summary>
[Required(ErrorMessage = "SpoolId is required.")]
public Guid SpoolId { get; set; }
/// <summary>Human-readable name for the print job. Required, max 200 characters.</summary>
[Required(ErrorMessage = "PrintName is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "PrintName must be between 1 and 200 characters.")]
public string PrintName { get; set; } = string.Empty;
/// <summary>Optional path or filename of the G-code file. Max 500 characters.</summary>
[StringLength(500, ErrorMessage = "GcodeFilePath must not exceed 500 characters.")]
public string? GcodeFilePath { get; set; }
/// <summary>Total millimeters of filament extruded. Must be non-negative.</summary>
[Range(0, double.MaxValue, ErrorMessage = "MmExtruded must be non-negative.")]
public decimal MmExtruded { get; set; }
/// <summary>
/// Derived grams consumed. If AutoDeriveGrams is true, this is recomputed
/// server-side and the provided value is ignored.
/// </summary>
[Range(0, double.MaxValue, ErrorMessage = "GramsDerived must be non-negative.")]
public decimal GramsDerived { get; set; }
/// <summary>Optional calculated COGS. Must be non-negative if provided.</summary>
[Range(0, double.MaxValue, ErrorMessage = "CostPerPrint must be non-negative.")]
public decimal? CostPerPrint { get; set; }
/// <summary>Optional notes about the print job. Max 2000 characters.</summary>
[StringLength(2000, ErrorMessage = "Notes must not exceed 2000 characters.")]
public string? Notes { get; set; }
/// <summary>
/// When true, the server recomputes GramsDerived, FilamentDiameterAtPrintMm,
/// and MaterialDensityAtPrint from the spool's current material data.
/// MmExtruded must still be provided.
/// </summary>
public bool AutoDeriveGrams { get; set; }
}
/// <summary>
/// Request DTO for the PATCH /api/printjobs/{id}/status endpoint.
/// Validates that the transition is allowed by business rules:
/// - Can always go to Cancelled or Failed from any state.
/// - Can move from Queued → Printing, Printing → Completed.
/// - Cannot move from Completed back to Printing or Queued.
/// - Cannot move from Cancelled back to any active state.
/// </summary>
public class UpdatePrintJobStatusRequest
{
/// <summary>
/// New status for the print job. Must be one of: Queued, Printing, Completed, Cancelled, Failed.
/// Case-insensitive.
/// </summary>
[Required(ErrorMessage = "Status is required.")]
[RegularExpression("^(Queued|Printing|Completed|Cancelled|Failed)$",
ErrorMessage = "Status must be one of: Queued, Printing, Completed, Cancelled, Failed.")]
public string Status { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.PrintJobs;
/// <summary>
/// Query parameters for filtering and paginating the print job list endpoint.
/// All parameters are optional — defaults are applied when not provided.
/// </summary>
public class PrintJobQueryParameters
{
/// <summary>Page number (1-based). Defaults to 1.</summary>
[Range(1, int.MaxValue, ErrorMessage = "PageNumber must be at least 1.")]
public int PageNumber { get; set; } = 1;
/// <summary>Number of items per page. Defaults to 20, max 100.</summary>
[Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")]
public int PageSize { get; set; } = 20;
/// <summary>Optional filter by printer ID. Only returns jobs for this printer.</summary>
public Guid? PrinterId { get; set; }
/// <summary>Optional filter by spool ID. Only returns jobs that used this spool.</summary>
public Guid? SpoolId { get; set; }
/// <summary>
/// Optional filter by job status. Must be a valid JobStatus value
/// (Queued, Printing, Completed, Cancelled, Failed). Case-insensitive.
/// </summary>
public string? Status { get; set; }
}

View File

@@ -0,0 +1,190 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.Printers;
/// <summary>
/// Response DTO for a Printer entity. Contains all printer details
/// including connection configuration and operational status.
/// </summary>
public class PrinterResponse
{
/// <summary>Unique identifier for the printer.</summary>
public Guid Id { get; set; }
/// <summary>Current operational status (Idle, Printing, Offline, Error, Paused).</summary>
public string Status { get; set; } = string.Empty;
/// <summary>Human-readable name (e.g., "Bambu X1C #1", "Elegoo Centauri").</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Manufacturer/brand (e.g., "Bambu Lab", "Elegoo").</summary>
public string Manufacturer { get; set; } = string.Empty;
/// <summary>Model name (e.g., "X1 Carbon", "Centauri Carbon").</summary>
public string Model { get; set; } = string.Empty;
/// <summary>Printer hardware type ("Fdm" or "Resin").</summary>
public string PrinterType { get; set; } = string.Empty;
/// <summary>Connectivity protocol ("Mqtt" or "Moonraker").</summary>
public string ConnectionType { get; set; } = string.Empty;
/// <summary>Hostname or IP address for printer connection.</summary>
public string HostnameOrIp { get; set; } = string.Empty;
/// <summary>Port number for the connection (8883 for MQTT/TLS, 7125 for Moonraker).</summary>
public int Port { get; set; }
/// <summary>Whether the printer is currently active and available for jobs.</summary>
public bool IsActive { get; set; }
/// <summary>Timestamp of the last status update received from the printer (UTC).</summary>
public DateTime? LastSeenAt { get; set; }
/// <summary>Timestamp when this record was created (UTC).</summary>
public DateTime CreatedAt { get; set; }
/// <summary>Timestamp when this record was last modified (UTC).</summary>
public DateTime UpdatedAt { get; set; }
}
/// <summary>
/// Lightweight response DTO for printer status. Optimized for polling
/// and dashboard displays. For real-time updates, use the SignalR PrinterHub.
/// </summary>
public class PrinterStatusResponse
{
/// <summary>Unique identifier for the printer.</summary>
public Guid Id { get; set; }
/// <summary>Human-readable name of the printer.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Current operational status (Idle, Printing, Offline, Error, Paused).</summary>
public string Status { get; set; } = string.Empty;
/// <summary>Timestamp of the last status update received from the printer (UTC).</summary>
public DateTime? LastSeenAt { get; set; }
/// <summary>Whether the printer is currently active and available for jobs.</summary>
public bool IsActive { get; set; }
}
/// <summary>
/// Request DTO for registering a new printer in the fleet.
/// All string enums accept: PrinterType = "Fdm"|"Resin",
/// ConnectionType = "Mqtt"|"Moonraker" (case-insensitive).
/// </summary>
public class CreatePrinterRequest
{
/// <summary>Human-readable name for the printer. Required, max 100 characters.</summary>
[Required(ErrorMessage = "Name is required.")]
[StringLength(100, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 100 characters.")]
public string Name { get; set; } = string.Empty;
/// <summary>Manufacturer/brand (e.g., "Bambu Lab", "Elegoo"). Required, max 50 characters.</summary>
[Required(ErrorMessage = "Manufacturer is required.")]
[StringLength(50, MinimumLength = 1, ErrorMessage = "Manufacturer must be between 1 and 50 characters.")]
public string Manufacturer { get; set; } = string.Empty;
/// <summary>Model name (e.g., "X1 Carbon", "Centauri Carbon"). Required, max 50 characters.</summary>
[Required(ErrorMessage = "Model is required.")]
[StringLength(50, MinimumLength = 1, ErrorMessage = "Model must be between 1 and 50 characters.")]
public string Model { get; set; } = string.Empty;
/// <summary>Printer hardware type: "Fdm" or "Resin". Defaults to "Fdm".</summary>
[Required(ErrorMessage = "PrinterType is required.")]
[RegularExpression("^(Fdm|Resin)$", ErrorMessage = "PrinterType must be 'Fdm' or 'Resin'.")]
public string PrinterType { get; set; } = "Fdm";
/// <summary>Connectivity protocol: "Mqtt" or "Moonraker". Defaults to "Mqtt".</summary>
[Required(ErrorMessage = "ConnectionType is required.")]
[RegularExpression("^(Mqtt|Moonraker)$", ErrorMessage = "ConnectionType must be 'Mqtt' or 'Moonraker'.")]
public string ConnectionType { get; set; } = "Mqtt";
/// <summary>Hostname or IP address for printer connection. Required, max 253 characters.</summary>
[Required(ErrorMessage = "HostnameOrIp is required.")]
[StringLength(253, MinimumLength = 1, ErrorMessage = "HostnameOrIp must be between 1 and 253 characters.")]
public string HostnameOrIp { get; set; } = string.Empty;
/// <summary>Port number. Defaults: 8883 (MQTT/TLS), 7125 (Moonraker) if zero.</summary>
[Range(0, 65535, ErrorMessage = "Port must be between 0 and 65535.")]
public int Port { get; set; }
/// <summary>MQTT username for Bambu Lab authentication. Used only for MQTT connection type.</summary>
[StringLength(100, ErrorMessage = "MqttUsername must be at most 100 characters.")]
public string MqttUsername { get; set; } = string.Empty;
/// <summary>MQTT password for Bambu Lab authentication. Used only for MQTT connection type.</summary>
[StringLength(200, ErrorMessage = "MqttPassword must be at most 200 characters.")]
public string MqttPassword { get; set; } = string.Empty;
/// <summary>Whether to use TLS for MQTT. Bambu Lab printers require TLS on port 8883.</summary>
public bool MqttUseTls { get; set; }
/// <summary>Moonraker API key for Elegoo Centauri Carbon. Used only for Moonraker connection type.</summary>
[StringLength(100, ErrorMessage = "ApiKey must be at most 100 characters.")]
public string ApiKey { get; set; } = string.Empty;
/// <summary>Whether the printer is active and available for jobs. Defaults to true.</summary>
public bool IsActive { get; set; } = true;
}
/// <summary>
/// Request DTO for updating an existing printer's configuration and connection info.
/// All fields are provided on update (full replacement semantics).
/// </summary>
public class UpdatePrinterRequest
{
/// <summary>Human-readable name for the printer. Required, max 100 characters.</summary>
[Required(ErrorMessage = "Name is required.")]
[StringLength(100, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 100 characters.")]
public string Name { get; set; } = string.Empty;
/// <summary>Manufacturer/brand. Required, max 50 characters.</summary>
[Required(ErrorMessage = "Manufacturer is required.")]
[StringLength(50, MinimumLength = 1, ErrorMessage = "Manufacturer must be between 1 and 50 characters.")]
public string Manufacturer { get; set; } = string.Empty;
/// <summary>Model name. Required, max 50 characters.</summary>
[Required(ErrorMessage = "Model is required.")]
[StringLength(50, MinimumLength = 1, ErrorMessage = "Model must be between 1 and 50 characters.")]
public string Model { get; set; } = string.Empty;
/// <summary>Printer hardware type: "Fdm" or "Resin".</summary>
[Required(ErrorMessage = "PrinterType is required.")]
[RegularExpression("^(Fdm|Resin)$", ErrorMessage = "PrinterType must be 'Fdm' or 'Resin'.")]
public string PrinterType { get; set; } = "Fdm";
/// <summary>Connectivity protocol: "Mqtt" or "Moonraker".</summary>
[Required(ErrorMessage = "ConnectionType is required.")]
[RegularExpression("^(Mqtt|Moonraker)$", ErrorMessage = "ConnectionType must be 'Mqtt' or 'Moonraker'.")]
public string ConnectionType { get; set; } = "Mqtt";
/// <summary>Hostname or IP address. Required, max 253 characters.</summary>
[Required(ErrorMessage = "HostnameOrIp is required.")]
[StringLength(253, MinimumLength = 1, ErrorMessage = "HostnameOrIp must be between 1 and 253 characters.")]
public string HostnameOrIp { get; set; } = string.Empty;
/// <summary>Port number. Defaults: 8883 (MQTT/TLS), 7125 (Moonraker) if zero.</summary>
[Range(0, 65535, ErrorMessage = "Port must be between 0 and 65535.")]
public int Port { get; set; }
/// <summary>MQTT username. Used only for MQTT connection type.</summary>
[StringLength(100, ErrorMessage = "MqttUsername must be at most 100 characters.")]
public string MqttUsername { get; set; } = string.Empty;
/// <summary>MQTT password. Used only for MQTT connection type.</summary>
[StringLength(200, ErrorMessage = "MqttPassword must be at most 200 characters.")]
public string MqttPassword { get; set; } = string.Empty;
/// <summary>Whether to use TLS for MQTT.</summary>
public bool MqttUseTls { get; set; }
/// <summary>Moonraker API key. Used only for Moonraker connection type.</summary>
[StringLength(100, ErrorMessage = "ApiKey must be at most 100 characters.")]
public string ApiKey { get; set; } = string.Empty;
/// <summary>Whether the printer is active and available for jobs.</summary>
public bool IsActive { get; set; } = true;
}

View File

@@ -0,0 +1,193 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.Spools;
/// <summary>
/// Response DTO for Spool entity — the core inventory unit of Extrudex.
/// Contains all spool details including denormalized material names for display.
/// </summary>
public class SpoolResponse
{
/// <summary>Unique identifier for the spool.</summary>
public Guid Id { get; set; }
/// <summary>Foreign key to the base material.</summary>
public Guid MaterialBaseId { get; set; }
/// <summary>Name of the base material (e.g., "PLA", "PETG").</summary>
public string MaterialBaseName { get; set; } = string.Empty;
/// <summary>Foreign key to the material finish.</summary>
public Guid MaterialFinishId { get; set; }
/// <summary>Name of the material finish (e.g., "Basic", "Matte").</summary>
public string MaterialFinishName { get; set; } = string.Empty;
/// <summary>Foreign key to the optional material modifier. Null if none.</summary>
public Guid? MaterialModifierId { get; set; }
/// <summary>Name of the material modifier (e.g., "Carbon Fiber"). Null if none.</summary>
public string? MaterialModifierName { get; set; }
/// <summary>Brand name (e.g., "Bambu Lab", "Polymaker").</summary>
public string Brand { get; set; } = string.Empty;
/// <summary>Human-readable color name (e.g., "Fire Engine Red").</summary>
public string ColorName { get; set; } = string.Empty;
/// <summary>Hex color code (e.g., "#FF0000").</summary>
public string ColorHex { get; set; } = string.Empty;
/// <summary>Total spool weight in grams when full.</summary>
public decimal WeightTotalGrams { get; set; }
/// <summary>Current remaining weight in grams.</summary>
public decimal WeightRemainingGrams { get; set; }
/// <summary>Filament diameter in millimeters. Typically 1.75mm.</summary>
public decimal FilamentDiameterMm { get; set; }
/// <summary>Manufacturer-assigned serial number. Must be unique.</summary>
public string SpoolSerial { get; set; } = string.Empty;
/// <summary>Purchase price per spool. Null if not tracked.</summary>
public decimal? PurchasePrice { get; set; }
/// <summary>Date the spool was purchased or received.</summary>
public DateTime? PurchaseDate { get; set; }
/// <summary>Whether the spool is currently active and available.</summary>
public bool IsActive { get; set; }
/// <summary>Timestamp when this record was created (UTC).</summary>
public DateTime CreatedAt { get; set; }
/// <summary>Timestamp when this record was last updated (UTC).</summary>
public DateTime UpdatedAt { get; set; }
}
/// <summary>
/// Request DTO for creating a new spool.
/// All required fields must be provided. MaterialFinish is required — use "Basic" as the default.
/// </summary>
public class CreateSpoolRequest
{
/// <summary>Foreign key to the base material. Required.</summary>
[Required(ErrorMessage = "MaterialBaseId is required.")]
public Guid MaterialBaseId { get; set; }
/// <summary>Foreign key to the material finish. Required — default is "Basic".</summary>
[Required(ErrorMessage = "MaterialFinishId is required.")]
public Guid MaterialFinishId { get; set; }
/// <summary>Foreign key to the optional material modifier. Null if none applies.</summary>
public Guid? MaterialModifierId { get; set; }
/// <summary>Brand name (e.g., "Bambu Lab", "Polymaker"). Required, max 200 characters.</summary>
[Required(ErrorMessage = "Brand is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "Brand must be between 1 and 200 characters.")]
public string Brand { get; set; } = string.Empty;
/// <summary>Human-readable color name (e.g., "Fire Engine Red"). Required, max 200 characters.</summary>
[Required(ErrorMessage = "ColorName is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "ColorName must be between 1 and 200 characters.")]
public string ColorName { get; set; } = string.Empty;
/// <summary>Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex.</summary>
[Required(ErrorMessage = "ColorHex is required.")]
[RegularExpression(@"^#[0-9A-Fa-f]{6}$", ErrorMessage = "ColorHex must be a valid hex color code (e.g., #FF0000).")]
[StringLength(7, MinimumLength = 7, ErrorMessage = "ColorHex must be exactly 7 characters (e.g., #FF0000).")]
public string ColorHex { get; set; } = string.Empty;
/// <summary>Total spool weight in grams when full. Must be greater than zero.</summary>
[Required(ErrorMessage = "WeightTotalGrams is required.")]
[Range(0.01, 100000, ErrorMessage = "Total weight must be between 0.01 and 100,000 grams.")]
public decimal WeightTotalGrams { get; set; }
/// <summary>Current remaining weight in grams. Must be non-negative.</summary>
[Required(ErrorMessage = "WeightRemainingGrams is required.")]
[Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")]
public decimal WeightRemainingGrams { get; set; }
/// <summary>Filament diameter in mm. Defaults to 1.75. Must be greater than zero.</summary>
[Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")]
public decimal FilamentDiameterMm { get; set; } = 1.75m;
/// <summary>Manufacturer-assigned serial number. Must be unique, max 200 characters.</summary>
[Required(ErrorMessage = "SpoolSerial is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "SpoolSerial must be between 1 and 200 characters.")]
public string SpoolSerial { get; set; } = string.Empty;
/// <summary>Optional purchase price per spool. Must be non-negative if provided.</summary>
[Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")]
public decimal? PurchasePrice { get; set; }
/// <summary>Optional purchase date. Must be a valid date if provided.</summary>
public DateTime? PurchaseDate { get; set; }
/// <summary>Whether the spool is active. Defaults to true.</summary>
public bool IsActive { get; set; } = true;
}
/// <summary>
/// Request DTO for updating an existing spool.
/// All required fields must be provided for a full update.
/// </summary>
public class UpdateSpoolRequest
{
/// <summary>Foreign key to the base material. Required.</summary>
[Required(ErrorMessage = "MaterialBaseId is required.")]
public Guid MaterialBaseId { get; set; }
/// <summary>Foreign key to the material finish. Required.</summary>
[Required(ErrorMessage = "MaterialFinishId is required.")]
public Guid MaterialFinishId { get; set; }
/// <summary>Foreign key to the optional material modifier. Null if none applies.</summary>
public Guid? MaterialModifierId { get; set; }
/// <summary>Brand name. Required, max 200 characters.</summary>
[Required(ErrorMessage = "Brand is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "Brand must be between 1 and 200 characters.")]
public string Brand { get; set; } = string.Empty;
/// <summary>Human-readable color name. Required, max 200 characters.</summary>
[Required(ErrorMessage = "ColorName is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "ColorName must be between 1 and 200 characters.")]
public string ColorName { get; set; } = string.Empty;
/// <summary>Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex.</summary>
[Required(ErrorMessage = "ColorHex is required.")]
[RegularExpression(@"^#[0-9A-Fa-f]{6}$", ErrorMessage = "ColorHex must be a valid hex color code (e.g., #FF0000).")]
[StringLength(7, MinimumLength = 7, ErrorMessage = "ColorHex must be exactly 7 characters (e.g., #FF0000).")]
public string ColorHex { get; set; } = string.Empty;
/// <summary>Total spool weight in grams when full. Must be greater than zero.</summary>
[Required(ErrorMessage = "WeightTotalGrams is required.")]
[Range(0.01, 100000, ErrorMessage = "Total weight must be between 0.01 and 100,000 grams.")]
public decimal WeightTotalGrams { get; set; }
/// <summary>Current remaining weight in grams. Must be non-negative.</summary>
[Required(ErrorMessage = "WeightRemainingGrams is required.")]
[Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")]
public decimal WeightRemainingGrams { get; set; }
/// <summary>Filament diameter in mm. Must be greater than zero.</summary>
[Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")]
public decimal FilamentDiameterMm { get; set; } = 1.75m;
/// <summary>Manufacturer-assigned serial number. Must be unique, max 200 characters.</summary>
[Required(ErrorMessage = "SpoolSerial is required.")]
[StringLength(200, MinimumLength = 1, ErrorMessage = "SpoolSerial must be between 1 and 200 characters.")]
public string SpoolSerial { get; set; } = string.Empty;
/// <summary>Optional purchase price per spool. Must be non-negative if provided.</summary>
[Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")]
public decimal? PurchasePrice { get; set; }
/// <summary>Optional purchase date.</summary>
public DateTime? PurchaseDate { get; set; }
/// <summary>Whether the spool is active.</summary>
public bool IsActive { get; set; } = true;
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.Spools;
/// <summary>
/// Query parameters for filtering and paginating the spool list endpoint.
/// All parameters are optional — defaults are applied when not provided.
/// </summary>
public class SpoolQueryParameters
{
/// <summary>Page number (1-based). Defaults to 1.</summary>
[Range(1, int.MaxValue, ErrorMessage = "PageNumber must be at least 1.")]
public int PageNumber { get; set; } = 1;
/// <summary>Number of items per page. Defaults to 20, max 100.</summary>
[Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")]
public int PageSize { get; set; } = 20;
/// <summary>Optional filter by material base ID.</summary>
public Guid? MaterialBaseId { get; set; }
/// <summary>Optional filter by material finish ID.</summary>
public Guid? MaterialFinishId { get; set; }
/// <summary>Optional filter by active status. True = active only, False = inactive only.</summary>
public bool? IsActive { get; set; }
}

View File

@@ -0,0 +1,31 @@
namespace Extrudex.API.Hubs;
/// <summary>
/// Strongly-typed client interface for the SignalR PrinterHub.
/// Defines the methods that the server can invoke on connected clients
/// to push real-time printer status updates.
/// </summary>
public interface IPrinterClient
{
/// <summary>
/// Pushes a full printer status update to all clients subscribed
/// to a specific printer's group. Fired whenever a printer's
/// operational status changes (e.g., Idle → Printing, Offline → Idle).
/// </summary>
/// <param name="printerId">The unique identifier of the printer that changed.</param>
/// <param name="status">The new status value (e.g., "Idle", "Printing", "Offline", "Error", "Paused").</param>
/// <param name="lastSeenAt">Timestamp (UTC) of when this status was last observed.</param>
/// <returns>A Task that completes when the client has processed the update.</returns>
Task PrinterStatusChanged(Guid printerId, string status, DateTime? lastSeenAt);
/// <summary>
/// Pushes a lightweight heartbeat to confirm that a printer is still
/// reachable and its connection is alive. Useful for dashboards that
/// display online/offline indicators without requiring a full status payload.
/// </summary>
/// <param name="printerId">The unique identifier of the printer.</param>
/// <param name="isActive">Whether the printer is currently active and available for jobs.</param>
/// <param name="lastSeenAt">Timestamp (UTC) of the last telemetry received from the printer.</param>
/// <returns>A Task that completes when the client has processed the heartbeat.</returns>
Task PrinterHeartbeat(Guid printerId, bool isActive, DateTime? lastSeenAt);
}

View File

@@ -0,0 +1,137 @@
using Microsoft.AspNetCore.SignalR;
namespace Extrudex.API.Hubs;
/// <summary>
/// SignalR hub for real-time printer status updates.
///
/// Clients connect to this hub to receive push notifications when
/// a printer's status changes (e.g., Idle → Printing, Offline → Idle)
/// or when a heartbeat confirms the printer is still reachable.
///
/// <para>Usage flow:</para>
/// <list type="number">
/// <item>Client connects to /hubs/printer</item>
/// <item>Client calls <see cref="JoinPrinterGroup"/> with a printer ID</item>
/// <item>Server adds the connection to a SignalR group named after the printer ID</item>
/// <item>When the backend detects a status change, it calls
/// <see cref="PrinterHubExtensions.PushPrinterStatusAsync"/>
/// which broadcasts to all subscribers of that printer</item>
/// </list>
///
/// <para>Group naming: <c>printer:{printerId}</c> (lowercase GUID).</para>
///
/// <para>Typed client: <see cref="IPrinterClient"/> — all server-to-client
/// calls go through this interface for compile-time safety.</para>
/// </summary>
public class PrinterHub : Hub<IPrinterClient>
{
/// <summary>
/// Adds the calling connection to the SignalR group for a specific printer.
/// Once joined, the client will receive all status updates and heartbeats
/// for that printer until it disconnects or calls <see cref="LeavePrinterGroup"/>.
/// </summary>
/// <param name="printerId">
/// The unique identifier of the printer to subscribe to.
/// The GUID is normalized to lowercase for consistent group naming.
/// </param>
/// <exception cref="HubException">
/// Thrown if <paramref name="printerId"/> cannot be parsed as a valid GUID.
/// </exception>
public async Task JoinPrinterGroup(Guid printerId)
{
var groupName = PrinterGroupName(printerId);
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
}
/// <summary>
/// Removes the calling connection from the SignalR group for a specific printer.
/// After leaving, the client will no longer receive updates for that printer.
/// </summary>
/// <param name="printerId">
/// The unique identifier of the printer to unsubscribe from.
/// </param>
public async Task LeavePrinterGroup(Guid printerId)
{
var groupName = PrinterGroupName(printerId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
}
/// <summary>
/// Overrides <see cref="Hub.OnDisconnectedAsync"/> to perform cleanup.
/// SignalR automatically removes disconnected connections from all groups,
/// so no manual cleanup is required here.
/// </summary>
/// <param name="exception">Exception that caused the disconnection, if any.</param>
public override Task OnDisconnectedAsync(Exception? exception)
{
// SignalR automatically removes the connection from all groups on disconnect.
// No manual cleanup needed.
return base.OnDisconnectedAsync(exception);
}
/// <summary>
/// Returns the SignalR group name for a given printer ID.
/// Format: <c>printer:{printerId}</c> (lowercase to avoid case-sensitivity issues).
/// </summary>
/// <param name="printerId">The unique identifier of the printer.</param>
/// <returns>A consistent, lowercase group name string.</returns>
internal static string PrinterGroupName(Guid printerId) =>
$"printer:{printerId.ToString().ToLowerInvariant()}";
}
/// <summary>
/// Extension methods for pushing real-time printer updates through
/// the <see cref="IHubContext{T}"/> of <see cref="PrinterHub"/>.
///
/// These methods are intended to be called from background services
/// (e.g., MQTT message handlers, Moonraker pollers) or other
/// server-side code that detects a printer state change.
/// </summary>
public static class PrinterHubExtensions
{
/// <summary>
/// Pushes a printer status change to all clients subscribed to
/// the given printer's SignalR group.
///
/// Call this from any background service or controller when a printer's
/// operational status changes (e.g., a Bambu Lab MQTT message reports
/// the printer started printing, or a Moonraker poller detects an error).
/// </summary>
/// <param name="hubContext">The hub context injected via DI.</param>
/// <param name="printerId">The unique identifier of the printer that changed.</param>
/// <param name="status">The new status string (e.g., "Idle", "Printing", "Offline").</param>
/// <param name="lastSeenAt">Timestamp (UTC) of when the status was observed, or null if unknown.</param>
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
public static async Task PushPrinterStatusAsync(
this IHubContext<PrinterHub, IPrinterClient> hubContext,
Guid printerId,
string status,
DateTime? lastSeenAt = null)
{
var groupName = PrinterHub.PrinterGroupName(printerId);
await hubContext.Clients.Group(groupName)
.PrinterStatusChanged(printerId, status, lastSeenAt);
}
/// <summary>
/// Pushes a heartbeat signal to all clients subscribed to the given
/// printer's SignalR group. Use this for lightweight "still alive"
/// notifications that don't require a full status payload.
/// </summary>
/// <param name="hubContext">The hub context injected via DI.</param>
/// <param name="printerId">The unique identifier of the printer.</param>
/// <param name="isActive">Whether the printer is currently active and accepting jobs.</param>
/// <param name="lastSeenAt">Timestamp (UTC) of the last telemetry from the printer, or null.</param>
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
public static async Task PushPrinterHeartbeatAsync(
this IHubContext<PrinterHub, IPrinterClient> hubContext,
Guid printerId,
bool isActive,
DateTime? lastSeenAt = null)
{
var groupName = PrinterHub.PrinterGroupName(printerId);
await hubContext.Clients.Group(groupName)
.PrinterHeartbeat(printerId, isActive, lastSeenAt);
}
}

View File

@@ -0,0 +1,100 @@
using Extrudex.API.DTOs.Materials;
using FluentValidation;
namespace Extrudex.API.Validators;
/// <summary>
/// Validation rules for creating a MaterialBase.
/// </summary>
public class CreateMaterialBaseRequestValidator : AbstractValidator<CreateMaterialBaseRequest>
{
public CreateMaterialBaseRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Material name is required.")
.MaximumLength(50).WithMessage("Material name must not exceed 50 characters.");
RuleFor(x => x.DensityGperCm3)
.GreaterThan(0).WithMessage("Density must be greater than zero.");
}
}
/// <summary>
/// Validation rules for updating a MaterialBase.
/// </summary>
public class UpdateMaterialBaseRequestValidator : AbstractValidator<UpdateMaterialBaseRequest>
{
public UpdateMaterialBaseRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Material name is required.")
.MaximumLength(50).WithMessage("Material name must not exceed 50 characters.");
RuleFor(x => x.DensityGperCm3)
.GreaterThan(0).WithMessage("Density must be greater than zero.");
}
}
/// <summary>
/// Validation rules for creating a MaterialFinish.
/// </summary>
public class CreateMaterialFinishRequestValidator : AbstractValidator<CreateMaterialFinishRequest>
{
public CreateMaterialFinishRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Finish name is required.")
.MaximumLength(50).WithMessage("Finish name must not exceed 50 characters.");
RuleFor(x => x.MaterialBaseId)
.NotEmpty().WithMessage("MaterialBaseId is required.");
}
}
/// <summary>
/// Validation rules for updating a MaterialFinish.
/// </summary>
public class UpdateMaterialFinishRequestValidator : AbstractValidator<UpdateMaterialFinishRequest>
{
public UpdateMaterialFinishRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Finish name is required.")
.MaximumLength(50).WithMessage("Finish name must not exceed 50 characters.");
RuleFor(x => x.MaterialBaseId)
.NotEmpty().WithMessage("MaterialBaseId is required.");
}
}
/// <summary>
/// Validation rules for creating a MaterialModifier.
/// </summary>
public class CreateMaterialModifierRequestValidator : AbstractValidator<CreateMaterialModifierRequest>
{
public CreateMaterialModifierRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Modifier name is required.")
.MaximumLength(50).WithMessage("Modifier name must not exceed 50 characters.");
RuleFor(x => x.MaterialBaseId)
.NotEmpty().WithMessage("MaterialBaseId is required.");
}
}
/// <summary>
/// Validation rules for updating a MaterialModifier.
/// </summary>
public class UpdateMaterialModifierRequestValidator : AbstractValidator<UpdateMaterialModifierRequest>
{
public UpdateMaterialModifierRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Modifier name is required.")
.MaximumLength(50).WithMessage("Modifier name must not exceed 50 characters.");
RuleFor(x => x.MaterialBaseId)
.NotEmpty().WithMessage("MaterialBaseId is required.");
}
}

View File

@@ -0,0 +1,106 @@
using Extrudex.API.DTOs.PrintJobs;
using FluentValidation;
namespace Extrudex.API.Validators;
/// <summary>
/// Validation rules for creating a PrintJob.
/// </summary>
public class CreatePrintJobRequestValidator : AbstractValidator<CreatePrintJobRequest>
{
private static readonly string[] ValidDataSources = { "Mqtt", "Moonraker", "Manual" };
public CreatePrintJobRequestValidator()
{
RuleFor(x => x.PrinterId)
.NotEmpty().WithMessage("PrinterId is required.");
RuleFor(x => x.SpoolId)
.NotEmpty().WithMessage("SpoolId is required.");
RuleFor(x => x.PrintName)
.NotEmpty().WithMessage("PrintName is required.")
.MaximumLength(200).WithMessage("PrintName must not exceed 200 characters.");
RuleFor(x => x.MmExtruded)
.GreaterThanOrEqualTo(0).WithMessage("MmExtruded must be non-negative.");
RuleFor(x => x.GramsDerived)
.GreaterThanOrEqualTo(0).WithMessage("GramsDerived must be non-negative.");
RuleFor(x => x.DataSource)
.Must(x => ValidDataSources.Contains(x, StringComparer.OrdinalIgnoreCase))
.WithMessage("DataSource must be 'Mqtt', 'Moonraker', or 'Manual'.");
RuleFor(x => x.FilamentDiameterAtPrintMm)
.GreaterThan(0).WithMessage("Filament diameter must be greater than zero.");
RuleFor(x => x.MaterialDensityAtPrint)
.GreaterThan(0).WithMessage("Material density must be greater than zero.");
When(x => x.GcodeFilePath != null, () =>
{
RuleFor(x => x.GcodeFilePath!)
.MaximumLength(500).WithMessage("G-code file path must not exceed 500 characters.");
});
When(x => x.Notes != null, () =>
{
RuleFor(x => x.Notes!)
.MaximumLength(2000).WithMessage("Notes must not exceed 2000 characters.");
});
}
}
/// <summary>
/// Validation rules for updating a PrintJob.
/// </summary>
public class UpdatePrintJobRequestValidator : AbstractValidator<UpdatePrintJobRequest>
{
public UpdatePrintJobRequestValidator()
{
RuleFor(x => x.PrinterId)
.NotEmpty().WithMessage("PrinterId is required.");
RuleFor(x => x.SpoolId)
.NotEmpty().WithMessage("SpoolId is required.");
RuleFor(x => x.PrintName)
.NotEmpty().WithMessage("PrintName is required.")
.MaximumLength(200).WithMessage("PrintName must not exceed 200 characters.");
RuleFor(x => x.MmExtruded)
.GreaterThanOrEqualTo(0).WithMessage("MmExtruded must be non-negative.");
RuleFor(x => x.GramsDerived)
.GreaterThanOrEqualTo(0).WithMessage("GramsDerived must be non-negative.");
When(x => x.GcodeFilePath != null, () =>
{
RuleFor(x => x.GcodeFilePath!)
.MaximumLength(500).WithMessage("G-code file path must not exceed 500 characters.");
});
When(x => x.Notes != null, () =>
{
RuleFor(x => x.Notes!)
.MaximumLength(2000).WithMessage("Notes must not exceed 2000 characters.");
});
}
}
/// <summary>
/// Validation rules for updating a PrintJob status.
/// </summary>
public class UpdatePrintJobStatusRequestValidator : AbstractValidator<UpdatePrintJobStatusRequest>
{
private static readonly string[] ValidStatuses = { "Queued", "Printing", "Completed", "Cancelled", "Failed" };
public UpdatePrintJobStatusRequestValidator()
{
RuleFor(x => x.Status)
.NotEmpty().WithMessage("Status is required.")
.Must(x => ValidStatuses.Contains(x, StringComparer.OrdinalIgnoreCase))
.WithMessage("Status must be one of: Queued, Printing, Completed, Cancelled, Failed.");
}
}

View File

@@ -0,0 +1,82 @@
using Extrudex.API.DTOs.Printers;
using FluentValidation;
namespace Extrudex.API.Validators;
/// <summary>
/// Validation rules for creating a Printer.
/// </summary>
public class CreatePrinterRequestValidator : AbstractValidator<CreatePrinterRequest>
{
private static readonly string[] ValidPrinterTypes = { "Fdm", "Resin" };
private static readonly string[] ValidConnectionTypes = { "Mqtt", "Moonraker" };
public CreatePrinterRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Printer name is required.")
.MaximumLength(100).WithMessage("Printer name must not exceed 100 characters.");
RuleFor(x => x.Manufacturer)
.NotEmpty().WithMessage("Manufacturer is required.")
.MaximumLength(100).WithMessage("Manufacturer must not exceed 100 characters.");
RuleFor(x => x.Model)
.NotEmpty().WithMessage("Model is required.")
.MaximumLength(100).WithMessage("Model must not exceed 100 characters.");
RuleFor(x => x.PrinterType)
.Must(x => ValidPrinterTypes.Contains(x, StringComparer.OrdinalIgnoreCase))
.WithMessage("PrinterType must be 'Fdm' or 'Resin'.");
RuleFor(x => x.ConnectionType)
.Must(x => ValidConnectionTypes.Contains(x, StringComparer.OrdinalIgnoreCase))
.WithMessage("ConnectionType must be 'Mqtt' or 'Moonraker'.");
RuleFor(x => x.HostnameOrIp)
.NotEmpty().WithMessage("HostnameOrIp is required.")
.MaximumLength(255).WithMessage("HostnameOrIp must not exceed 255 characters.");
RuleFor(x => x.Port)
.InclusiveBetween(1, 65535).WithMessage("Port must be between 1 and 65535.");
}
}
/// <summary>
/// Validation rules for updating a Printer.
/// </summary>
public class UpdatePrinterRequestValidator : AbstractValidator<UpdatePrinterRequest>
{
private static readonly string[] ValidPrinterTypes = { "Fdm", "Resin" };
private static readonly string[] ValidConnectionTypes = { "Mqtt", "Moonraker" };
public UpdatePrinterRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Printer name is required.")
.MaximumLength(100).WithMessage("Printer name must not exceed 100 characters.");
RuleFor(x => x.Manufacturer)
.NotEmpty().WithMessage("Manufacturer is required.")
.MaximumLength(100).WithMessage("Manufacturer must not exceed 100 characters.");
RuleFor(x => x.Model)
.NotEmpty().WithMessage("Model is required.")
.MaximumLength(100).WithMessage("Model must not exceed 100 characters.");
RuleFor(x => x.PrinterType)
.Must(x => ValidPrinterTypes.Contains(x, StringComparer.OrdinalIgnoreCase))
.WithMessage("PrinterType must be 'Fdm' or 'Resin'.");
RuleFor(x => x.ConnectionType)
.Must(x => ValidConnectionTypes.Contains(x, StringComparer.OrdinalIgnoreCase))
.WithMessage("ConnectionType must be 'Mqtt' or 'Moonraker'.");
RuleFor(x => x.HostnameOrIp)
.NotEmpty().WithMessage("HostnameOrIp is required.")
.MaximumLength(255).WithMessage("HostnameOrIp must not exceed 255 characters.");
RuleFor(x => x.Port)
.InclusiveBetween(1, 65535).WithMessage("Port must be between 1 and 65535.");
}
}

View File

@@ -0,0 +1,96 @@
using Extrudex.API.DTOs.Spools;
using FluentValidation;
namespace Extrudex.API.Validators;
/// <summary>
/// Validation rules for creating a Spool.
/// </summary>
public class CreateSpoolRequestValidator : AbstractValidator<CreateSpoolRequest>
{
public CreateSpoolRequestValidator()
{
RuleFor(x => x.MaterialBaseId)
.NotEmpty().WithMessage("MaterialBaseId is required.");
RuleFor(x => x.MaterialFinishId)
.NotEmpty().WithMessage("MaterialFinishId is required.");
RuleFor(x => x.Brand)
.NotEmpty().WithMessage("Brand is required.")
.MaximumLength(100).WithMessage("Brand must not exceed 100 characters.");
RuleFor(x => x.ColorName)
.NotEmpty().WithMessage("ColorName is required.")
.MaximumLength(100).WithMessage("ColorName must not exceed 100 characters.");
RuleFor(x => x.ColorHex)
.NotEmpty().WithMessage("ColorHex is required.")
.Matches(@"^#[0-9A-Fa-f]{6}$").WithMessage("ColorHex must be a valid hex color code (e.g., #FF0000).");
RuleFor(x => x.WeightTotalGrams)
.GreaterThan(0).WithMessage("Total weight must be greater than zero.");
RuleFor(x => x.WeightRemainingGrams)
.GreaterThanOrEqualTo(0).WithMessage("Remaining weight must be non-negative.");
RuleFor(x => x.FilamentDiameterMm)
.GreaterThan(0).WithMessage("Filament diameter must be greater than zero.");
RuleFor(x => x.SpoolSerial)
.NotEmpty().WithMessage("SpoolSerial is required.")
.MaximumLength(100).WithMessage("SpoolSerial must not exceed 100 characters.");
When(x => x.PurchasePrice.HasValue, () =>
{
RuleFor(x => x.PurchasePrice!.Value)
.GreaterThanOrEqualTo(0).WithMessage("Purchase price must be non-negative.");
});
}
}
/// <summary>
/// Validation rules for updating a Spool.
/// </summary>
public class UpdateSpoolRequestValidator : AbstractValidator<UpdateSpoolRequest>
{
public UpdateSpoolRequestValidator()
{
RuleFor(x => x.MaterialBaseId)
.NotEmpty().WithMessage("MaterialBaseId is required.");
RuleFor(x => x.MaterialFinishId)
.NotEmpty().WithMessage("MaterialFinishId is required.");
RuleFor(x => x.Brand)
.NotEmpty().WithMessage("Brand is required.")
.MaximumLength(100).WithMessage("Brand must not exceed 100 characters.");
RuleFor(x => x.ColorName)
.NotEmpty().WithMessage("ColorName is required.")
.MaximumLength(100).WithMessage("ColorName must not exceed 100 characters.");
RuleFor(x => x.ColorHex)
.NotEmpty().WithMessage("ColorHex is required.")
.Matches(@"^#[0-9A-Fa-f]{6}$").WithMessage("ColorHex must be a valid hex color code (e.g., #FF0000).");
RuleFor(x => x.WeightTotalGrams)
.GreaterThan(0).WithMessage("Total weight must be greater than zero.");
RuleFor(x => x.WeightRemainingGrams)
.GreaterThanOrEqualTo(0).WithMessage("Remaining weight must be non-negative.");
RuleFor(x => x.FilamentDiameterMm)
.GreaterThan(0).WithMessage("Filament diameter must be greater than zero.");
RuleFor(x => x.SpoolSerial)
.NotEmpty().WithMessage("SpoolSerial is required.")
.MaximumLength(100).WithMessage("SpoolSerial must not exceed 100 characters.");
When(x => x.PurchasePrice.HasValue, () =>
{
RuleFor(x => x.PurchasePrice!.Value)
.GreaterThanOrEqualTo(0).WithMessage("Purchase price must be non-negative.");
});
}
}