initial commit

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

View File

@@ -0,0 +1,314 @@
using Extrudex.API.DTOs;
using Extrudex.API.DTOs.Filaments;
using Extrudex.Domain.Entities;
using Extrudex.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Extrudex.API.Controllers;
/// <summary>
/// Controller for managing filament spools — the core inventory unit of Extrudex.
/// Exposes spool data under the user-facing "filaments" route while mapping
/// to the Spool domain entity internally.
/// </summary>
[ApiController]
[Route("api/filaments")]
public class FilamentsController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<FilamentsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FilamentsController"/> class.
/// </summary>
/// <param name="dbContext">The database context for data access.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public FilamentsController(ExtrudexDbContext dbContext, ILogger<FilamentsController> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
/// Gets a paginated list of filament spools with optional filtering by material,
/// brand, and active status. Results are ordered by creation date (newest first)
/// and include denormalized material names for display.
/// </summary>
/// <param name="query">Optional query parameters for pagination and filtering.</param>
/// <returns>A paginated list of filament spools matching the filter criteria.</returns>
/// <response code="200">Returns the paginated list of filament spools.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<FilamentResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<FilamentResponse>>> GetFilaments(
[FromQuery] FilamentQueryParameters query)
{
_logger.LogDebug(
"Getting filaments: pageNumber={PageNumber}, pageSize={PageSize}, " +
"materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, " +
"materialModifierId={MaterialModifierId}, brand={Brand}, isActive={IsActive}",
query.PageNumber, query.PageSize, query.MaterialBaseId,
query.MaterialFinishId, query.MaterialModifierId, query.Brand, query.IsActive);
// Clamp pagination values
var pageNumber = Math.Max(1, query.PageNumber);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var spoolQuery = _dbContext.Spools
.Include(s => s.MaterialBase)
.Include(s => s.MaterialFinish)
.Include(s => s.MaterialModifier)
.AsQueryable();
// Apply filters
if (query.MaterialBaseId.HasValue)
spoolQuery = spoolQuery.Where(s => s.MaterialBaseId == query.MaterialBaseId.Value);
if (query.MaterialFinishId.HasValue)
spoolQuery = spoolQuery.Where(s => s.MaterialFinishId == query.MaterialFinishId.Value);
if (query.MaterialModifierId.HasValue)
spoolQuery = spoolQuery.Where(s => s.MaterialModifierId == query.MaterialModifierId.Value);
if (!string.IsNullOrWhiteSpace(query.Brand))
spoolQuery = spoolQuery.Where(s =>
s.Brand.ToLower().Contains(query.Brand.ToLower()));
if (query.IsActive.HasValue)
spoolQuery = spoolQuery.Where(s => s.IsActive == query.IsActive.Value);
var totalCount = await spoolQuery.CountAsync();
var items = await spoolQuery
.OrderByDescending(s => s.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(s => MapToFilamentResponse(s))
.ToListAsync();
var response = new PagedResponse<FilamentResponse>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
return Ok(response);
}
/// <summary>
/// Gets a specific filament spool by its unique identifier.
/// Includes denormalized material names for display.
/// </summary>
/// <param name="id">The unique identifier of the filament spool.</param>
/// <returns>The filament spool details.</returns>
/// <response code="200">Returns the filament spool details.</response>
/// <response code="404">If the filament spool with the given ID is not found.</response>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<FilamentResponse>> GetFilament(Guid id)
{
_logger.LogDebug("Getting filament {Id}", id);
var spool = await _dbContext.Spools
.Include(s => s.MaterialBase)
.Include(s => s.MaterialFinish)
.Include(s => s.MaterialModifier)
.FirstOrDefaultAsync(s => s.Id == id);
if (spool is null)
{
_logger.LogWarning("Filament {Id} not found", id);
return NotFound(new { error = $"Filament with ID '{id}' not found." });
}
return Ok(MapToFilamentResponse(spool));
}
/// <summary>
/// Creates a new filament spool. Validates that all foreign keys reference
/// existing material entities and that the SpoolSerial is unique.
/// WeightRemainingGrams must not exceed WeightTotalGrams.
/// </summary>
/// <param name="request">The filament creation request with all required fields.</param>
/// <returns>The newly created filament spool with generated ID and timestamps.</returns>
/// <response code="201">Returns the created filament spool with location header.</response>
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
[HttpPost]
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<FilamentResponse>> CreateFilament(
[FromBody] CreateFilamentRequest request)
{
_logger.LogInformation("Creating filament: {Serial} - {Brand} {Color}",
request.SpoolSerial, request.Brand, request.ColorName);
// Validate foreign keys exist
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
if (materialBase is null)
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId);
if (materialFinish is null)
return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." });
if (request.MaterialModifierId.HasValue)
{
var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value);
if (modifier is null)
return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." });
}
// Validate serial uniqueness
var serialExists = await _dbContext.Spools
.AnyAsync(s => s.SpoolSerial == request.SpoolSerial);
if (serialExists)
return BadRequest(new { error = $"Filament with serial '{request.SpoolSerial}' already exists." });
// Validate remaining weight doesn't exceed total weight
if (request.WeightRemainingGrams > request.WeightTotalGrams)
return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." });
var entity = new Spool
{
MaterialBaseId = request.MaterialBaseId,
MaterialFinishId = request.MaterialFinishId,
MaterialModifierId = request.MaterialModifierId,
Brand = request.Brand,
ColorName = request.ColorName,
ColorHex = request.ColorHex,
WeightTotalGrams = request.WeightTotalGrams,
WeightRemainingGrams = request.WeightRemainingGrams,
FilamentDiameterMm = request.FilamentDiameterMm,
SpoolSerial = request.SpoolSerial,
PurchasePrice = request.PurchasePrice,
PurchaseDate = request.PurchaseDate,
IsActive = request.IsActive
};
_dbContext.Spools.Add(entity);
await _dbContext.SaveChangesAsync();
// Reload navigation properties for the response
await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync();
await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync();
if (entity.MaterialModifierId.HasValue)
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
var response = MapToFilamentResponse(entity);
return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response);
}
/// <summary>
/// Updates an existing filament spool. Validates foreign keys and serial uniqueness
/// (excluding the current spool from the uniqueness check).
/// WeightRemainingGrams must not exceed WeightTotalGrams.
/// </summary>
/// <param name="id">The unique identifier of the filament spool to update.</param>
/// <param name="request">The filament update request with all required fields.</param>
/// <returns>The updated filament spool with current timestamps.</returns>
/// <response code="200">Returns the updated filament spool.</response>
/// <response code="404">If the filament spool with the given ID is not found.</response>
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<FilamentResponse>> UpdateFilament(
Guid id, [FromBody] UpdateFilamentRequest request)
{
_logger.LogInformation("Updating filament {Id}", id);
var entity = await _dbContext.Spools.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Filament {Id} not found for update", id);
return NotFound(new { error = $"Filament with ID '{id}' not found." });
}
// Validate foreign keys exist
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
if (materialBase is null)
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId);
if (materialFinish is null)
return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." });
if (request.MaterialModifierId.HasValue)
{
var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value);
if (modifier is null)
return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." });
}
// Check serial uniqueness (excluding current spool)
var serialExists = await _dbContext.Spools
.AnyAsync(s => s.SpoolSerial == request.SpoolSerial && s.Id != id);
if (serialExists)
return BadRequest(new { error = $"Filament with serial '{request.SpoolSerial}' already exists." });
// Validate remaining weight doesn't exceed total weight
if (request.WeightRemainingGrams > request.WeightTotalGrams)
return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." });
entity.MaterialBaseId = request.MaterialBaseId;
entity.MaterialFinishId = request.MaterialFinishId;
entity.MaterialModifierId = request.MaterialModifierId;
entity.Brand = request.Brand;
entity.ColorName = request.ColorName;
entity.ColorHex = request.ColorHex;
entity.WeightTotalGrams = request.WeightTotalGrams;
entity.WeightRemainingGrams = request.WeightRemainingGrams;
entity.FilamentDiameterMm = request.FilamentDiameterMm;
entity.SpoolSerial = request.SpoolSerial;
entity.PurchasePrice = request.PurchasePrice;
entity.PurchaseDate = request.PurchaseDate;
entity.IsActive = request.IsActive;
await _dbContext.SaveChangesAsync();
// Reload navigation properties
await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync();
await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync();
if (entity.MaterialModifierId.HasValue)
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
return Ok(MapToFilamentResponse(entity));
}
// ── Mapping helper ─────────────────────────────────────────
/// <summary>
/// Maps a Spool domain entity to a FilamentResponse DTO.
/// Denormalizes material names for display convenience.
/// Populates the QrCodeUrl for easy frontend access to the spool's QR code.
/// </summary>
/// <param name="s">The spool entity to map.</param>
/// <returns>A FilamentResponse DTO with denormalized material names and QR code URL.</returns>
private static FilamentResponse MapToFilamentResponse(Spool s) => new()
{
Id = s.Id,
MaterialBaseId = s.MaterialBaseId,
MaterialBaseName = s.MaterialBase?.Name ?? string.Empty,
MaterialFinishId = s.MaterialFinishId,
MaterialFinishName = s.MaterialFinish?.Name ?? string.Empty,
MaterialModifierId = s.MaterialModifierId,
MaterialModifierName = s.MaterialModifier?.Name,
Brand = s.Brand,
ColorName = s.ColorName,
ColorHex = s.ColorHex,
WeightTotalGrams = s.WeightTotalGrams,
WeightRemainingGrams = s.WeightRemainingGrams,
FilamentDiameterMm = s.FilamentDiameterMm,
SpoolSerial = s.SpoolSerial,
PurchasePrice = s.PurchasePrice,
PurchaseDate = s.PurchaseDate,
IsActive = s.IsActive,
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt,
QrCodeUrl = $"/api/qr/spool/{s.Id}"
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
namespace Extrudex.Domain.Base;
/// <summary>
/// Base entity providing automatic audit timestamp tracking and a primary key.
/// All domain entities that require created/updated timestamps should inherit from this class.
/// Inherits Id from BaseEntity, so subclasses have both identity and audit columns.
/// </summary>
public abstract class AuditableEntity : BaseEntity
{
/// <summary>
/// Timestamp indicating when this entity was first created (UTC).
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Timestamp indicating when this entity was last modified (UTC).
/// </summary>
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,12 @@
namespace Extrudex.Domain.Base;
/// <summary>
/// Base entity providing a primary key identifier.
/// </summary>
public abstract class BaseEntity
{
/// <summary>
/// Unique identifier for the entity.
/// </summary>
public Guid Id { get; set; } = Guid.NewGuid();
}

View File

@@ -0,0 +1,41 @@
using Extrudex.Domain.Base;
namespace Extrudex.Domain.Entities;
/// <summary>
/// Represents a single slot within an AMS unit. Each slot can hold one spool
/// and tracks the tray index, remaining weight, and which spool is loaded.
/// </summary>
public class AmsSlot : AuditableEntity
{
/// <summary>
/// The 1-based tray/slot index within the AMS unit (1-4 per unit).
/// </summary>
public int TrayIndex { get; set; }
/// <summary>
/// Foreign key to the AMS unit this slot belongs to.
/// </summary>
public Guid AmsUnitId { get; set; }
/// <summary>
/// Navigation to the parent AMS unit.
/// </summary>
public AmsUnit AmsUnit { get; set; } = null!;
/// <summary>
/// Foreign key to the spool currently loaded in this slot. Null if empty.
/// </summary>
public Guid? SpoolId { get; set; }
/// <summary>
/// Navigation to the spool currently loaded in this slot. Null if no spool is loaded.
/// </summary>
public Spool? Spool { get; set; }
/// <summary>
/// Remaining filament weight in grams as reported by the AMS.
/// Bambu Lab AMS reports remaining weight per tray.
/// </summary>
public decimal? RemainingWeightG { get; set; }
}

View File

@@ -0,0 +1,30 @@
using Extrudex.Domain.Base;
namespace Extrudex.Domain.Entities;
/// <summary>
/// Represents an AMS (Automatic Material System) unit installed on a Bambu Lab printer.
/// Each AMS unit contains multiple slots that hold spools.
/// </summary>
public class AmsUnit : AuditableEntity
{
/// <summary>
/// The 1-based index of this AMS unit on the printer (e.g., AMS 1, AMS 2).
/// </summary>
public int UnitIndex { get; set; }
/// <summary>
/// Foreign key to the parent Printer this AMS unit is installed on.
/// </summary>
public Guid PrinterId { get; set; }
/// <summary>
/// Navigation to the parent Printer.
/// </summary>
public Printer Printer { get; set; } = null!;
/// <summary>
/// Navigation collection of slots in this AMS unit.
/// </summary>
public ICollection<AmsSlot> Slots { get; set; } = new List<AmsSlot>();
}

View File

@@ -0,0 +1,36 @@
using Extrudex.Domain.Base;
namespace Extrudex.Domain.Entities;
/// <summary>
/// Base polymer/material type. This is a lookup table enforcing consistent
/// material naming across all spools. Free-text material names are not allowed.
/// </summary>
public class MaterialBase : AuditableEntity
{
/// <summary>
/// Human-readable name of the base material (e.g., "PLA", "PETG", "ABS").
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Density of the material in g/cm³ (g/mL). Used for deriving grams consumed
/// from mm extruded: grams = mm × cross_section_area × density.
/// </summary>
public decimal DensityGperCm3 { get; set; }
/// <summary>
/// Navigation collection of finishes available for this material base.
/// </summary>
public ICollection<MaterialFinish> Finishes { get; set; } = new List<MaterialFinish>();
/// <summary>
/// Navigation collection of modifiers applicable to this material base.
/// </summary>
public ICollection<MaterialModifier> Modifiers { get; set; } = new List<MaterialModifier>();
/// <summary>
/// Navigation collection of spools made from this material base.
/// </summary>
public ICollection<Spool> Spools { get; set; } = new List<Spool>();
}

View File

@@ -0,0 +1,30 @@
using Extrudex.Domain.Base;
namespace Extrudex.Domain.Entities;
/// <summary>
/// Surface finish descriptor for a material. This is REQUIRED on every spool
/// record. The default value is "Basic" (not "Standard").
/// </summary>
public class MaterialFinish : AuditableEntity
{
/// <summary>
/// Human-readable name of the finish (e.g., "Basic", "Matte", "Silk", "Glitter").
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Foreign key to the parent MaterialBase. A finish belongs to exactly one base material.
/// </summary>
public Guid MaterialBaseId { get; set; }
/// <summary>
/// Navigation to the parent MaterialBase.
/// </summary>
public MaterialBase MaterialBase { get; set; } = null!;
/// <summary>
/// Navigation collection of spools with this finish.
/// </summary>
public ICollection<Spool> Spools { get; set; } = new List<Spool>();
}

View File

@@ -0,0 +1,30 @@
using Extrudex.Domain.Base;
namespace Extrudex.Domain.Entities;
/// <summary>
/// Optional modifier/additive for a material (e.g., "Carbon Fiber", "Glass Fiber",
/// "Wood Fill", "Glow-in-the-Dark"). Not every spool has a modifier.
/// </summary>
public class MaterialModifier : AuditableEntity
{
/// <summary>
/// Human-readable name of the modifier (e.g., "Carbon Fiber", "Wood Fill").
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Foreign key to the parent MaterialBase. A modifier belongs to exactly one base material.
/// </summary>
public Guid MaterialBaseId { get; set; }
/// <summary>
/// Navigation to the parent MaterialBase.
/// </summary>
public MaterialBase MaterialBase { get; set; } = null!;
/// <summary>
/// Navigation collection of spools with this modifier.
/// </summary>
public ICollection<Spool> Spools { get; set; } = new List<Spool>();
}

View File

@@ -0,0 +1,100 @@
using Extrudex.Domain.Base;
using Extrudex.Domain.Enums;
namespace Extrudex.Domain.Entities;
/// <summary>
/// Represents a single print job. Tracks which printer and spool were used,
/// how much filament was consumed, and audit snapshots of material properties
/// at the time of printing (to preserve COGS accuracy even if material data
/// changes later).
/// </summary>
public class PrintJob : AuditableEntity
{
/// <summary>
/// Foreign key to the printer that executed this print job.
/// </summary>
public Guid PrinterId { get; set; }
/// <summary>
/// Navigation to the printer that executed this print job.
/// </summary>
public Printer Printer { get; set; } = null!;
/// <summary>
/// Foreign key to the spool that provided filament for this print job.
/// </summary>
public Guid SpoolId { get; set; }
/// <summary>
/// Navigation to the spool that provided filament for this print job.
/// </summary>
public Spool Spool { get; set; } = null!;
/// <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 being printed.
/// </summary>
public string? GcodeFilePath { get; set; }
/// <summary>
/// Total millimeters of filament extruded during this print job.
/// The primary input to the COGS derivation formula.
/// </summary>
public decimal MmExtruded { get; set; }
/// <summary>
/// Derived grams consumed for this print, calculated as:
/// mm_extruded × cross_section_area × material_density_at_print.
/// </summary>
public decimal GramsDerived { get; set; }
/// <summary>
/// Calculated cost of goods sold (COGS) for this print job.
/// Derived from grams consumed and the spool's purchase price.
/// </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.
/// </summary>
public JobStatus Status { get; set; } = JobStatus.Queued;
/// <summary>
/// The source of the print job data (which integration path provided it).
/// </summary>
public DataSource DataSource { get; set; }
/// <summary>
/// Audit snapshot: the filament diameter (mm) recorded at the time of printing.
/// Preserved so COGS calculations remain accurate even if the spool's
/// diameter is later corrected.
/// </summary>
public decimal FilamentDiameterAtPrintMm { get; set; }
/// <summary>
/// Audit snapshot: the material density (g/cm³) recorded at the time of printing.
/// Preserved so COGS calculations remain accurate even if the material's
/// density is later corrected.
/// </summary>
public decimal MaterialDensityAtPrint { get; set; }
/// <summary>
/// Optional notes about the print job (e.g., "First layer adhesion issues").
/// </summary>
public string? Notes { get; set; }
}

View File

@@ -0,0 +1,97 @@
using Extrudex.Domain.Base;
using Extrudex.Domain.Enums;
namespace Extrudex.Domain.Entities;
/// <summary>
/// Represents a 3D printer in the fleet. Stores connection details for
/// MQTT (Bambu Lab) or Moonraker (Elegoo Centauri Carbon) integration.
/// </summary>
public class Printer : AuditableEntity
{
/// <summary>
/// Current operational status of the printer, updated via real-time telemetry.
/// </summary>
public PrinterStatus Status { get; set; } = PrinterStatus.Offline;
/// <summary>
/// Human-readable name for the printer (e.g., "Bambu X1C #1", "Elegoo Centauri").
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Manufacturer/brand of the printer (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>
/// The hardware type of the printer (FDM or Resin).
/// </summary>
public PrinterType PrinterType { get; set; } = PrinterType.Fdm;
/// <summary>
/// The connectivity protocol used by this printer (MQTT or Moonraker).
/// </summary>
public ConnectionType ConnectionType { get; set; } = ConnectionType.Mqtt;
/// <summary>
/// Hostname or IP address for connecting to the printer.
/// </summary>
public string HostnameOrIp { get; set; } = string.Empty;
/// <summary>
/// Port number for the printer connection. Defaults: 8883 (MQTT/TLS), 7125 (Moonraker).
/// </summary>
public int Port { get; set; }
/// <summary>
/// MQTT username for Bambu Lab printer authentication.
/// Stored only for MQTT connection type printers.
/// </summary>
public string MqttUsername { get; set; } = string.Empty;
/// <summary>
/// MQTT password for Bambu Lab printer authentication.
/// Stored only for MQTT connection type printers.
/// </summary>
public string MqttPassword { get; set; } = string.Empty;
/// <summary>
/// Whether to use TLS for the MQTT connection. Bambu Lab printers
/// require TLS on port 8883.
/// </summary>
public bool MqttUseTls { get; set; }
/// <summary>
/// Moonraker API key for Elegoo Centauri Carbon authentication.
/// Stored only for Moonraker connection type printers.
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Whether this printer is currently active and available for print jobs.
/// Inactive printers are retained for historical records.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Timestamp of the last status update received from the printer (UTC).
/// Used to detect stale connections.
/// </summary>
public DateTime? LastSeenAt { get; set; }
/// <summary>
/// Navigation collection of AMS units installed on this printer.
/// </summary>
public ICollection<AmsUnit> AmsUnits { get; set; } = new List<AmsUnit>();
/// <summary>
/// Navigation collection of print jobs executed on this printer.
/// </summary>
public ICollection<PrintJob> PrintJobs { get; set; } = new List<PrintJob>();
}

View File

@@ -0,0 +1,105 @@
using Extrudex.Domain.Base;
namespace Extrudex.Domain.Entities;
/// <summary>
/// Represents a physical spool of filament. Every spool must have a MaterialBase
/// and MaterialFinish. MaterialModifier is optional.
/// Spools are the core inventory unit and link to PrintJobs for COGS tracking.
/// </summary>
public class Spool : AuditableEntity
{
/// <summary>
/// Foreign key to the base material. Every spool must specify a material base.
/// </summary>
public Guid MaterialBaseId { get; set; }
/// <summary>
/// Navigation to the base material (e.g., PLA, PETG, ABS).
/// </summary>
public MaterialBase MaterialBase { get; set; } = null!;
/// <summary>
/// Foreign key to the material finish. REQUIRED on every spool — default is "Basic".
/// </summary>
public Guid MaterialFinishId { get; set; }
/// <summary>
/// Navigation to the material finish (e.g., Basic, Matte, Silk, Glitter).
/// </summary>
public MaterialFinish MaterialFinish { get; set; } = null!;
/// <summary>
/// Foreign key to the optional material modifier. Null if no modifier applies.
/// </summary>
public Guid? MaterialModifierId { get; set; }
/// <summary>
/// Navigation to the optional material modifier (e.g., Carbon Fiber, Wood Fill).
/// </summary>
public MaterialModifier? MaterialModifier { get; set; }
/// <summary>
/// Human-readable brand name (e.g., "Bambu Lab", "Polymaker", "eSUN").
/// </summary>
public string Brand { get; set; } = string.Empty;
/// <summary>
/// Human-readable color name (e.g., "Fire Engine Red", "Galaxy Black").
/// </summary>
public string ColorName { get; set; } = string.Empty;
/// <summary>
/// Hex color code for the filament (e.g., "#FF0000" for red).
/// Enables color-based filtering and visual identification.
/// </summary>
public string ColorHex { get; set; } = string.Empty;
/// <summary>
/// Total spool weight in grams when full (brand new, unopened).
/// </summary>
public decimal WeightTotalGrams { get; set; }
/// <summary>
/// Current remaining weight in grams. Updated via AMS data or manual check-in.
/// </summary>
public decimal WeightRemainingGrams { get; set; }
/// <summary>
/// Filament diameter in millimeters. Typically 1.75mm for FDM printers.
/// Used in the COGS derivation formula: grams = mm × cross_section_area × density.
/// </summary>
public decimal FilamentDiameterMm { get; set; } = 1.75m;
/// <summary>
/// Manufacturer-assigned serial number for the spool. Used for barcode/QR scanning.
/// Must be unique across all spools.
/// </summary>
public string SpoolSerial { get; set; } = string.Empty;
/// <summary>
/// Purchase price per spool in the system currency. Used for COGS calculations.
/// </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 for use.
/// Inactive spools are retained for historical COGS records.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Navigation collection of AMS slots where this spool is loaded.
/// </summary>
public ICollection<AmsSlot> AmsSlots { get; set; } = new List<AmsSlot>();
/// <summary>
/// Navigation collection of print jobs that consumed filament from this spool.
/// </summary>
public ICollection<PrintJob> PrintJobs { get; set; } = new List<PrintJob>();
}

View File

@@ -0,0 +1,13 @@
namespace Extrudex.Domain.Enums;
/// <summary>
/// Describes how the backend communicates with a printer.
/// </summary>
public enum ConnectionType
{
/// <summary>Bambu Lab printers communicating via MQTT over TLS.</summary>
Mqtt = 0,
/// <summary>Klipper-based printers (Elegoo) communicating via Moonraker REST/WebSocket.</summary>
Moonraker = 1
}

View File

@@ -0,0 +1,16 @@
namespace Extrudex.Domain.Enums;
/// <summary>
/// Indicates where the print job data originated from.
/// </summary>
public enum DataSource
{
/// <summary>Data reported by a Bambu Lab printer via MQTT.</summary>
Mqtt = 0,
/// <summary>Data reported by an Elegoo/Klipper printer via Moonraker.</summary>
Moonraker = 1,
/// <summary>Manually entered by a user.</summary>
Manual = 2
}

View File

@@ -0,0 +1,22 @@
namespace Extrudex.Domain.Enums;
/// <summary>
/// Represents the current lifecycle status of a print job.
/// </summary>
public enum JobStatus
{
/// <summary>Job has been created but not yet sent to the printer.</summary>
Queued = 0,
/// <summary>Printer is actively printing this job.</summary>
Printing = 1,
/// <summary>Job completed successfully.</summary>
Completed = 2,
/// <summary>Job was cancelled by the user.</summary>
Cancelled = 3,
/// <summary>Job failed due to an error.</summary>
Failed = 4
}

View File

@@ -0,0 +1,32 @@
namespace Extrudex.Domain.Enums;
/// <summary>
/// Represents the current operational status of a printer.
/// </summary>
public enum PrinterStatus
{
/// <summary>
/// Printer is online and idle, ready to accept jobs.
/// </summary>
Idle = 0,
/// <summary>
/// Printer is currently printing.
/// </summary>
Printing = 1,
/// <summary>
/// Printer is offline or unreachable.
/// </summary>
Offline = 2,
/// <summary>
/// Printer is in an error state.
/// </summary>
Error = 3,
/// <summary>
/// Printer is paused.
/// </summary>
Paused = 4
}

View File

@@ -0,0 +1,13 @@
namespace Extrudex.Domain.Enums;
/// <summary>
/// Identifies the type of 3D printer hardware.
/// </summary>
public enum PrinterType
{
/// <summary>FDM/FFF filament-based printer.</summary>
Fdm = 0,
/// <summary>Resin-based SLA/DLP/LCD printer.</summary>
Resin = 1
}

View File

@@ -0,0 +1,24 @@
namespace Extrudex.Domain.Enums;
/// <summary>
/// Defines the resource types that support QR code generation.
/// Each type maps to a distinct API route for QR code retrieval.
/// </summary>
public enum QrResourceType
{
/// <summary>
/// QR code for a filament spool — links to spool detail/scan view.
/// </summary>
Spool,
/// <summary>
/// QR code for a printer — links to printer detail/monitor view.
/// </summary>
Printer,
/// <summary>
/// QR code for a storage location — links to location inventory view.
/// Reserved for future use when Location entities are introduced.
/// </summary>
Location
}

View File

@@ -0,0 +1,41 @@
using Extrudex.Domain.Enums;
namespace Extrudex.Domain.Interfaces;
/// <summary>
/// Service interface for generating QR codes that encode deep links to
/// Extrudex resources (spools, printers, locations). QR codes are
/// high-contrast and optimized for small label printing.
/// </summary>
public interface IQrCodeService
{
/// <summary>
/// Generates a PNG QR code image for the specified resource type and ID.
/// The encoded URL points to the resource's detail page in the Extrudex frontend.
/// </summary>
/// <param name="resourceType">The type of resource (Spool, Printer, Location).</param>
/// <param name="id">The unique identifier of the resource.</param>
/// <param name="pixelsPerModule">
/// Pixel density per QR module. Higher values produce larger images.
/// Default (20) balances readability and label size.
/// </param>
/// <returns>A byte array containing the PNG image data.</returns>
byte[] GeneratePng(QrResourceType resourceType, Guid id, int pixelsPerModule = 20);
/// <summary>
/// Generates an SVG QR code image for the specified resource type and ID.
/// SVG is resolution-independent and ideal for printing at any scale.
/// </summary>
/// <param name="resourceType">The type of resource (Spool, Printer, Location).</param>
/// <param name="id">The unique identifier of the resource.</param>
/// <returns>A string containing the SVG markup.</returns>
string GenerateSvg(QrResourceType resourceType, Guid id);
/// <summary>
/// Constructs the URL that will be encoded into the QR code for the given resource.
/// </summary>
/// <param name="resourceType">The type of resource.</param>
/// <param name="id">The unique identifier of the resource.</param>
/// <returns>The absolute URL to be encoded in the QR code.</returns>
string GetResourceUrl(QrResourceType resourceType, Guid id);
}

21
backend/Extrudex.csproj Normal file
View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Extrudex</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="MQTTnet" Version="4.3.7.1207" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
</Project>

18
backend/Extrudex.sln Normal file
View File

@@ -0,0 +1,18 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Extrudex", "Extrudex.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,50 @@
using Extrudex.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Extrudex.Infrastructure.Data.Configurations;
public class AmsSlotConfiguration : BaseEntityConfiguration<AmsSlot>
{
public override void Configure(EntityTypeBuilder<AmsSlot> builder)
{
base.Configure(builder);
builder.Property(e => e.TrayIndex)
.HasColumnName("tray_index")
.IsRequired();
builder.Property(e => e.AmsUnitId)
.HasColumnName("ams_unit_id")
.IsRequired();
builder.Property(e => e.SpoolId)
.HasColumnName("spool_id");
builder.Property(e => e.RemainingWeightG)
.HasColumnName("remaining_weight_g")
.HasPrecision(10, 2);
// Unique index on (ams_unit_id, tray_index) — each slot position is unique within its unit
builder.HasIndex(e => new { e.AmsUnitId, e.TrayIndex })
.IsUnique()
.HasDatabaseName("ix_ams_slots_ams_unit_id_tray_index");
// Index on spool_id for looking up which slot holds a given spool
builder.HasIndex(e => e.SpoolId)
.HasDatabaseName("ix_ams_slots_spool_id");
// Relationships
builder.HasOne(e => e.AmsUnit)
.WithMany(e => e.Slots)
.HasForeignKey(e => e.AmsUnitId)
.HasConstraintName("fk_ams_slots_ams_unit")
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.Spool)
.WithMany(e => e.AmsSlots)
.HasForeignKey(e => e.SpoolId)
.HasConstraintName("fk_ams_slots_spool")
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -0,0 +1,38 @@
using Extrudex.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Extrudex.Infrastructure.Data.Configurations;
public class AmsUnitConfiguration : BaseEntityConfiguration<AmsUnit>
{
public override void Configure(EntityTypeBuilder<AmsUnit> builder)
{
base.Configure(builder);
builder.Property(e => e.UnitIndex)
.HasColumnName("unit_index")
.IsRequired();
builder.Property(e => e.PrinterId)
.HasColumnName("printer_id")
.IsRequired();
// Unique index on (printer_id, unit_index) — no two units on the same printer share an index
builder.HasIndex(e => new { e.PrinterId, e.UnitIndex })
.IsUnique()
.HasDatabaseName("ix_ams_units_printer_id_unit_index");
// Relationships
builder.HasOne(e => e.Printer)
.WithMany(e => e.AmsUnits)
.HasForeignKey(e => e.PrinterId)
.HasConstraintName("fk_ams_units_printer")
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(e => e.Slots)
.WithOne(e => e.AmsUnit)
.HasForeignKey(e => e.AmsUnitId)
.HasConstraintName("fk_ams_slots_ams_unit");
}
}

View File

@@ -0,0 +1,63 @@
using Extrudex.Domain.Base;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Extrudex.Infrastructure.Data.Configurations;
/// <summary>
/// Base configuration for all entities. Sets up common conventions:
/// - Table names in snake_case
/// - GUID primary keys stored as PostgreSQL UUID
/// - Automatic timestamp columns in snake_case
/// </summary>
/// <typeparam name="TEntity">The entity type to configure.</typeparam>
public abstract class BaseEntityConfiguration<TEntity> : IEntityTypeConfiguration<TEntity>
where TEntity : BaseEntity
{
public virtual void Configure(EntityTypeBuilder<TEntity> builder)
{
// Table name in snake_case
builder.ToTable(ToSnakeCase(typeof(TEntity).Name));
// Primary key stored as UUID
builder.HasKey(e => e.Id);
builder.Property(e => e.Id)
.HasColumnName("id")
.ValueGeneratedNever();
// If the entity is auditable, configure the timestamp columns
if (typeof(AuditableEntity).IsAssignableFrom(typeof(TEntity)))
{
ConfigureAuditColumns(builder);
}
}
/// <summary>
/// Configures audit timestamp columns (created_at, updated_at) for auditable entities.
/// Uses string-based property names since the generic type constraint is BaseEntity
/// and cannot be cast to AuditableEntity at compile time.
/// </summary>
private static void ConfigureAuditColumns(EntityTypeBuilder<TEntity> builder)
{
builder.Property<DateTime>("CreatedAt")
.HasColumnName("created_at")
.HasDefaultValueSql("now() at time zone 'utc'");
builder.Property<DateTime>("UpdatedAt")
.HasColumnName("updated_at")
.HasDefaultValueSql("now() at time zone 'utc'");
}
/// <summary>
/// Converts PascalCase or camelCase to snake_case.
/// </summary>
protected static string ToSnakeCase(string name)
{
return string.Concat(
name.Select((ch, i) =>
i > 0 && char.IsUpper(ch) && (char.IsLower(name[i - 1]) || (i + 1 < name.Length && char.IsLower(name[i + 1])))
? "_" + ch
: ch.ToString()))
.ToLowerInvariant();
}
}

View File

@@ -0,0 +1,44 @@
using Extrudex.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Extrudex.Infrastructure.Data.Configurations;
public class MaterialBaseConfiguration : BaseEntityConfiguration<MaterialBase>
{
public override void Configure(EntityTypeBuilder<MaterialBase> builder)
{
base.Configure(builder);
builder.Property(e => e.Name)
.HasColumnName("name")
.IsRequired()
.HasMaxLength(100);
builder.Property(e => e.DensityGperCm3)
.HasColumnName("density_g_per_cm3")
.HasPrecision(10, 4)
.IsRequired();
// Unique index on material base name
builder.HasIndex(e => e.Name)
.IsUnique()
.HasDatabaseName("ix_material_bases_name");
// Relationships
builder.HasMany(e => e.Finishes)
.WithOne(e => e.MaterialBase)
.HasForeignKey(e => e.MaterialBaseId)
.HasConstraintName("fk_material_finishes_material_base");
builder.HasMany(e => e.Modifiers)
.WithOne(e => e.MaterialBase)
.HasForeignKey(e => e.MaterialBaseId)
.HasConstraintName("fk_material_modifiers_material_base");
builder.HasMany(e => e.Spools)
.WithOne(e => e.MaterialBase)
.HasForeignKey(e => e.MaterialBaseId)
.HasConstraintName("fk_spools_material_base");
}
}

View File

@@ -0,0 +1,39 @@
using Extrudex.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Extrudex.Infrastructure.Data.Configurations;
public class MaterialFinishConfiguration : BaseEntityConfiguration<MaterialFinish>
{
public override void Configure(EntityTypeBuilder<MaterialFinish> builder)
{
base.Configure(builder);
builder.Property(e => e.Name)
.HasColumnName("name")
.IsRequired()
.HasMaxLength(100);
builder.Property(e => e.MaterialBaseId)
.HasColumnName("material_base_id")
.IsRequired();
// Unique index on (material_base_id, name) — each finish name is unique per base material
builder.HasIndex(e => new { e.MaterialBaseId, e.Name })
.IsUnique()
.HasDatabaseName("ix_material_finishes_material_base_id_name");
// Relationship configured from MaterialBase side; navigation-only here
builder.HasOne(e => e.MaterialBase)
.WithMany(e => e.Finishes)
.HasForeignKey(e => e.MaterialBaseId)
.HasConstraintName("fk_material_finishes_material_base")
.OnDelete(DeleteBehavior.Restrict);
builder.HasMany(e => e.Spools)
.WithOne(e => e.MaterialFinish)
.HasForeignKey(e => e.MaterialFinishId)
.HasConstraintName("fk_spools_material_finish");
}
}

View File

@@ -0,0 +1,38 @@
using Extrudex.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Extrudex.Infrastructure.Data.Configurations;
public class MaterialModifierConfiguration : BaseEntityConfiguration<MaterialModifier>
{
public override void Configure(EntityTypeBuilder<MaterialModifier> builder)
{
base.Configure(builder);
builder.Property(e => e.Name)
.HasColumnName("name")
.IsRequired()
.HasMaxLength(100);
builder.Property(e => e.MaterialBaseId)
.HasColumnName("material_base_id")
.IsRequired();
// Unique index on (material_base_id, name) — each modifier name is unique per base material
builder.HasIndex(e => new { e.MaterialBaseId, e.Name })
.IsUnique()
.HasDatabaseName("ix_material_modifiers_material_base_id_name");
builder.HasOne(e => e.MaterialBase)
.WithMany(e => e.Modifiers)
.HasForeignKey(e => e.MaterialBaseId)
.HasConstraintName("fk_material_modifiers_material_base")
.OnDelete(DeleteBehavior.Restrict);
builder.HasMany(e => e.Spools)
.WithOne(e => e.MaterialModifier!)
.HasForeignKey(e => e.MaterialModifierId)
.HasConstraintName("fk_spools_material_modifier");
}
}

View File

@@ -0,0 +1,108 @@
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Extrudex.Infrastructure.Data.Configurations;
public class PrintJobConfiguration : BaseEntityConfiguration<PrintJob>
{
public override void Configure(EntityTypeBuilder<PrintJob> builder)
{
base.Configure(builder);
builder.Property(e => e.PrinterId)
.HasColumnName("printer_id")
.IsRequired();
builder.Property(e => e.SpoolId)
.HasColumnName("spool_id")
.IsRequired();
builder.Property(e => e.PrintName)
.HasColumnName("print_name")
.IsRequired()
.HasMaxLength(500);
builder.Property(e => e.GcodeFilePath)
.HasColumnName("gcode_file_path")
.HasMaxLength(1000);
builder.Property(e => e.MmExtruded)
.HasColumnName("mm_extruded")
.HasPrecision(12, 2)
.IsRequired();
builder.Property(e => e.GramsDerived)
.HasColumnName("grams_derived")
.HasPrecision(10, 2)
.IsRequired();
builder.Property(e => e.CostPerPrint)
.HasColumnName("cost_per_print")
.HasPrecision(10, 4);
builder.Property(e => e.StartedAt)
.HasColumnName("started_at");
builder.Property(e => e.CompletedAt)
.HasColumnName("completed_at");
builder.Property(e => e.Status)
.HasColumnName("status")
.HasConversion<string>()
.HasMaxLength(50)
.HasDefaultValue(JobStatus.Queued)
.IsRequired();
builder.Property(e => e.DataSource)
.HasColumnName("data_source")
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired();
// Audit snapshots for COGS accuracy
builder.Property(e => e.FilamentDiameterAtPrintMm)
.HasColumnName("filament_diameter_at_print_mm")
.HasPrecision(6, 3)
.IsRequired();
builder.Property(e => e.MaterialDensityAtPrint)
.HasColumnName("material_density_at_print")
.HasPrecision(10, 4)
.IsRequired();
builder.Property(e => e.Notes)
.HasColumnName("notes")
.HasMaxLength(2000);
// Index on status for filtering active/completed jobs
builder.HasIndex(e => e.Status)
.HasDatabaseName("ix_print_jobs_status");
// Index on printer_id for querying jobs by printer
builder.HasIndex(e => e.PrinterId)
.HasDatabaseName("ix_print_jobs_printer_id");
// Index on spool_id for querying jobs by spool
builder.HasIndex(e => e.SpoolId)
.HasDatabaseName("ix_print_jobs_spool_id");
// Index on data_source for querying by integration path
builder.HasIndex(e => e.DataSource)
.HasDatabaseName("ix_print_jobs_data_source");
// Relationships
builder.HasOne(e => e.Printer)
.WithMany(e => e.PrintJobs)
.HasForeignKey(e => e.PrinterId)
.HasConstraintName("fk_print_jobs_printer")
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.Spool)
.WithMany(e => e.PrintJobs)
.HasForeignKey(e => e.SpoolId)
.HasConstraintName("fk_print_jobs_spool")
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -0,0 +1,111 @@
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Extrudex.Infrastructure.Data.Configurations;
public class PrinterConfiguration : BaseEntityConfiguration<Printer>
{
public override void Configure(EntityTypeBuilder<Printer> builder)
{
base.Configure(builder);
builder.Property(e => e.Name)
.HasColumnName("name")
.IsRequired()
.HasMaxLength(200);
builder.Property(e => e.Status)
.HasColumnName("status")
.HasConversion<string>()
.HasMaxLength(50)
.HasDefaultValue(PrinterStatus.Offline)
.IsRequired();
builder.Property(e => e.Manufacturer)
.HasColumnName("manufacturer")
.IsRequired()
.HasMaxLength(200);
builder.Property(e => e.Model)
.HasColumnName("model")
.IsRequired()
.HasMaxLength(200);
builder.Property(e => e.PrinterType)
.HasColumnName("printer_type")
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired();
builder.Property(e => e.ConnectionType)
.HasColumnName("connection_type")
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired();
builder.Property(e => e.HostnameOrIp)
.HasColumnName("hostname_or_ip")
.IsRequired()
.HasMaxLength(255);
builder.Property(e => e.Port)
.HasColumnName("port")
.IsRequired();
// MQTT credentials
builder.Property(e => e.MqttUsername)
.HasColumnName("mqtt_username")
.HasMaxLength(200);
builder.Property(e => e.MqttPassword)
.HasColumnName("mqtt_password")
.HasMaxLength(500);
builder.Property(e => e.MqttUseTls)
.HasColumnName("mqtt_use_tls")
.HasDefaultValue(false)
.IsRequired();
// Moonraker API key
builder.Property(e => e.ApiKey)
.HasColumnName("api_key")
.HasMaxLength(500);
builder.Property(e => e.IsActive)
.HasColumnName("is_active")
.HasDefaultValue(true)
.IsRequired();
builder.Property(e => e.LastSeenAt)
.HasColumnName("last_seen_at");
// Index on status for filtering online/offline printers
builder.HasIndex(e => e.Status)
.HasDatabaseName("ix_printers_status");
// Index on printer_type for filtering by printer hardware type
builder.HasIndex(e => e.PrinterType)
.HasDatabaseName("ix_printers_printer_type");
// Index on connection for querying by protocol
builder.HasIndex(e => e.ConnectionType)
.HasDatabaseName("ix_printers_connection_type");
// Index on is_active for active printer queries
builder.HasIndex(e => e.IsActive)
.HasDatabaseName("ix_printers_is_active");
// Relationships
builder.HasMany(e => e.AmsUnits)
.WithOne(e => e.Printer)
.HasForeignKey(e => e.PrinterId)
.HasConstraintName("fk_ams_units_printer");
builder.HasMany(e => e.PrintJobs)
.WithOne(e => e.Printer)
.HasForeignKey(e => e.PrinterId)
.HasConstraintName("fk_print_jobs_printer");
}
}

View File

@@ -0,0 +1,113 @@
using Extrudex.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Extrudex.Infrastructure.Data.Configurations;
public class SpoolConfiguration : BaseEntityConfiguration<Spool>
{
public override void Configure(EntityTypeBuilder<Spool> builder)
{
base.Configure(builder);
builder.Property(e => e.MaterialBaseId)
.HasColumnName("material_base_id")
.IsRequired();
builder.Property(e => e.MaterialFinishId)
.HasColumnName("material_finish_id")
.IsRequired();
builder.Property(e => e.MaterialModifierId)
.HasColumnName("material_modifier_id");
builder.Property(e => e.Brand)
.HasColumnName("brand")
.IsRequired()
.HasMaxLength(200);
builder.Property(e => e.ColorName)
.HasColumnName("color_name")
.IsRequired()
.HasMaxLength(200);
builder.Property(e => e.ColorHex)
.HasColumnName("color_hex")
.IsRequired()
.HasMaxLength(7); // "#RRGGBB" format
builder.Property(e => e.WeightTotalGrams)
.HasColumnName("weight_total_grams")
.HasPrecision(10, 2)
.IsRequired();
builder.Property(e => e.WeightRemainingGrams)
.HasColumnName("weight_remaining_grams")
.HasPrecision(10, 2)
.IsRequired();
builder.Property(e => e.FilamentDiameterMm)
.HasColumnName("filament_diameter_mm")
.HasPrecision(6, 3)
.IsRequired();
builder.Property(e => e.SpoolSerial)
.HasColumnName("spool_serial")
.IsRequired()
.HasMaxLength(200);
builder.Property(e => e.PurchasePrice)
.HasColumnName("purchase_price")
.HasPrecision(10, 2);
builder.Property(e => e.PurchaseDate)
.HasColumnName("purchase_date");
builder.Property(e => e.IsActive)
.HasColumnName("is_active")
.HasDefaultValue(true)
.IsRequired();
// Unique index on spool_serial — critical for barcode/QR scanning
builder.HasIndex(e => e.SpoolSerial)
.IsUnique()
.HasDatabaseName("ix_spools_spool_serial");
// Index on material_base_id for spool filtering
builder.HasIndex(e => e.MaterialBaseId)
.HasDatabaseName("ix_spools_material_base_id");
// Index on is_active for active spool queries
builder.HasIndex(e => e.IsActive)
.HasDatabaseName("ix_spools_is_active");
// Relationships
builder.HasOne(e => e.MaterialBase)
.WithMany(e => e.Spools)
.HasForeignKey(e => e.MaterialBaseId)
.HasConstraintName("fk_spools_material_base")
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.MaterialFinish)
.WithMany(e => e.Spools)
.HasForeignKey(e => e.MaterialFinishId)
.HasConstraintName("fk_spools_material_finish")
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.MaterialModifier)
.WithMany(e => e.Spools)
.HasForeignKey(e => e.MaterialModifierId)
.HasConstraintName("fk_spools_material_modifier")
.OnDelete(DeleteBehavior.SetNull);
builder.HasMany(e => e.AmsSlots)
.WithOne(e => e.Spool!)
.HasForeignKey(e => e.SpoolId)
.HasConstraintName("fk_ams_slots_spool");
builder.HasMany(e => e.PrintJobs)
.WithOne(e => e.Spool)
.HasForeignKey(e => e.SpoolId)
.HasConstraintName("fk_print_jobs_spool");
}
}

View File

@@ -0,0 +1,78 @@
using Extrudex.Domain.Base;
using Extrudex.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Extrudex.Infrastructure.Data;
/// <summary>
/// Main EF Core database context for the Extrudex system.
/// Handles entity registration, snake_case naming, and automatic timestamp management.
/// </summary>
public class ExtrudexDbContext : DbContext
{
public ExtrudexDbContext(DbContextOptions<ExtrudexDbContext> options) : base(options) { }
// Lookup tables
public DbSet<MaterialBase> MaterialBases => Set<MaterialBase>();
public DbSet<MaterialFinish> MaterialFinishes => Set<MaterialFinish>();
public DbSet<MaterialModifier> MaterialModifiers => Set<MaterialModifier>();
// Core entities
public DbSet<Spool> Spools => Set<Spool>();
public DbSet<Printer> Printers => Set<Printer>();
public DbSet<AmsUnit> AmsUnits => Set<AmsUnit>();
public DbSet<AmsSlot> AmsSlots => Set<AmsSlot>();
public DbSet<PrintJob> PrintJobs => Set<PrintJob>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply all entity type configurations from the assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ExtrudexDbContext).Assembly);
// Apply seed data
modelBuilder.Entity<MaterialBase>().HasData(SeedData.MaterialBases);
modelBuilder.Entity<MaterialFinish>().HasData(SeedData.MaterialFinishes);
modelBuilder.Entity<MaterialModifier>().HasData(SeedData.MaterialModifiers);
}
/// <summary>
/// Automatically set UpdatedAt on auditable entities during SaveChanges.
/// </summary>
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
SetAuditTimestamps();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override async Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default)
{
SetAuditTimestamps();
return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
/// <summary>
/// Sets UpdatedAt on all auditable entities that have been modified.
/// Sets CreatedAt on all auditable entities that are being added.
/// </summary>
private void SetAuditTimestamps()
{
var entries = ChangeTracker.Entries<AuditableEntity>();
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = DateTime.UtcNow;
entry.Entity.UpdatedAt = DateTime.UtcNow;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = DateTime.UtcNow;
}
}
}
}

