initial commit
This commit is contained in:
314
backend/API/Controllers/FilamentsController.cs
Normal file
314
backend/API/Controllers/FilamentsController.cs
Normal 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}"
|
||||
};
|
||||
}
|
||||
158
backend/API/Controllers/MaterialBasesController.cs
Normal file
158
backend/API/Controllers/MaterialBasesController.cs
Normal 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
|
||||
};
|
||||
}
|
||||
191
backend/API/Controllers/MaterialFinishesController.cs
Normal file
191
backend/API/Controllers/MaterialFinishesController.cs
Normal 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
|
||||
};
|
||||
}
|
||||
533
backend/API/Controllers/MaterialLookupsController.cs
Normal file
533
backend/API/Controllers/MaterialLookupsController.cs
Normal 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
|
||||
};
|
||||
}
|
||||
192
backend/API/Controllers/MaterialModifiersController.cs
Normal file
192
backend/API/Controllers/MaterialModifiersController.cs
Normal 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
|
||||
};
|
||||
}
|
||||
512
backend/API/Controllers/PrintJobsController.cs
Normal file
512
backend/API/Controllers/PrintJobsController.cs
Normal 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
|
||||
};
|
||||
}
|
||||
297
backend/API/Controllers/PrintersController.cs
Normal file
297
backend/API/Controllers/PrintersController.cs
Normal 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
|
||||
};
|
||||
}
|
||||
101
backend/API/Controllers/QrController.cs
Normal file
101
backend/API/Controllers/QrController.cs
Normal 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: 4–40.
|
||||
/// </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");
|
||||
}
|
||||
}
|
||||
329
backend/API/Controllers/SpoolsController.cs
Normal file
329
backend/API/Controllers/SpoolsController.cs
Normal 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
|
||||
};
|
||||
}
|
||||
199
backend/API/DTOs/Filaments/FilamentDtos.cs
Normal file
199
backend/API/DTOs/Filaments/FilamentDtos.cs
Normal 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;
|
||||
}
|
||||
33
backend/API/DTOs/Filaments/FilamentQueryDtos.cs
Normal file
33
backend/API/DTOs/Filaments/FilamentQueryDtos.cs
Normal 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; }
|
||||
}
|
||||
56
backend/API/DTOs/Materials/MaterialBaseDtos.cs
Normal file
56
backend/API/DTOs/Materials/MaterialBaseDtos.cs
Normal 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; }
|
||||
}
|
||||
57
backend/API/DTOs/Materials/MaterialFinishDtos.cs
Normal file
57
backend/API/DTOs/Materials/MaterialFinishDtos.cs
Normal 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; }
|
||||
}
|
||||
57
backend/API/DTOs/Materials/MaterialModifierDtos.cs
Normal file
57
backend/API/DTOs/Materials/MaterialModifierDtos.cs
Normal 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; }
|
||||
}
|
||||
30
backend/API/DTOs/PagedResponse.cs
Normal file
30
backend/API/DTOs/PagedResponse.cs
Normal 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;
|
||||
}
|
||||
223
backend/API/DTOs/PrintJobs/PrintJobDtos.cs
Normal file
223
backend/API/DTOs/PrintJobs/PrintJobDtos.cs
Normal 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;
|
||||
}
|
||||
30
backend/API/DTOs/PrintJobs/PrintJobQueryDtos.cs
Normal file
30
backend/API/DTOs/PrintJobs/PrintJobQueryDtos.cs
Normal 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; }
|
||||
}
|
||||
190
backend/API/DTOs/Printers/PrinterDtos.cs
Normal file
190
backend/API/DTOs/Printers/PrinterDtos.cs
Normal 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;
|
||||
}
|
||||
193
backend/API/DTOs/Spools/SpoolDtos.cs
Normal file
193
backend/API/DTOs/Spools/SpoolDtos.cs
Normal 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;
|
||||
}
|
||||
27
backend/API/DTOs/Spools/SpoolQueryDtos.cs
Normal file
27
backend/API/DTOs/Spools/SpoolQueryDtos.cs
Normal 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; }
|
||||
}
|
||||
31
backend/API/Hubs/IPrinterClient.cs
Normal file
31
backend/API/Hubs/IPrinterClient.cs
Normal 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);
|
||||
}
|
||||
137
backend/API/Hubs/PrinterHub.cs
Normal file
137
backend/API/Hubs/PrinterHub.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
100
backend/API/Validators/MaterialValidators.cs
Normal file
100
backend/API/Validators/MaterialValidators.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
106
backend/API/Validators/PrintJobValidators.cs
Normal file
106
backend/API/Validators/PrintJobValidators.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
82
backend/API/Validators/PrinterValidators.cs
Normal file
82
backend/API/Validators/PrinterValidators.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
96
backend/API/Validators/SpoolValidators.cs
Normal file
96
backend/API/Validators/SpoolValidators.cs
Normal 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.");
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user