View File

@@ -0,0 +1,121 @@
using Extrudex.Domain.Entities;
namespace Extrudex.Infrastructure.Data;
/// <summary>
/// Static seed data for all material lookup tables.
/// These are inserted via EF Core HasData during the initial migration.
///
/// IDs are deterministic GUIDs to ensure idempotent seed operations.
/// MaterialFinish is REQUIRED on every spool — default value is "Basic" (not "Standard").
/// </summary>
public static class SeedData
{
// ─────────────────────────────────────────────
// MaterialBase — deterministic GUIDs
// ─────────────────────────────────────────────
public static readonly Guid PlaId = Guid.Parse("10000000-0000-0000-0000-000000000001");
public static readonly Guid PetgId = Guid.Parse("10000000-0000-0000-0000-000000000002");
public static readonly Guid AbsId = Guid.Parse("10000000-0000-0000-0000-000000000003");
public static readonly Guid AsaId = Guid.Parse("10000000-0000-0000-0000-000000000004");
public static readonly Guid TpuId = Guid.Parse("10000000-0000-0000-0000-000000000005");
public static readonly Guid NylonId = Guid.Parse("10000000-0000-0000-0000-000000000006");
public static readonly MaterialBase[] MaterialBases =
[
new() { Id = PlaId, Name = "PLA", DensityGperCm3 = 1.24m },
new() { Id = PetgId, Name = "PETG", DensityGperCm3 = 1.27m },
new() { Id = AbsId, Name = "ABS", DensityGperCm3 = 1.04m },
new() { Id = AsaId, Name = "ASA", DensityGperCm3 = 1.07m },
new() { Id = TpuId, Name = "TPU", DensityGperCm3 = 1.21m },
new() { Id = NylonId, Name = "Nylon", DensityGperCm3 = 1.14m }
];
// ─────────────────────────────────────────────
// MaterialFinish — "Basic" is the default, NOT "Standard"
// ─────────────────────────────────────────────
public static readonly Guid PlaBasicId = Guid.Parse("20000000-0000-0000-0000-000000000001");
public static readonly Guid PlaMatteId = Guid.Parse("20000000-0000-0000-0000-000000000002");
public static readonly Guid PlaSilkId = Guid.Parse("20000000-0000-0000-0000-000000000003");
public static readonly Guid PlaGlitterId = Guid.Parse("20000000-0000-0000-0000-000000000004");
public static readonly Guid PlaMarbleId = Guid.Parse("20000000-0000-0000-0000-000000000005");
public static readonly Guid PlaSparkleId = Guid.Parse("20000000-0000-0000-0000-000000000006");
public static readonly Guid PetgBasicId = Guid.Parse("20000000-0000-0000-0000-000000000007");
public static readonly Guid PetgMatteId = Guid.Parse("20000000-0000-0000-0000-000000000008");
public static readonly Guid PetgSilkId = Guid.Parse("20000000-0000-0000-0000-000000000009");
public static readonly Guid AbsBasicId = Guid.Parse("20000000-0000-0000-0000-000000000010");
public static readonly Guid AbsMatteId = Guid.Parse("20000000-0000-0000-0000-000000000011");
public static readonly Guid AsaBasicId = Guid.Parse("20000000-0000-0000-0000-000000000012");
public static readonly Guid AsaMatteId = Guid.Parse("20000000-0000-0000-0000-000000000013");
public static readonly Guid TpuBasicId = Guid.Parse("20000000-0000-0000-0000-000000000014");
public static readonly Guid NylonBasicId = Guid.Parse("20000000-0000-0000-0000-000000000015");
public static readonly MaterialFinish[] MaterialFinishes =
[
// PLA finishes
new() { Id = PlaBasicId, Name = "Basic", MaterialBaseId = PlaId },
new() { Id = PlaMatteId, Name = "Matte", MaterialBaseId = PlaId },
new() { Id = PlaSilkId, Name = "Silk", MaterialBaseId = PlaId },
new() { Id = PlaGlitterId, Name = "Glitter", MaterialBaseId = PlaId },
new() { Id = PlaMarbleId, Name = "Marble", MaterialBaseId = PlaId },
new() { Id = PlaSparkleId, Name = "Sparkle", MaterialBaseId = PlaId },
// PETG finishes
new() { Id = PetgBasicId, Name = "Basic", MaterialBaseId = PetgId },
new() { Id = PetgMatteId, Name = "Matte", MaterialBaseId = PetgId },
new() { Id = PetgSilkId, Name = "Silk", MaterialBaseId = PetgId },
// ABS finishes
new() { Id = AbsBasicId, Name = "Basic", MaterialBaseId = AbsId },
new() { Id = AbsMatteId, Name = "Matte", MaterialBaseId = AbsId },
// ASA finishes
new() { Id = AsaBasicId, Name = "Basic", MaterialBaseId = AsaId },
new() { Id = AsaMatteId, Name = "Matte", MaterialBaseId = AsaId },
// TPU finishes
new() { Id = TpuBasicId, Name = "Basic", MaterialBaseId = TpuId },
// Nylon finishes
new() { Id = NylonBasicId, Name = "Basic", MaterialBaseId = NylonId }
];
// ─────────────────────────────────────────────
// MaterialModifier — optional additives
// ─────────────────────────────────────────────
public static readonly Guid PlaCarbonFiberId = Guid.Parse("30000000-0000-0000-0000-000000000001");
public static readonly Guid PlaGlassFiberId = Guid.Parse("30000000-0000-0000-0000-000000000002");
public static readonly Guid PlaWoodFillId = Guid.Parse("30000000-0000-0000-0000-000000000003");
public static readonly Guid PlaGlowInDarkId = Guid.Parse("30000000-0000-0000-0000-000000000004");
public static readonly Guid PetgCarbonFiberId = Guid.Parse("30000000-0000-0000-0000-000000000005");
public static readonly Guid PetgGlassFiberId = Guid.Parse("30000000-0000-0000-0000-000000000006");
public static readonly Guid AbsCarbonFiberId = Guid.Parse("30000000-0000-0000-0000-000000000007");
public static readonly Guid AbsGlassFiberId = Guid.Parse("30000000-0000-0000-0000-000000000008");
public static readonly Guid AsaCarbonFiberId = Guid.Parse("30000000-0000-0000-0000-000000000009");
public static readonly Guid NylonCarbonFiberId = Guid.Parse("30000000-0000-0000-0000-000000000010");
public static readonly Guid NylonGlassFiberId = Guid.Parse("30000000-0000-0000-0000-000000000011");
public static readonly MaterialModifier[] MaterialModifiers =
[
// PLA modifiers
new() { Id = PlaCarbonFiberId, Name = "Carbon Fiber", MaterialBaseId = PlaId },
new() { Id = PlaGlassFiberId, Name = "Glass Fiber", MaterialBaseId = PlaId },
new() { Id = PlaWoodFillId, Name = "Wood Fill", MaterialBaseId = PlaId },
new() { Id = PlaGlowInDarkId, Name = "Glow-in-the-Dark", MaterialBaseId = PlaId },
// PETG modifiers
new() { Id = PetgCarbonFiberId, Name = "Carbon Fiber", MaterialBaseId = PetgId },
new() { Id = PetgGlassFiberId, Name = "Glass Fiber", MaterialBaseId = PetgId },
// ABS modifiers
new() { Id = AbsCarbonFiberId, Name = "Carbon Fiber", MaterialBaseId = AbsId },
new() { Id = AbsGlassFiberId, Name = "Glass Fiber", MaterialBaseId = AbsId },
// ASA modifiers
new() { Id = AsaCarbonFiberId, Name = "Carbon Fiber", MaterialBaseId = AsaId },
// Nylon modifiers
new() { Id = NylonCarbonFiberId, Name = "Carbon Fiber", MaterialBaseId = NylonId },
new() { Id = NylonGlassFiberId, Name = "Glass Fiber", MaterialBaseId = NylonId }
];
}

View File

@@ -0,0 +1,67 @@
using Extrudex.Domain.Enums;
using Extrudex.Domain.Interfaces;
using QRCoder;
namespace Extrudex.Infrastructure.Services;
/// <summary>
/// Generates high-contrast QR codes encoding deep links to Extrudex resources.
/// Optimized for small label printing with dark modules on white background.
/// Uses QRCoder library with ECC-level High for robust scanning on tiny labels.
/// </summary>
public class QrCodeService : IQrCodeService
{
private const string BaseUrl = "https://extrudex.app";
/// <inheritdoc />
public byte[] GeneratePng(QrResourceType resourceType, Guid id, int pixelsPerModule = 20)
{
var url = GetResourceUrl(resourceType, id);
using var qrGenerator = new QRCodeGenerator();
var qrCodeData = qrGenerator.CreateQrCode(
url,
QRCodeGenerator.ECCLevel.H); // High error correction — critical for small labels
using var qrCode = new PngByteQRCode(qrCodeData);
return qrCode.GetGraphic(
pixelsPerModule,
darkColorRgba: new byte[] { 0, 0, 0, 255 }, // Pure black — maximum contrast
lightColorRgba: new byte[] { 255, 255, 255, 255 }, // Pure white background
drawQuietZones: true); // Quiet zones improve scan reliability
}
/// <inheritdoc />
public string GenerateSvg(QrResourceType resourceType, Guid id)
{
var url = GetResourceUrl(resourceType, id);
using var qrGenerator = new QRCodeGenerator();
var qrCodeData = qrGenerator.CreateQrCode(
url,
QRCodeGenerator.ECCLevel.H);
using var svgQrCode = new SvgQRCode(qrCodeData);
return svgQrCode.GetGraphic(
pixelsPerModule: 20,
darkColorHex: "#000000", // Pure black — maximum contrast
lightColorHex: "#FFFFFF", // Pure white background
drawQuietZones: true,
sizingMode: SvgQRCode.SizingMode.WidthHeightAttribute);
}
/// <inheritdoc />
public string GetResourceUrl(QrResourceType resourceType, Guid id)
{
var path = resourceType switch
{
QrResourceType.Spool => $"/spools/{id}",
QrResourceType.Printer => $"/printers/{id}",
QrResourceType.Location => $"/locations/{id}",
_ => throw new ArgumentOutOfRangeException(nameof(resourceType),
resourceType, $"Unsupported QR resource type: {resourceType}")
};
return $"{BaseUrl}{path}";
}
}

103
backend/Program.cs Normal file
View File

@@ -0,0 +1,103 @@
using System.Reflection;
using Extrudex.API.Hubs;
using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Data;
using Extrudex.Infrastructure.Services;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// ── Database ───────────────────────────────────────────────
// Connection string resolution (highest priority first):
// 1. EXTRUDEX_DB_CONNECTION_STRING env var (Docker / production)
// 2. Individual env vars: EXTRUDEX_DB_HOST, EXTRUDEX_DB_PORT, etc.
// 3. appsettings.json ConnectionStrings:ExtrudexDb
// 4. Hardcoded default for local dev
var connectionString = Environment.GetEnvironmentVariable("EXTRUDEX_DB_CONNECTION_STRING")
?? BuildConnectionStringFromEnvVars()
?? builder.Configuration.GetConnectionString("ExtrudexDb")
?? "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme";
builder.Services.AddDbContext<ExtrudexDbContext>(options =>
options.UseNpgsql(connectionString));
// ── API Services ───────────────────────────────────────────
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new()
{
Title = "Extrudex API",
Version = "v1",
Description = "Filament inventory and print tracking system"
});
// Include XML doc comments in Swagger output
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
{
c.IncludeXmlComments(xmlPath);
}
});
// ── QR Code Generation ──────────────────────────────────────
builder.Services.AddSingleton<IQrCodeService, QrCodeService>();
// ── FluentValidation ──────────────────────────────────────
// Registers all validators from the API assembly into DI.
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
// ── CORS (kiosk + remote browser) ─────────────────────────
// AllowAnyOrigin disallows credentials by spec; this is fine for
// REST API calls. SignalR WebSockets negotiate without credentials
// by default, so no special CORS policy is needed. If browser clients
// require credentials (cookies, auth headers), replace AllowAnyOrigin
// with .WithOrigins(...) and add .AllowCredentials().
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// ── SignalR (real-time printer updates) ────────────────────
builder.Services.AddSignalR();
var app = builder.Build();
// ── Middleware ──────────────────────────────────────────────
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors();
app.UseAuthorization();
app.MapControllers();
// ── Hub Endpoints ───────────────────────────────────────────
app.MapHub<PrinterHub>("/hubs/printer");
app.Run();
// Helper: builds a connection string from individual env vars.
// Returns null if EXTRUDEX_DB_HOST is not set.
static string? BuildConnectionStringFromEnvVars()
{
var host = Environment.GetEnvironmentVariable("EXTRUDEX_DB_HOST");
if (string.IsNullOrEmpty(host)) return null;
var port = Environment.GetEnvironmentVariable("EXTRUDEX_DB_PORT") ?? "5432";
var database = Environment.GetEnvironmentVariable("EXTRUDEX_DB_NAME") ?? "extrudex";
var username = Environment.GetEnvironmentVariable("EXTRUDEX_DB_USER") ?? "extrudex";
var password = Environment.GetEnvironmentVariable("EXTRUDEX_DB_PASSWORD") ?? "changeme";
return $"Host={host};Port={port};Database={database};Username={username};Password={password}";
}

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"Microsoft.EntityFrameworkCore": "Debug"
}
},
"ConnectionStrings": {
"ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex_dev;Username=extrudex;Password=changeme"
}
}

13
backend/appsettings.json Normal file
View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme"
}
}