From 230c3b295d8b404bf667a0b2a31696338d6558fc Mon Sep 17 00:00:00 2001 From: "cubecraft-agents[bot]" <3458173+cubecraft-agents[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:51:05 +0000 Subject: [PATCH] initial commit --- .gitignore | 5 + README.md | 0 .../API/Controllers/FilamentsController.cs | 314 ++++++++ .../Controllers/MaterialBasesController.cs | 158 ++++ .../Controllers/MaterialFinishesController.cs | 191 +++++ .../Controllers/MaterialLookupsController.cs | 533 +++++++++++++ .../MaterialModifiersController.cs | 192 +++++ .../API/Controllers/PrintJobsController.cs | 512 ++++++++++++ backend/API/Controllers/PrintersController.cs | 297 +++++++ backend/API/Controllers/QrController.cs | 101 +++ backend/API/Controllers/SpoolsController.cs | 329 ++++++++ backend/API/DTOs/Filaments/FilamentDtos.cs | 199 +++++ .../API/DTOs/Filaments/FilamentQueryDtos.cs | 33 + .../API/DTOs/Materials/MaterialBaseDtos.cs | 56 ++ .../API/DTOs/Materials/MaterialFinishDtos.cs | 57 ++ .../DTOs/Materials/MaterialModifierDtos.cs | 57 ++ backend/API/DTOs/PagedResponse.cs | 30 + backend/API/DTOs/PrintJobs/PrintJobDtos.cs | 223 ++++++ .../API/DTOs/PrintJobs/PrintJobQueryDtos.cs | 30 + backend/API/DTOs/Printers/PrinterDtos.cs | 190 +++++ backend/API/DTOs/Spools/SpoolDtos.cs | 193 +++++ backend/API/DTOs/Spools/SpoolQueryDtos.cs | 27 + backend/API/Hubs/IPrinterClient.cs | 31 + backend/API/Hubs/PrinterHub.cs | 137 ++++ backend/API/Validators/MaterialValidators.cs | 100 +++ backend/API/Validators/PrintJobValidators.cs | 106 +++ backend/API/Validators/PrinterValidators.cs | 82 ++ backend/API/Validators/SpoolValidators.cs | 96 +++ backend/Domain/Base/AuditableEntity.cs | 19 + backend/Domain/Base/BaseEntity.cs | 12 + backend/Domain/Entities/AmsSlot.cs | 41 + backend/Domain/Entities/AmsUnit.cs | 30 + backend/Domain/Entities/MaterialBase.cs | 36 + backend/Domain/Entities/MaterialFinish.cs | 30 + backend/Domain/Entities/MaterialModifier.cs | 30 + backend/Domain/Entities/PrintJob.cs | 100 +++ backend/Domain/Entities/Printer.cs | 97 +++ backend/Domain/Entities/Spool.cs | 105 +++ backend/Domain/Enums/ConnectionType.cs | 13 + backend/Domain/Enums/DataSource.cs | 16 + backend/Domain/Enums/JobStatus.cs | 22 + backend/Domain/Enums/PrinterStatus.cs | 32 + backend/Domain/Enums/PrinterType.cs | 13 + backend/Domain/Enums/QrResourceType.cs | 24 + backend/Domain/Interfaces/IQrCodeService.cs | 41 + backend/Extrudex.csproj | 21 + backend/Extrudex.sln | 18 + .../Configurations/AmsSlotConfiguration.cs | 50 ++ .../Configurations/AmsUnitConfiguration.cs | 38 + .../Configurations/BaseEntityConfiguration.cs | 63 ++ .../MaterialBaseConfiguration.cs | 44 ++ .../MaterialFinishConfiguration.cs | 39 + .../MaterialModifierConfiguration.cs | 38 + .../Configurations/PrintJobConfiguration.cs | 108 +++ .../Configurations/PrinterConfiguration.cs | 111 +++ .../Data/Configurations/SpoolConfiguration.cs | 113 +++ .../Infrastructure/Data/ExtrudexDbContext.cs | 78 ++ backend/Infrastructure/Data/Seed/SeedData.cs | 121 +++ .../Infrastructure/Services/QrCodeService.cs | 67 ++ backend/Program.cs | 103 +++ backend/appsettings.Development.json | 12 + backend/appsettings.json | 13 + design/01-filament-inventory-list.md | 344 ++++++++ design/02-spool-detail-view.md | 424 ++++++++++ design/03-smart-intake-workflow.md | 738 ++++++++++++++++++ design/hardware_setup.png | Bin 0 -> 761690 bytes design/homepage-mockup-kiosk.jpg | Bin 0 -> 2353995 bytes design/homepage-mockup-mobile.jpg | Bin 0 -> 2190164 bytes design/homepage-spec.md | 510 ++++++++++++ design/kiosk_interface.png | Bin 0 -> 657941 bytes design/mockup-inventory-list-kiosk.jpg | Bin 0 -> 2084783 bytes design/mockup-inventory-list-mobile.jpg | Bin 0 -> 1949515 bytes design/mockup-spool-detail-kiosk.jpg | Bin 0 -> 1944272 bytes design/mockup-spool-detail-mobile.jpg | Bin 0 -> 2047424 bytes design/overall_dashboard.png | Bin 0 -> 667217 bytes design/smart-intake-identify-kiosk.jpg | Bin 0 -> 1971430 bytes design/smart-intake-identify-mobile.jpg | Bin 0 -> 1934046 bytes design/smart-intake-identify-spec.md | 100 +++ 78 files changed, 8093 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/API/Controllers/FilamentsController.cs create mode 100644 backend/API/Controllers/MaterialBasesController.cs create mode 100644 backend/API/Controllers/MaterialFinishesController.cs create mode 100644 backend/API/Controllers/MaterialLookupsController.cs create mode 100644 backend/API/Controllers/MaterialModifiersController.cs create mode 100644 backend/API/Controllers/PrintJobsController.cs create mode 100644 backend/API/Controllers/PrintersController.cs create mode 100644 backend/API/Controllers/QrController.cs create mode 100644 backend/API/Controllers/SpoolsController.cs create mode 100644 backend/API/DTOs/Filaments/FilamentDtos.cs create mode 100644 backend/API/DTOs/Filaments/FilamentQueryDtos.cs create mode 100644 backend/API/DTOs/Materials/MaterialBaseDtos.cs create mode 100644 backend/API/DTOs/Materials/MaterialFinishDtos.cs create mode 100644 backend/API/DTOs/Materials/MaterialModifierDtos.cs create mode 100644 backend/API/DTOs/PagedResponse.cs create mode 100644 backend/API/DTOs/PrintJobs/PrintJobDtos.cs create mode 100644 backend/API/DTOs/PrintJobs/PrintJobQueryDtos.cs create mode 100644 backend/API/DTOs/Printers/PrinterDtos.cs create mode 100644 backend/API/DTOs/Spools/SpoolDtos.cs create mode 100644 backend/API/DTOs/Spools/SpoolQueryDtos.cs create mode 100644 backend/API/Hubs/IPrinterClient.cs create mode 100644 backend/API/Hubs/PrinterHub.cs create mode 100644 backend/API/Validators/MaterialValidators.cs create mode 100644 backend/API/Validators/PrintJobValidators.cs create mode 100644 backend/API/Validators/PrinterValidators.cs create mode 100644 backend/API/Validators/SpoolValidators.cs create mode 100644 backend/Domain/Base/AuditableEntity.cs create mode 100644 backend/Domain/Base/BaseEntity.cs create mode 100644 backend/Domain/Entities/AmsSlot.cs create mode 100644 backend/Domain/Entities/AmsUnit.cs create mode 100644 backend/Domain/Entities/MaterialBase.cs create mode 100644 backend/Domain/Entities/MaterialFinish.cs create mode 100644 backend/Domain/Entities/MaterialModifier.cs create mode 100644 backend/Domain/Entities/PrintJob.cs create mode 100644 backend/Domain/Entities/Printer.cs create mode 100644 backend/Domain/Entities/Spool.cs create mode 100644 backend/Domain/Enums/ConnectionType.cs create mode 100644 backend/Domain/Enums/DataSource.cs create mode 100644 backend/Domain/Enums/JobStatus.cs create mode 100644 backend/Domain/Enums/PrinterStatus.cs create mode 100644 backend/Domain/Enums/PrinterType.cs create mode 100644 backend/Domain/Enums/QrResourceType.cs create mode 100644 backend/Domain/Interfaces/IQrCodeService.cs create mode 100644 backend/Extrudex.csproj create mode 100644 backend/Extrudex.sln create mode 100644 backend/Infrastructure/Data/Configurations/AmsSlotConfiguration.cs create mode 100644 backend/Infrastructure/Data/Configurations/AmsUnitConfiguration.cs create mode 100644 backend/Infrastructure/Data/Configurations/BaseEntityConfiguration.cs create mode 100644 backend/Infrastructure/Data/Configurations/MaterialBaseConfiguration.cs create mode 100644 backend/Infrastructure/Data/Configurations/MaterialFinishConfiguration.cs create mode 100644 backend/Infrastructure/Data/Configurations/MaterialModifierConfiguration.cs create mode 100644 backend/Infrastructure/Data/Configurations/PrintJobConfiguration.cs create mode 100644 backend/Infrastructure/Data/Configurations/PrinterConfiguration.cs create mode 100644 backend/Infrastructure/Data/Configurations/SpoolConfiguration.cs create mode 100644 backend/Infrastructure/Data/ExtrudexDbContext.cs create mode 100644 backend/Infrastructure/Data/Seed/SeedData.cs create mode 100644 backend/Infrastructure/Services/QrCodeService.cs create mode 100644 backend/Program.cs create mode 100644 backend/appsettings.Development.json create mode 100644 backend/appsettings.json create mode 100644 design/01-filament-inventory-list.md create mode 100644 design/02-spool-detail-view.md create mode 100644 design/03-smart-intake-workflow.md create mode 100644 design/hardware_setup.png create mode 100644 design/homepage-mockup-kiosk.jpg create mode 100644 design/homepage-mockup-mobile.jpg create mode 100644 design/homepage-spec.md create mode 100644 design/kiosk_interface.png create mode 100644 design/mockup-inventory-list-kiosk.jpg create mode 100644 design/mockup-inventory-list-mobile.jpg create mode 100644 design/mockup-spool-detail-kiosk.jpg create mode 100644 design/mockup-spool-detail-mobile.jpg create mode 100644 design/overall_dashboard.png create mode 100644 design/smart-intake-identify-kiosk.jpg create mode 100644 design/smart-intake-identify-mobile.jpg create mode 100644 design/smart-intake-identify-spec.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29ec7d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +*.user +*.suo +.vs/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/API/Controllers/FilamentsController.cs b/backend/API/Controllers/FilamentsController.cs new file mode 100644 index 0000000..5c62fec --- /dev/null +++ b/backend/API/Controllers/FilamentsController.cs @@ -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; + +/// +/// Controller for managing filament spools — the core inventory unit of Extrudex. +/// Exposes spool data under the user-facing "filaments" route while mapping +/// to the Spool domain entity internally. +/// +[ApiController] +[Route("api/filaments")] +public class FilamentsController : ControllerBase +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The database context for data access. + /// The logger for diagnostic output. + public FilamentsController(ExtrudexDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Gets a paginated list of filament spools with optional filtering by material, + /// brand, and active status. Results are ordered by creation date (newest first) + /// and include denormalized material names for display. + /// + /// Optional query parameters for pagination and filtering. + /// A paginated list of filament spools matching the filter criteria. + /// Returns the paginated list of filament spools. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> GetFilaments( + [FromQuery] FilamentQueryParameters query) + { + _logger.LogDebug( + "Getting filaments: pageNumber={PageNumber}, pageSize={PageSize}, " + + "materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, " + + "materialModifierId={MaterialModifierId}, brand={Brand}, isActive={IsActive}", + 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 + { + Items = items, + TotalCount = totalCount, + PageNumber = pageNumber, + PageSize = pageSize + }; + + return Ok(response); + } + + /// + /// Gets a specific filament spool by its unique identifier. + /// Includes denormalized material names for display. + /// + /// The unique identifier of the filament spool. + /// The filament spool details. + /// Returns the filament spool details. + /// If the filament spool with the given ID is not found. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetFilament(Guid id) + { + _logger.LogDebug("Getting filament {Id}", id); + + var spool = await _dbContext.Spools + .Include(s => s.MaterialBase) + .Include(s => s.MaterialFinish) + .Include(s => s.MaterialModifier) + .FirstOrDefaultAsync(s => s.Id == id); + + if (spool is null) + { + _logger.LogWarning("Filament {Id} not found", id); + return NotFound(new { error = $"Filament with ID '{id}' not found." }); + } + + return Ok(MapToFilamentResponse(spool)); + } + + /// + /// Creates a new filament spool. Validates that all foreign keys reference + /// existing material entities and that the SpoolSerial is unique. + /// WeightRemainingGrams must not exceed WeightTotalGrams. + /// + /// The filament creation request with all required fields. + /// The newly created filament spool with generated ID and timestamps. + /// Returns the created filament spool with location header. + /// If the request is invalid or foreign keys don't exist. + [HttpPost] + [ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateFilament( + [FromBody] CreateFilamentRequest request) + { + _logger.LogInformation("Creating filament: {Serial} - {Brand} {Color}", + request.SpoolSerial, request.Brand, request.ColorName); + + // Validate foreign keys exist + var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId); + if (materialBase is null) + return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." }); + + var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId); + if (materialFinish is null) + return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." }); + + if (request.MaterialModifierId.HasValue) + { + var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value); + if (modifier is null) + return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." }); + } + + // Validate serial uniqueness + var serialExists = await _dbContext.Spools + .AnyAsync(s => s.SpoolSerial == request.SpoolSerial); + if (serialExists) + return BadRequest(new { error = $"Filament with serial '{request.SpoolSerial}' already exists." }); + + // Validate remaining weight doesn't exceed total weight + if (request.WeightRemainingGrams > request.WeightTotalGrams) + return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." }); + + var entity = new Spool + { + MaterialBaseId = request.MaterialBaseId, + MaterialFinishId = request.MaterialFinishId, + MaterialModifierId = request.MaterialModifierId, + Brand = request.Brand, + ColorName = request.ColorName, + ColorHex = request.ColorHex, + WeightTotalGrams = request.WeightTotalGrams, + WeightRemainingGrams = request.WeightRemainingGrams, + FilamentDiameterMm = request.FilamentDiameterMm, + SpoolSerial = request.SpoolSerial, + PurchasePrice = request.PurchasePrice, + PurchaseDate = request.PurchaseDate, + IsActive = request.IsActive + }; + + _dbContext.Spools.Add(entity); + await _dbContext.SaveChangesAsync(); + + // Reload navigation properties for the response + await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync(); + await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync(); + if (entity.MaterialModifierId.HasValue) + await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync(); + + var response = MapToFilamentResponse(entity); + return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response); + } + + /// + /// Updates an existing filament spool. Validates foreign keys and serial uniqueness + /// (excluding the current spool from the uniqueness check). + /// WeightRemainingGrams must not exceed WeightTotalGrams. + /// + /// The unique identifier of the filament spool to update. + /// The filament update request with all required fields. + /// The updated filament spool with current timestamps. + /// Returns the updated filament spool. + /// If the filament spool with the given ID is not found. + /// If the request is invalid or foreign keys don't exist. + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> UpdateFilament( + Guid id, [FromBody] UpdateFilamentRequest request) + { + _logger.LogInformation("Updating filament {Id}", id); + + var entity = await _dbContext.Spools.FindAsync(id); + if (entity is null) + { + _logger.LogWarning("Filament {Id} not found for update", id); + return NotFound(new { error = $"Filament with ID '{id}' not found." }); + } + + // Validate foreign keys exist + var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId); + if (materialBase is null) + return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." }); + + var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId); + if (materialFinish is null) + return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." }); + + if (request.MaterialModifierId.HasValue) + { + var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value); + if (modifier is null) + return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." }); + } + + // Check serial uniqueness (excluding current spool) + var serialExists = await _dbContext.Spools + .AnyAsync(s => s.SpoolSerial == request.SpoolSerial && s.Id != id); + if (serialExists) + return BadRequest(new { error = $"Filament with serial '{request.SpoolSerial}' already exists." }); + + // Validate remaining weight doesn't exceed total weight + if (request.WeightRemainingGrams > request.WeightTotalGrams) + return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." }); + + entity.MaterialBaseId = request.MaterialBaseId; + entity.MaterialFinishId = request.MaterialFinishId; + entity.MaterialModifierId = request.MaterialModifierId; + entity.Brand = request.Brand; + entity.ColorName = request.ColorName; + entity.ColorHex = request.ColorHex; + entity.WeightTotalGrams = request.WeightTotalGrams; + entity.WeightRemainingGrams = request.WeightRemainingGrams; + entity.FilamentDiameterMm = request.FilamentDiameterMm; + entity.SpoolSerial = request.SpoolSerial; + entity.PurchasePrice = request.PurchasePrice; + entity.PurchaseDate = request.PurchaseDate; + entity.IsActive = request.IsActive; + + await _dbContext.SaveChangesAsync(); + + // Reload navigation properties + await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync(); + await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync(); + if (entity.MaterialModifierId.HasValue) + await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync(); + + return Ok(MapToFilamentResponse(entity)); + } + + // ── Mapping helper ───────────────────────────────────────── + + /// + /// Maps a Spool domain entity to a FilamentResponse DTO. + /// Denormalizes material names for display convenience. + /// Populates the QrCodeUrl for easy frontend access to the spool's QR code. + /// + /// The spool entity to map. + /// A FilamentResponse DTO with denormalized material names and QR code URL. + private static FilamentResponse MapToFilamentResponse(Spool s) => new() + { + Id = s.Id, + MaterialBaseId = s.MaterialBaseId, + MaterialBaseName = s.MaterialBase?.Name ?? string.Empty, + MaterialFinishId = s.MaterialFinishId, + MaterialFinishName = s.MaterialFinish?.Name ?? string.Empty, + MaterialModifierId = s.MaterialModifierId, + MaterialModifierName = s.MaterialModifier?.Name, + Brand = s.Brand, + ColorName = s.ColorName, + ColorHex = s.ColorHex, + WeightTotalGrams = s.WeightTotalGrams, + WeightRemainingGrams = s.WeightRemainingGrams, + FilamentDiameterMm = s.FilamentDiameterMm, + SpoolSerial = s.SpoolSerial, + PurchasePrice = s.PurchasePrice, + PurchaseDate = s.PurchaseDate, + IsActive = s.IsActive, + CreatedAt = s.CreatedAt, + UpdatedAt = s.UpdatedAt, + QrCodeUrl = $"/api/qr/spool/{s.Id}" + }; +} \ No newline at end of file diff --git a/backend/API/Controllers/MaterialBasesController.cs b/backend/API/Controllers/MaterialBasesController.cs new file mode 100644 index 0000000..2269547 --- /dev/null +++ b/backend/API/Controllers/MaterialBasesController.cs @@ -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; + +/// +/// Controller for managing material bases — the core polymer/material type +/// (e.g., PLA, PETG, ABS). Enforces consistent material naming across all spools. +/// +[ApiController] +[Route("api/material-bases")] +public class MaterialBasesController : ControllerBase +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + public MaterialBasesController( + ExtrudexDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Gets all material bases ordered by name. + /// + /// A list of all material bases with their densities. + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> GetMaterialBases() + { + _logger.LogDebug("Getting all material bases"); + + var bases = await _dbContext.MaterialBases + .OrderBy(mb => mb.Name) + .Select(mb => MapToResponse(mb)) + .ToListAsync(); + + return Ok(bases); + } + + /// + /// Gets a specific material base by ID. + /// + /// The unique identifier of the material base. + /// The material base details. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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)); + } + + /// + /// Creates a new material base. + /// + /// The material base creation request. + /// The created material base. + [HttpPost] + [ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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); + } + + /// + /// Updates an existing material base. + /// + /// The unique identifier of the material base to update. + /// The material base update request. + /// The updated material base. + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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)); + } + + /// + /// Deletes a material base. + /// + /// The unique identifier of the material base to delete. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 + }; +} \ No newline at end of file diff --git a/backend/API/Controllers/MaterialFinishesController.cs b/backend/API/Controllers/MaterialFinishesController.cs new file mode 100644 index 0000000..4474515 --- /dev/null +++ b/backend/API/Controllers/MaterialFinishesController.cs @@ -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; + +/// +/// Controller for managing material finishes — the surface finish descriptor +/// for a material. This is REQUIRED on every spool record. Default: "Basic". +/// +[ApiController] +[Route("api/material-finishes")] +public class MaterialFinishesController : ControllerBase +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + public MaterialFinishesController( + ExtrudexDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Gets all material finishes, optionally filtered by material base. + /// + /// Optional filter: return finishes for this material base only. + /// A list of material finishes ordered by base material name, then finish name. + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> 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); + } + + /// + /// Gets a specific material finish by ID. + /// + /// The unique identifier of the finish. + /// The material finish details. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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)); + } + + /// + /// Creates a new material finish. + /// + /// The material finish creation request. + /// The created material finish. + [HttpPost] + [ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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); + } + + /// + /// Updates an existing material finish. + /// + /// The unique identifier of the finish to update. + /// The material finish update request. + /// The updated material finish. + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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)); + } + + /// + /// Deletes a material finish. + /// + /// The unique identifier of the finish to delete. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 + }; +} \ No newline at end of file diff --git a/backend/API/Controllers/MaterialLookupsController.cs b/backend/API/Controllers/MaterialLookupsController.cs new file mode 100644 index 0000000..dc3f4a2 --- /dev/null +++ b/backend/API/Controllers/MaterialLookupsController.cs @@ -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; + +/// +/// Controller for querying material metadata — bases, finishes, and modifiers. +/// Provides lookup/reference data for the spool inventory system. +/// +[ApiController] +[Route("api/materials")] +public class MaterialLookupsController : ControllerBase +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + public MaterialLookupsController(ExtrudexDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + // ── MaterialBase ────────────────────────────────────────── + + /// + /// Gets all material bases (PLA, PETG, ABS, etc.). + /// + /// A list of all material bases with their densities. + [HttpGet("bases")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> 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); + } + + /// + /// Gets a specific material base by ID. + /// + /// The unique identifier of the material base. + /// The material base details. + [HttpGet("bases/{id:guid}")] + [ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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)); + } + + /// + /// Creates a new material base. + /// + /// The material base creation request. + /// The created material base. + [HttpPost("bases")] + [ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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); + } + + /// + /// Updates an existing material base. + /// + /// The unique identifier of the material base to update. + /// The material base update request. + /// The updated material base. + [HttpPut("bases/{id:guid}")] + [ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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)); + } + + /// + /// Deletes a material base. + /// + /// The unique identifier of the material base to delete. + [HttpDelete("bases/{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 ──────────────────────────────────────── + + /// + /// Gets all material finishes, optionally filtered by material base. + /// + /// Optional filter: return finishes for this material base only. + /// A list of material finishes. + [HttpGet("finishes")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> 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); + } + + /// + /// Gets a specific material finish by ID. + /// + /// The unique identifier of the finish. + /// The material finish details. + [HttpGet("finishes/{id:guid}")] + [ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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)); + } + + /// + /// Creates a new material finish. + /// + /// The material finish creation request. + /// The created material finish. + [HttpPost("finishes")] + [ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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); + } + + /// + /// Updates an existing material finish. + /// + /// The unique identifier of the finish to update. + /// The material finish update request. + /// The updated material finish. + [HttpPut("finishes/{id:guid}")] + [ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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)); + } + + /// + /// Deletes a material finish. + /// + /// The unique identifier of the finish to delete. + [HttpDelete("finishes/{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 ────────────────────────────────────── + + /// + /// Gets all material modifiers, optionally filtered by material base. + /// + /// Optional filter: return modifiers for this material base only. + /// A list of material modifiers. + [HttpGet("modifiers")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> 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); + } + + /// + /// Gets a specific material modifier by ID. + /// + /// The unique identifier of the modifier. + /// The material modifier details. + [HttpGet("modifiers/{id:guid}")] + [ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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)); + } + + /// + /// Creates a new material modifier. + /// + /// The material modifier creation request. + /// The created material modifier. + [HttpPost("modifiers")] + [ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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); + } + + /// + /// Updates an existing material modifier. + /// + /// The unique identifier of the modifier to update. + /// The material modifier update request. + /// The updated material modifier. + [HttpPut("modifiers/{id:guid}")] + [ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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)); + } + + /// + /// Deletes a material modifier. + /// + /// The unique identifier of the modifier to delete. + [HttpDelete("modifiers/{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 ───────────────────────── + + /// + /// Gets all material density data (shorthand for bases with density info). + /// + /// A list of material bases with their density values. + [HttpGet("densities")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> 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 + }; +} \ No newline at end of file diff --git a/backend/API/Controllers/MaterialModifiersController.cs b/backend/API/Controllers/MaterialModifiersController.cs new file mode 100644 index 0000000..57c7360 --- /dev/null +++ b/backend/API/Controllers/MaterialModifiersController.cs @@ -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; + +/// +/// 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. +/// +[ApiController] +[Route("api/material-modifiers")] +public class MaterialModifiersController : ControllerBase +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + public MaterialModifiersController( + ExtrudexDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Gets all material modifiers, optionally filtered by material base. + /// + /// Optional filter: return modifiers for this material base only. + /// A list of material modifiers ordered by base material name, then modifier name. + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> 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); + } + + /// + /// Gets a specific material modifier by ID. + /// + /// The unique identifier of the modifier. + /// The material modifier details. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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)); + } + + /// + /// Creates a new material modifier. + /// + /// The material modifier creation request. + /// The created material modifier. + [HttpPost] + [ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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); + } + + /// + /// Updates an existing material modifier. + /// + /// The unique identifier of the modifier to update. + /// The material modifier update request. + /// The updated material modifier. + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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)); + } + + /// + /// Deletes a material modifier. + /// + /// The unique identifier of the modifier to delete. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 + }; +} \ No newline at end of file diff --git a/backend/API/Controllers/PrintJobsController.cs b/backend/API/Controllers/PrintJobsController.cs new file mode 100644 index 0000000..226430e --- /dev/null +++ b/backend/API/Controllers/PrintJobsController.cs @@ -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; + +/// +/// 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). +/// +[ApiController] +[Route("api/printjobs")] +public class PrintJobsController : ControllerBase +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The database context for data access. + /// The logger for diagnostic output. + public PrintJobsController(ExtrudexDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + // ── GET /api/printjobs ──────────────────────────────────────── + + /// + /// 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. + /// + /// Optional query parameters for pagination and filtering. + /// A paginated list of print jobs matching the filter criteria. + /// Returns the paginated list of print jobs. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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(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 + { + Items = items, + TotalCount = totalCount, + PageNumber = pageNumber, + PageSize = pageSize + }; + + return Ok(response); + } + + // ── GET /api/printjobs/{id} ─────────────────────────────────── + + /// + /// Gets a specific print job by its unique identifier. + /// Includes denormalized printer name and spool serial for display. + /// + /// The unique identifier of the print job. + /// The print job details. + /// Returns the print job details. + /// If the print job with the given ID is not found. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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 ─────────────────────────────────────── + + /// + /// 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). + /// + /// The print job creation request with all required fields. + /// The newly created print job with generated ID and timestamps. + /// Returns the created print job with location header. + /// If the request is invalid or foreign keys don't exist. + [HttpPost] + [ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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(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} ──────────────────────────────────── + + /// + /// 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. + /// + /// The unique identifier of the print job to update. + /// The print job update request with all required fields. + /// The updated print job with current timestamps. + /// Returns the updated print job. + /// If the print job with the given ID is not found. + /// If the request is invalid or foreign keys don't exist. + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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 ────────────────────────── + + /// + /// 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) + /// + /// The unique identifier of the print job. + /// The status update request containing the new status. + /// The updated print job with the new status. + /// Returns the print job with updated status. + /// If the print job with the given ID is not found. + /// If the status transition is invalid. + [HttpPatch("{id:guid}/status")] + [ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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(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} ───────────────────────────────── + + /// + /// 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. + /// + /// The unique identifier of the print job to soft-delete. + /// No content on success. + /// The print job was successfully soft-deleted. + /// If the print job with the given ID is not found. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 ──────────────────────────────────── + + /// + /// 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³. + /// + /// Total millimeters of filament extruded. + /// Filament diameter in mm (typically 1.75mm). + /// Material density in g/cm³ (e.g., 1.24 for PLA). + /// Derived grams consumed, rounded to 2 decimal places. + 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 ─────────────────────────────── + + /// + /// Validates that a status transition is allowed by business rules. + /// Returns null if the transition is valid, or an error message if not. + /// + /// The current status of the print job. + /// The desired new status. + /// null if valid; an error message string if invalid. + 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 + }; +} \ No newline at end of file diff --git a/backend/API/Controllers/PrintersController.cs b/backend/API/Controllers/PrintersController.cs new file mode 100644 index 0000000..c73e0f2 --- /dev/null +++ b/backend/API/Controllers/PrintersController.cs @@ -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; + +/// +/// 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. +/// +[ApiController] +[Route("api/printers")] +public class PrintersController : ControllerBase +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The database context for data access. + /// The logger for diagnostic output. + public PrintersController(ExtrudexDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Gets all printers, optionally filtered by active status. + /// Results are ordered by printer name alphabetically. + /// + /// + /// Optional filter: true for active printers only, + /// false for inactive (soft-deleted) printers, + /// omit for all printers. + /// + /// A list of printers matching the filter criteria. + /// Returns the list of printers. + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> 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); + } + + /// + /// Gets a specific printer by its unique identifier. + /// + /// The unique identifier of the printer. + /// The full printer details including connection configuration. + /// Returns the printer details. + /// The printer was not found. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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)); + } + + /// + /// 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. + /// + /// The unique identifier of the printer. + /// The printer's current status, last-seen timestamp, and active flag. + /// Returns the printer status. + /// The printer was not found. + [HttpGet("{id:guid}/status")] + [ProducesResponseType(typeof(PrinterStatusResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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 + }); + } + + /// + /// 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. + /// + /// The printer registration request with connection details. + /// The newly created printer. + /// The printer was created successfully. + /// The request body is invalid or validation failed. + [HttpPost] + [ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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(request.PrinterType, true, out var printerType)) + { + return BadRequest(new { error = $"Invalid PrinterType: '{request.PrinterType}'. Must be 'Fdm' or 'Resin'." }); + } + + if (!Enum.TryParse(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); + } + + /// + /// 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. + /// + /// The unique identifier of the printer to update. + /// The printer update request with new values. + /// The updated printer. + /// The printer was updated successfully. + /// The request body is invalid or validation failed. + /// The printer was not found. + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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(request.PrinterType, true, out var printerType)) + { + return BadRequest(new { error = $"Invalid PrinterType: '{request.PrinterType}'. Must be 'Fdm' or 'Resin'." }); + } + + if (!Enum.TryParse(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)); + } + + /// + /// Soft-deletes a printer by setting IsActive to false. + /// The printer record is retained for historical and audit purposes. + /// Soft-deleted printers can be recovered via PUT with IsActive = true. + /// + /// The unique identifier of the printer to soft-delete. + /// No content on success. + /// The printer was soft-deleted successfully. + /// The printer was not found. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 ─────────────────────────────────────────────── + + /// + /// Maps a domain entity to a DTO. + /// + /// The printer entity to map. + /// A response DTO suitable for API output. + 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 + }; +} \ No newline at end of file diff --git a/backend/API/Controllers/QrController.cs b/backend/API/Controllers/QrController.cs new file mode 100644 index 0000000..f02894e --- /dev/null +++ b/backend/API/Controllers/QrController.cs @@ -0,0 +1,101 @@ +using Extrudex.Domain.Enums; +using Extrudex.Domain.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace Extrudex.API.Controllers; + +/// +/// 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. +/// +[ApiController] +[Route("api/qr")] +public class QrController : ControllerBase +{ + private readonly IQrCodeService _qrCodeService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The QR code generation service. + /// The logger for diagnostic output. + public QrController(IQrCodeService qrCodeService, ILogger logger) + { + _qrCodeService = qrCodeService; + _logger = logger; + } + + /// + /// 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 format query parameter is set to "svg". + /// + /// + /// The type of resource: spool, printer, or location. + /// + /// The unique identifier of the resource. + /// + /// Optional output format: png (default) or svg. + /// SVG is resolution-independent and ideal for printing at any scale. + /// + /// + /// Optional pixel density per QR module for PNG output (default: 20). + /// Ignored for SVG output. Range: 4–40. + /// + /// The QR code image in the requested format. + /// Returns the QR code image. + /// If the resource type is invalid or parameters are out of range. + [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(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"); + } +} \ No newline at end of file diff --git a/backend/API/Controllers/SpoolsController.cs b/backend/API/Controllers/SpoolsController.cs new file mode 100644 index 0000000..e270f2f --- /dev/null +++ b/backend/API/Controllers/SpoolsController.cs @@ -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; + +/// +/// Controller for managing filament spools — the core inventory unit of Extrudex. +/// Supports full CRUD with pagination, filtering by material and active status, +/// and soft-delete semantics (DELETE sets IsActive = false). +/// +[ApiController] +[Route("api/spools")] +public class SpoolsController : ControllerBase +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The database context for data access. + /// The logger for diagnostic output. + public SpoolsController(ExtrudexDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Gets a paginated list of spools with optional filtering by material and active status. + /// Results are ordered by creation date (newest first) and include denormalized material names. + /// + /// Optional query parameters for pagination and filtering. + /// A paginated list of spools matching the filter criteria. + /// Returns the paginated list of spools. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> GetSpools( + [FromQuery] SpoolQueryParameters query) + { + _logger.LogDebug( + "Getting spools: pageNumber={PageNumber}, pageSize={PageSize}, " + + "materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, isActive={IsActive}", + query.PageNumber, query.PageSize, query.MaterialBaseId, query.MaterialFinishId, query.IsActive); + + // Clamp pagination values + var pageNumber = Math.Max(1, query.PageNumber); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var spoolQuery = _dbContext.Spools + .Include(s => s.MaterialBase) + .Include(s => s.MaterialFinish) + .Include(s => s.MaterialModifier) + .AsQueryable(); + + // Apply filters + if (query.MaterialBaseId.HasValue) + spoolQuery = spoolQuery.Where(s => s.MaterialBaseId == query.MaterialBaseId.Value); + + if (query.MaterialFinishId.HasValue) + spoolQuery = spoolQuery.Where(s => s.MaterialFinishId == query.MaterialFinishId.Value); + + if (query.IsActive.HasValue) + spoolQuery = spoolQuery.Where(s => s.IsActive == query.IsActive.Value); + + var totalCount = await spoolQuery.CountAsync(); + + var items = await spoolQuery + .OrderByDescending(s => s.CreatedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .Select(s => MapToSpoolResponse(s)) + .ToListAsync(); + + var response = new PagedResponse + { + Items = items, + TotalCount = totalCount, + PageNumber = pageNumber, + PageSize = pageSize + }; + + return Ok(response); + } + + /// + /// Gets a specific spool by its unique identifier. + /// Includes denormalized material names for display. + /// + /// The unique identifier of the spool. + /// The spool details. + /// Returns the spool details. + /// If the spool with the given ID is not found. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetSpool(Guid id) + { + _logger.LogDebug("Getting spool {Id}", id); + + var spool = await _dbContext.Spools + .Include(s => s.MaterialBase) + .Include(s => s.MaterialFinish) + .Include(s => s.MaterialModifier) + .FirstOrDefaultAsync(s => s.Id == id); + + if (spool is null) + { + _logger.LogWarning("Spool {Id} not found", id); + return NotFound(new { error = $"Spool with ID '{id}' not found." }); + } + + return Ok(MapToSpoolResponse(spool)); + } + + /// + /// Creates a new spool record. Validates that all foreign keys reference existing entities + /// and that the SpoolSerial is unique across all spools. + /// + /// The spool creation request with all required fields. + /// The newly created spool with generated ID and timestamps. + /// Returns the created spool with location header. + /// If the request is invalid or foreign keys don't exist. + [HttpPost] + [ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateSpool([FromBody] CreateSpoolRequest request) + { + _logger.LogInformation("Creating spool: {Serial} - {Brand} {Color}", + request.SpoolSerial, request.Brand, request.ColorName); + + // Validate foreign keys exist + var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId); + if (materialBase is null) + return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." }); + + var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId); + if (materialFinish is null) + return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." }); + + if (request.MaterialModifierId.HasValue) + { + var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value); + if (modifier is null) + return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." }); + } + + // Validate serial uniqueness + var serialExists = await _dbContext.Spools + .AnyAsync(s => s.SpoolSerial == request.SpoolSerial); + if (serialExists) + return BadRequest(new { error = $"Spool with serial '{request.SpoolSerial}' already exists." }); + + // Validate remaining weight doesn't exceed total weight + if (request.WeightRemainingGrams > request.WeightTotalGrams) + return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." }); + + var entity = new Spool + { + MaterialBaseId = request.MaterialBaseId, + MaterialFinishId = request.MaterialFinishId, + MaterialModifierId = request.MaterialModifierId, + Brand = request.Brand, + ColorName = request.ColorName, + ColorHex = request.ColorHex, + WeightTotalGrams = request.WeightTotalGrams, + WeightRemainingGrams = request.WeightRemainingGrams, + FilamentDiameterMm = request.FilamentDiameterMm, + SpoolSerial = request.SpoolSerial, + PurchasePrice = request.PurchasePrice, + PurchaseDate = request.PurchaseDate, + IsActive = request.IsActive + }; + + _dbContext.Spools.Add(entity); + await _dbContext.SaveChangesAsync(); + + // Reload navigation properties for the response + await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync(); + await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync(); + if (entity.MaterialModifierId.HasValue) + await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync(); + + var response = MapToSpoolResponse(entity); + return CreatedAtAction(nameof(GetSpool), new { id = entity.Id }, response); + } + + /// + /// Updates an existing spool. Validates foreign keys and serial uniqueness + /// (excluding the current spool from the uniqueness check). + /// + /// The unique identifier of the spool to update. + /// The spool update request with all required fields. + /// The updated spool with current timestamps. + /// Returns the updated spool. + /// If the spool with the given ID is not found. + /// If the request is invalid or foreign keys don't exist. + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> UpdateSpool( + Guid id, [FromBody] UpdateSpoolRequest request) + { + _logger.LogInformation("Updating spool {Id}", id); + + var entity = await _dbContext.Spools.FindAsync(id); + if (entity is null) + { + _logger.LogWarning("Spool {Id} not found for update", id); + return NotFound(new { error = $"Spool with ID '{id}' not found." }); + } + + // Validate foreign keys exist + var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId); + if (materialBase is null) + return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." }); + + var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId); + if (materialFinish is null) + return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." }); + + if (request.MaterialModifierId.HasValue) + { + var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value); + if (modifier is null) + return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." }); + } + + // Check serial uniqueness (excluding current spool) + var serialExists = await _dbContext.Spools + .AnyAsync(s => s.SpoolSerial == request.SpoolSerial && s.Id != id); + if (serialExists) + return BadRequest(new { error = $"Spool with serial '{request.SpoolSerial}' already exists." }); + + // Validate remaining weight doesn't exceed total weight + if (request.WeightRemainingGrams > request.WeightTotalGrams) + return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." }); + + entity.MaterialBaseId = request.MaterialBaseId; + entity.MaterialFinishId = request.MaterialFinishId; + entity.MaterialModifierId = request.MaterialModifierId; + entity.Brand = request.Brand; + entity.ColorName = request.ColorName; + entity.ColorHex = request.ColorHex; + entity.WeightTotalGrams = request.WeightTotalGrams; + entity.WeightRemainingGrams = request.WeightRemainingGrams; + entity.FilamentDiameterMm = request.FilamentDiameterMm; + entity.SpoolSerial = request.SpoolSerial; + entity.PurchasePrice = request.PurchasePrice; + entity.PurchaseDate = request.PurchaseDate; + entity.IsActive = request.IsActive; + + await _dbContext.SaveChangesAsync(); + + // Reload navigation properties + await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync(); + await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync(); + if (entity.MaterialModifierId.HasValue) + await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync(); + + return Ok(MapToSpoolResponse(entity)); + } + + /// + /// Soft-deletes a spool by setting IsActive = false. The spool is retained + /// in the database for historical COGS and print job records. + /// This is NOT a hard delete — the data is preserved. + /// + /// The unique identifier of the spool to soft-delete. + /// No content on success. + /// The spool was successfully soft-deleted. + /// If the spool with the given ID is not found. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteSpool(Guid id) + { + _logger.LogInformation("Soft-deleting spool {Id}", id); + + var entity = await _dbContext.Spools.FindAsync(id); + if (entity is null) + { + _logger.LogWarning("Spool {Id} not found for soft-delete", id); + return NotFound(new { error = $"Spool with ID '{id}' not found." }); + } + + if (!entity.IsActive) + { + _logger.LogDebug("Spool {Id} is already inactive — idempotent no-op", id); + return NoContent(); + } + + entity.IsActive = false; + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Spool {Id} soft-deleted (IsActive = false)", id); + return NoContent(); + } + + // ── Mapping helper ───────────────────────────────────────── + + private static SpoolResponse MapToSpoolResponse(Spool s) => new() + { + Id = s.Id, + MaterialBaseId = s.MaterialBaseId, + MaterialBaseName = s.MaterialBase?.Name ?? string.Empty, + MaterialFinishId = s.MaterialFinishId, + MaterialFinishName = s.MaterialFinish?.Name ?? string.Empty, + MaterialModifierId = s.MaterialModifierId, + MaterialModifierName = s.MaterialModifier?.Name, + Brand = s.Brand, + ColorName = s.ColorName, + ColorHex = s.ColorHex, + WeightTotalGrams = s.WeightTotalGrams, + WeightRemainingGrams = s.WeightRemainingGrams, + FilamentDiameterMm = s.FilamentDiameterMm, + SpoolSerial = s.SpoolSerial, + PurchasePrice = s.PurchasePrice, + PurchaseDate = s.PurchaseDate, + IsActive = s.IsActive, + CreatedAt = s.CreatedAt, + UpdatedAt = s.UpdatedAt + }; +} \ No newline at end of file diff --git a/backend/API/DTOs/Filaments/FilamentDtos.cs b/backend/API/DTOs/Filaments/FilamentDtos.cs new file mode 100644 index 0000000..3b1b91b --- /dev/null +++ b/backend/API/DTOs/Filaments/FilamentDtos.cs @@ -0,0 +1,199 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.Filaments; + +/// +/// Response DTO for a filament spool — the core inventory unit of Extrudex. +/// Contains all spool details including denormalized material names for display. +/// +public class FilamentResponse +{ + /// Unique identifier for the filament spool. + public Guid Id { get; set; } + + /// Foreign key to the base material. + public Guid MaterialBaseId { get; set; } + + /// Name of the base material (e.g., "PLA", "PETG"). + public string MaterialBaseName { get; set; } = string.Empty; + + /// Foreign key to the material finish. + public Guid MaterialFinishId { get; set; } + + /// Name of the material finish (e.g., "Basic", "Matte"). + public string MaterialFinishName { get; set; } = string.Empty; + + /// Foreign key to the optional material modifier. Null if none. + public Guid? MaterialModifierId { get; set; } + + /// Name of the material modifier (e.g., "Carbon Fiber"). Null if none. + public string? MaterialModifierName { get; set; } + + /// Brand name (e.g., "Bambu Lab", "Polymaker"). + public string Brand { get; set; } = string.Empty; + + /// Human-readable color name (e.g., "Fire Engine Red"). + public string ColorName { get; set; } = string.Empty; + + /// Hex color code (e.g., "#FF0000"). + public string ColorHex { get; set; } = string.Empty; + + /// Total spool weight in grams when full. + public decimal WeightTotalGrams { get; set; } + + /// Current remaining weight in grams. + public decimal WeightRemainingGrams { get; set; } + + /// Filament diameter in millimeters. Typically 1.75mm. + public decimal FilamentDiameterMm { get; set; } + + /// Manufacturer-assigned serial number. Must be unique. + public string SpoolSerial { get; set; } = string.Empty; + + /// Purchase price per spool. Null if not tracked. + public decimal? PurchasePrice { get; set; } + + /// Date the spool was purchased or received. + public DateTime? PurchaseDate { get; set; } + + /// Whether the spool is currently active and available. + public bool IsActive { get; set; } + + /// Timestamp when this record was created (UTC). + public DateTime CreatedAt { get; set; } + + /// Timestamp when this record was last updated (UTC). + public DateTime UpdatedAt { get; set; } + + /// + /// URL to the QR code image for this spool. + /// Encodes a deep link to the spool's detail page. + /// + public string QrCodeUrl { get; set; } = string.Empty; +} + +/// +/// Request DTO for creating a new filament spool. +/// All required fields must be provided. MaterialFinish is required — use "Basic" as the default. +/// +public class CreateFilamentRequest +{ + /// Foreign key to the base material. Required. + [Required(ErrorMessage = "MaterialBaseId is required.")] + public Guid MaterialBaseId { get; set; } + + /// Foreign key to the material finish. Required — default is "Basic". + [Required(ErrorMessage = "MaterialFinishId is required.")] + public Guid MaterialFinishId { get; set; } + + /// Foreign key to the optional material modifier. Null if none applies. + public Guid? MaterialModifierId { get; set; } + + /// Brand name (e.g., "Bambu Lab", "Polymaker"). Required, max 200 characters. + [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; + + /// Human-readable color name (e.g., "Fire Engine Red"). Required, max 200 characters. + [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; + + /// Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex. + [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; + + /// Total spool weight in grams when full. Must be greater than zero. + [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; } + + /// Current remaining weight in grams. Must be non-negative. + [Required(ErrorMessage = "WeightRemainingGrams is required.")] + [Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")] + public decimal WeightRemainingGrams { get; set; } + + /// Filament diameter in mm. Defaults to 1.75. Must be greater than zero. + [Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")] + public decimal FilamentDiameterMm { get; set; } = 1.75m; + + /// Manufacturer-assigned serial number. Must be unique, max 200 characters. + [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; + + /// Optional purchase price per spool. Must be non-negative if provided. + [Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")] + public decimal? PurchasePrice { get; set; } + + /// Optional purchase date. Must be a valid date if provided. + public DateTime? PurchaseDate { get; set; } + + /// Whether the spool is active. Defaults to true. + public bool IsActive { get; set; } = true; +} + +/// +/// Request DTO for updating an existing filament spool. +/// All required fields must be provided for a full update. +/// +public class UpdateFilamentRequest +{ + /// Foreign key to the base material. Required. + [Required(ErrorMessage = "MaterialBaseId is required.")] + public Guid MaterialBaseId { get; set; } + + /// Foreign key to the material finish. Required. + [Required(ErrorMessage = "MaterialFinishId is required.")] + public Guid MaterialFinishId { get; set; } + + /// Foreign key to the optional material modifier. Null if none applies. + public Guid? MaterialModifierId { get; set; } + + /// Brand name. Required, max 200 characters. + [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; + + /// Human-readable color name. Required, max 200 characters. + [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; + + /// Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex. + [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; + + /// Total spool weight in grams when full. Must be greater than zero. + [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; } + + /// Current remaining weight in grams. Must be non-negative. + [Required(ErrorMessage = "WeightRemainingGrams is required.")] + [Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")] + public decimal WeightRemainingGrams { get; set; } + + /// Filament diameter in mm. Must be greater than zero. + [Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")] + public decimal FilamentDiameterMm { get; set; } = 1.75m; + + /// Manufacturer-assigned serial number. Must be unique, max 200 characters. + [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; + + /// Optional purchase price per spool. Must be non-negative if provided. + [Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")] + public decimal? PurchasePrice { get; set; } + + /// Optional purchase date. + public DateTime? PurchaseDate { get; set; } + + /// Whether the spool is active. + public bool IsActive { get; set; } = true; +} \ No newline at end of file diff --git a/backend/API/DTOs/Filaments/FilamentQueryDtos.cs b/backend/API/DTOs/Filaments/FilamentQueryDtos.cs new file mode 100644 index 0000000..98c9bd6 --- /dev/null +++ b/backend/API/DTOs/Filaments/FilamentQueryDtos.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.Filaments; + +/// +/// Query parameters for filtering and paginating the filament list endpoint. +/// All parameters are optional — defaults are applied when not provided. +/// +public class FilamentQueryParameters +{ + /// Page number (1-based). Defaults to 1. + [Range(1, int.MaxValue, ErrorMessage = "PageNumber must be at least 1.")] + public int PageNumber { get; set; } = 1; + + /// Number of items per page. Defaults to 20, max 100. + [Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")] + public int PageSize { get; set; } = 20; + + /// Optional filter by material base ID. + public Guid? MaterialBaseId { get; set; } + + /// Optional filter by material finish ID. + public Guid? MaterialFinishId { get; set; } + + /// Optional filter by material modifier ID. + public Guid? MaterialModifierId { get; set; } + + /// Optional filter by brand name (case-insensitive partial match). + public string? Brand { get; set; } + + /// Optional filter by active status. True = active only, False = inactive only. + public bool? IsActive { get; set; } +} \ No newline at end of file diff --git a/backend/API/DTOs/Materials/MaterialBaseDtos.cs b/backend/API/DTOs/Materials/MaterialBaseDtos.cs new file mode 100644 index 0000000..467bae6 --- /dev/null +++ b/backend/API/DTOs/Materials/MaterialBaseDtos.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.Materials; + +/// +/// Response DTO for MaterialBase entity. +/// +public class MaterialBaseResponse +{ + /// Unique identifier for the material base. + public Guid Id { get; set; } + + /// Human-readable name (e.g., "PLA", "PETG", "ABS"). + public string Name { get; set; } = string.Empty; + + /// Density in g/cm³ used for grams-derived calculations. + public decimal DensityGperCm3 { get; set; } + + /// Timestamp when this record was created (UTC). + public DateTime CreatedAt { get; set; } + + /// Timestamp when this record was last updated (UTC). + public DateTime UpdatedAt { get; set; } +} + +/// +/// Request DTO for creating a new MaterialBase. +/// +public class CreateMaterialBaseRequest +{ + /// Human-readable name (e.g., "PLA", "PETG", "ABS"). Required, max 50 characters. + [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; + + /// Density in g/cm³. Must be greater than zero. + [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; } +} + +/// +/// Request DTO for updating an existing MaterialBase. +/// +public class UpdateMaterialBaseRequest +{ + /// Human-readable name (e.g., "PLA", "PETG", "ABS"). Required, max 50 characters. + [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; + + /// Density in g/cm³. Must be greater than zero. + [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; } +} \ No newline at end of file diff --git a/backend/API/DTOs/Materials/MaterialFinishDtos.cs b/backend/API/DTOs/Materials/MaterialFinishDtos.cs new file mode 100644 index 0000000..9746704 --- /dev/null +++ b/backend/API/DTOs/Materials/MaterialFinishDtos.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.Materials; + +/// +/// Response DTO for MaterialFinish entity. +/// +public class MaterialFinishResponse +{ + /// Unique identifier for the finish. + public Guid Id { get; set; } + + /// Human-readable name (e.g., "Basic", "Matte", "Silk"). + public string Name { get; set; } = string.Empty; + + /// Foreign key to the parent MaterialBase. + public Guid MaterialBaseId { get; set; } + + /// Name of the parent material base (for display). + public string MaterialBaseName { get; set; } = string.Empty; + + /// Timestamp when this record was created (UTC). + public DateTime CreatedAt { get; set; } + + /// Timestamp when this record was last updated (UTC). + public DateTime UpdatedAt { get; set; } +} + +/// +/// Request DTO for creating a new MaterialFinish. +/// +public class CreateMaterialFinishRequest +{ + /// Human-readable name (e.g., "Basic", "Matte", "Silk"). Required, max 50 characters. + [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; + + /// Foreign key to the parent MaterialBase. Required. + [Required(ErrorMessage = "MaterialBaseId is required.")] + public Guid MaterialBaseId { get; set; } +} + +/// +/// Request DTO for updating an existing MaterialFinish. +/// +public class UpdateMaterialFinishRequest +{ + /// Human-readable name (e.g., "Basic", "Matte", "Silk"). Required, max 50 characters. + [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; + + /// Foreign key to the parent MaterialBase. Required. + [Required(ErrorMessage = "MaterialBaseId is required.")] + public Guid MaterialBaseId { get; set; } +} \ No newline at end of file diff --git a/backend/API/DTOs/Materials/MaterialModifierDtos.cs b/backend/API/DTOs/Materials/MaterialModifierDtos.cs new file mode 100644 index 0000000..59ee85d --- /dev/null +++ b/backend/API/DTOs/Materials/MaterialModifierDtos.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.Materials; + +/// +/// Response DTO for MaterialModifier entity. +/// +public class MaterialModifierResponse +{ + /// Unique identifier for the modifier. + public Guid Id { get; set; } + + /// Human-readable name (e.g., "Carbon Fiber", "Wood Fill"). + public string Name { get; set; } = string.Empty; + + /// Foreign key to the parent MaterialBase. + public Guid MaterialBaseId { get; set; } + + /// Name of the parent material base (for display). + public string MaterialBaseName { get; set; } = string.Empty; + + /// Timestamp when this record was created (UTC). + public DateTime CreatedAt { get; set; } + + /// Timestamp when this record was last updated (UTC). + public DateTime UpdatedAt { get; set; } +} + +/// +/// Request DTO for creating a new MaterialModifier. +/// +public class CreateMaterialModifierRequest +{ + /// Human-readable name (e.g., "Carbon Fiber", "Wood Fill"). Required, max 50 characters. + [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; + + /// Foreign key to the parent MaterialBase. Required. + [Required(ErrorMessage = "MaterialBaseId is required.")] + public Guid MaterialBaseId { get; set; } +} + +/// +/// Request DTO for updating an existing MaterialModifier. +/// +public class UpdateMaterialModifierRequest +{ + /// Human-readable name (e.g., "Carbon Fiber", "Wood Fill"). Required, max 50 characters. + [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; + + /// Foreign key to the parent MaterialBase. Required. + [Required(ErrorMessage = "MaterialBaseId is required.")] + public Guid MaterialBaseId { get; set; } +} \ No newline at end of file diff --git a/backend/API/DTOs/PagedResponse.cs b/backend/API/DTOs/PagedResponse.cs new file mode 100644 index 0000000..29cb236 --- /dev/null +++ b/backend/API/DTOs/PagedResponse.cs @@ -0,0 +1,30 @@ +namespace Extrudex.API.DTOs; + +/// +/// Generic paginated response wrapper for list endpoints. +/// Provides pagination metadata alongside the result items. +/// +/// The type of items in the page. +public class PagedResponse +{ + /// The items in the current page. + public IReadOnlyList Items { get; set; } = []; + + /// Total number of items across all pages. + public int TotalCount { get; set; } + + /// Current page number (1-based). + public int PageNumber { get; set; } + + /// Number of items per page. + public int PageSize { get; set; } + + /// Total number of pages. + public int TotalPages => PageSize > 0 ? (int)Math.Ceiling(TotalCount / (double)PageSize) : 0; + + /// Whether there is a next page. + public bool HasNextPage => PageNumber < TotalPages; + + /// Whether there is a previous page. + public bool HasPreviousPage => PageNumber > 1; +} \ No newline at end of file diff --git a/backend/API/DTOs/PrintJobs/PrintJobDtos.cs b/backend/API/DTOs/PrintJobs/PrintJobDtos.cs new file mode 100644 index 0000000..9ba7fbf --- /dev/null +++ b/backend/API/DTOs/PrintJobs/PrintJobDtos.cs @@ -0,0 +1,223 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.PrintJobs; + +/// +/// 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. +/// +public class PrintJobResponse +{ + /// Unique identifier for the print job. + public Guid Id { get; set; } + + /// Foreign key to the printer that executed this job. + public Guid PrinterId { get; set; } + + /// Name of the printer that executed this job. + public string PrinterName { get; set; } = string.Empty; + + /// Foreign key to the spool that provided filament. + public Guid SpoolId { get; set; } + + /// Serial number of the spool that provided filament. + public string SpoolSerial { get; set; } = string.Empty; + + /// Human-readable name or identifier for the print job. + public string PrintName { get; set; } = string.Empty; + + /// Path or filename of the G-code file. + public string? GcodeFilePath { get; set; } + + /// Total millimeters of filament extruded during this print. + public decimal MmExtruded { get; set; } + + /// + /// 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. + /// + public decimal GramsDerived { get; set; } + + /// Calculated cost of goods sold (COGS) for this print job. + public decimal? CostPerPrint { get; set; } + + /// Timestamp when the print job started (UTC). + public DateTime? StartedAt { get; set; } + + /// Timestamp when the print job completed or failed (UTC). + public DateTime? CompletedAt { get; set; } + + /// Current status of the print job (Queued, Printing, Completed, Cancelled, Failed). + public string Status { get; set; } = string.Empty; + + /// Data source that provided this job (Mqtt, Moonraker, Manual). + public string DataSource { get; set; } = string.Empty; + + /// Audit snapshot: filament diameter (mm) recorded at time of print. + public decimal FilamentDiameterAtPrintMm { get; set; } + + /// Audit snapshot: material density (g/cm³) recorded at time of print. + public decimal MaterialDensityAtPrint { get; set; } + + /// Optional notes about the print job. + public string? Notes { get; set; } + + /// Timestamp when this record was created (UTC). + public DateTime CreatedAt { get; set; } + + /// Timestamp when this record was last updated (UTC). + public DateTime UpdatedAt { get; set; } +} + +/// +/// 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. +/// +public class CreatePrintJobRequest +{ + /// Foreign key to the printer that will execute this job. Required. + [Required(ErrorMessage = "PrinterId is required.")] + public Guid PrinterId { get; set; } + + /// Foreign key to the spool providing filament. Required. + [Required(ErrorMessage = "SpoolId is required.")] + public Guid SpoolId { get; set; } + + /// Human-readable name for the print job. Required, max 200 characters. + [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; + + /// Optional path or filename of the G-code file. Max 500 characters. + [StringLength(500, ErrorMessage = "GcodeFilePath must not exceed 500 characters.")] + public string? GcodeFilePath { get; set; } + + /// Total millimeters of filament extruded. Must be non-negative. Defaults to 0. + [Range(0, double.MaxValue, ErrorMessage = "MmExtruded must be non-negative.")] + public decimal MmExtruded { get; set; } + + /// + /// 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. + /// + [Range(0, double.MaxValue, ErrorMessage = "GramsDerived must be non-negative.")] + public decimal GramsDerived { get; set; } + + /// Optional calculated COGS. Must be non-negative if provided. + [Range(0, double.MaxValue, ErrorMessage = "CostPerPrint must be non-negative.")] + public decimal? CostPerPrint { get; set; } + + /// Optional timestamp when the job started (UTC). + public DateTime? StartedAt { get; set; } + + /// + /// Data source for this job. Must be "Mqtt", "Moonraker", or "Manual". + /// Defaults to "Manual". + /// + [Required(ErrorMessage = "DataSource is required.")] + [RegularExpression("^(Mqtt|Moonraker|Manual)$", ErrorMessage = "DataSource must be 'Mqtt', 'Moonraker', or 'Manual'.")] + public string DataSource { get; set; } = "Manual"; + + /// + /// 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. + /// + [Range(0.01, 100, ErrorMessage = "FilamentDiameterAtPrintMm must be between 0.01 and 100 mm.")] + public decimal FilamentDiameterAtPrintMm { get; set; } = 1.75m; + + /// + /// 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. + /// + [Range(0.001, 100, ErrorMessage = "MaterialDensityAtPrint must be between 0.001 and 100 g/cm³.")] + public decimal MaterialDensityAtPrint { get; set; } + + /// Optional notes about the print job. Max 2000 characters. + [StringLength(2000, ErrorMessage = "Notes must not exceed 2000 characters.")] + public string? Notes { get; set; } + + /// + /// When true, the server auto-derives GramsDerived, FilamentDiameterAtPrintMm, + /// and MaterialDensityAtPrint from the spool's material data. + /// MmExtruded must still be provided. Overrides manual GramsDerived. + /// + public bool AutoDeriveGrams { get; set; } +} + +/// +/// 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. +/// +public class UpdatePrintJobRequest +{ + /// Foreign key to the printer. Required. + [Required(ErrorMessage = "PrinterId is required.")] + public Guid PrinterId { get; set; } + + /// Foreign key to the spool. Required. + [Required(ErrorMessage = "SpoolId is required.")] + public Guid SpoolId { get; set; } + + /// Human-readable name for the print job. Required, max 200 characters. + [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; + + /// Optional path or filename of the G-code file. Max 500 characters. + [StringLength(500, ErrorMessage = "GcodeFilePath must not exceed 500 characters.")] + public string? GcodeFilePath { get; set; } + + /// Total millimeters of filament extruded. Must be non-negative. + [Range(0, double.MaxValue, ErrorMessage = "MmExtruded must be non-negative.")] + public decimal MmExtruded { get; set; } + + /// + /// Derived grams consumed. If AutoDeriveGrams is true, this is recomputed + /// server-side and the provided value is ignored. + /// + [Range(0, double.MaxValue, ErrorMessage = "GramsDerived must be non-negative.")] + public decimal GramsDerived { get; set; } + + /// Optional calculated COGS. Must be non-negative if provided. + [Range(0, double.MaxValue, ErrorMessage = "CostPerPrint must be non-negative.")] + public decimal? CostPerPrint { get; set; } + + /// Optional notes about the print job. Max 2000 characters. + [StringLength(2000, ErrorMessage = "Notes must not exceed 2000 characters.")] + public string? Notes { get; set; } + + /// + /// When true, the server recomputes GramsDerived, FilamentDiameterAtPrintMm, + /// and MaterialDensityAtPrint from the spool's current material data. + /// MmExtruded must still be provided. + /// + public bool AutoDeriveGrams { get; set; } +} + +/// +/// 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. +/// +public class UpdatePrintJobStatusRequest +{ + /// + /// New status for the print job. Must be one of: Queued, Printing, Completed, Cancelled, Failed. + /// Case-insensitive. + /// + [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; +} \ No newline at end of file diff --git a/backend/API/DTOs/PrintJobs/PrintJobQueryDtos.cs b/backend/API/DTOs/PrintJobs/PrintJobQueryDtos.cs new file mode 100644 index 0000000..af37fe5 --- /dev/null +++ b/backend/API/DTOs/PrintJobs/PrintJobQueryDtos.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.PrintJobs; + +/// +/// Query parameters for filtering and paginating the print job list endpoint. +/// All parameters are optional — defaults are applied when not provided. +/// +public class PrintJobQueryParameters +{ + /// Page number (1-based). Defaults to 1. + [Range(1, int.MaxValue, ErrorMessage = "PageNumber must be at least 1.")] + public int PageNumber { get; set; } = 1; + + /// Number of items per page. Defaults to 20, max 100. + [Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")] + public int PageSize { get; set; } = 20; + + /// Optional filter by printer ID. Only returns jobs for this printer. + public Guid? PrinterId { get; set; } + + /// Optional filter by spool ID. Only returns jobs that used this spool. + public Guid? SpoolId { get; set; } + + /// + /// Optional filter by job status. Must be a valid JobStatus value + /// (Queued, Printing, Completed, Cancelled, Failed). Case-insensitive. + /// + public string? Status { get; set; } +} \ No newline at end of file diff --git a/backend/API/DTOs/Printers/PrinterDtos.cs b/backend/API/DTOs/Printers/PrinterDtos.cs new file mode 100644 index 0000000..3f00cb5 --- /dev/null +++ b/backend/API/DTOs/Printers/PrinterDtos.cs @@ -0,0 +1,190 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.Printers; + +/// +/// Response DTO for a Printer entity. Contains all printer details +/// including connection configuration and operational status. +/// +public class PrinterResponse +{ + /// Unique identifier for the printer. + public Guid Id { get; set; } + + /// Current operational status (Idle, Printing, Offline, Error, Paused). + public string Status { get; set; } = string.Empty; + + /// Human-readable name (e.g., "Bambu X1C #1", "Elegoo Centauri"). + public string Name { get; set; } = string.Empty; + + /// Manufacturer/brand (e.g., "Bambu Lab", "Elegoo"). + public string Manufacturer { get; set; } = string.Empty; + + /// Model name (e.g., "X1 Carbon", "Centauri Carbon"). + public string Model { get; set; } = string.Empty; + + /// Printer hardware type ("Fdm" or "Resin"). + public string PrinterType { get; set; } = string.Empty; + + /// Connectivity protocol ("Mqtt" or "Moonraker"). + public string ConnectionType { get; set; } = string.Empty; + + /// Hostname or IP address for printer connection. + public string HostnameOrIp { get; set; } = string.Empty; + + /// Port number for the connection (8883 for MQTT/TLS, 7125 for Moonraker). + public int Port { get; set; } + + /// Whether the printer is currently active and available for jobs. + public bool IsActive { get; set; } + + /// Timestamp of the last status update received from the printer (UTC). + public DateTime? LastSeenAt { get; set; } + + /// Timestamp when this record was created (UTC). + public DateTime CreatedAt { get; set; } + + /// Timestamp when this record was last modified (UTC). + public DateTime UpdatedAt { get; set; } +} + +/// +/// Lightweight response DTO for printer status. Optimized for polling +/// and dashboard displays. For real-time updates, use the SignalR PrinterHub. +/// +public class PrinterStatusResponse +{ + /// Unique identifier for the printer. + public Guid Id { get; set; } + + /// Human-readable name of the printer. + public string Name { get; set; } = string.Empty; + + /// Current operational status (Idle, Printing, Offline, Error, Paused). + public string Status { get; set; } = string.Empty; + + /// Timestamp of the last status update received from the printer (UTC). + public DateTime? LastSeenAt { get; set; } + + /// Whether the printer is currently active and available for jobs. + public bool IsActive { get; set; } +} + +/// +/// Request DTO for registering a new printer in the fleet. +/// All string enums accept: PrinterType = "Fdm"|"Resin", +/// ConnectionType = "Mqtt"|"Moonraker" (case-insensitive). +/// +public class CreatePrinterRequest +{ + /// Human-readable name for the printer. Required, max 100 characters. + [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; + + /// Manufacturer/brand (e.g., "Bambu Lab", "Elegoo"). Required, max 50 characters. + [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; + + /// Model name (e.g., "X1 Carbon", "Centauri Carbon"). Required, max 50 characters. + [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; + + /// Printer hardware type: "Fdm" or "Resin". Defaults to "Fdm". + [Required(ErrorMessage = "PrinterType is required.")] + [RegularExpression("^(Fdm|Resin)$", ErrorMessage = "PrinterType must be 'Fdm' or 'Resin'.")] + public string PrinterType { get; set; } = "Fdm"; + + /// Connectivity protocol: "Mqtt" or "Moonraker". Defaults to "Mqtt". + [Required(ErrorMessage = "ConnectionType is required.")] + [RegularExpression("^(Mqtt|Moonraker)$", ErrorMessage = "ConnectionType must be 'Mqtt' or 'Moonraker'.")] + public string ConnectionType { get; set; } = "Mqtt"; + + /// Hostname or IP address for printer connection. Required, max 253 characters. + [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; + + /// Port number. Defaults: 8883 (MQTT/TLS), 7125 (Moonraker) if zero. + [Range(0, 65535, ErrorMessage = "Port must be between 0 and 65535.")] + public int Port { get; set; } + + /// MQTT username for Bambu Lab authentication. Used only for MQTT connection type. + [StringLength(100, ErrorMessage = "MqttUsername must be at most 100 characters.")] + public string MqttUsername { get; set; } = string.Empty; + + /// MQTT password for Bambu Lab authentication. Used only for MQTT connection type. + [StringLength(200, ErrorMessage = "MqttPassword must be at most 200 characters.")] + public string MqttPassword { get; set; } = string.Empty; + + /// Whether to use TLS for MQTT. Bambu Lab printers require TLS on port 8883. + public bool MqttUseTls { get; set; } + + /// Moonraker API key for Elegoo Centauri Carbon. Used only for Moonraker connection type. + [StringLength(100, ErrorMessage = "ApiKey must be at most 100 characters.")] + public string ApiKey { get; set; } = string.Empty; + + /// Whether the printer is active and available for jobs. Defaults to true. + public bool IsActive { get; set; } = true; +} + +/// +/// Request DTO for updating an existing printer's configuration and connection info. +/// All fields are provided on update (full replacement semantics). +/// +public class UpdatePrinterRequest +{ + /// Human-readable name for the printer. Required, max 100 characters. + [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; + + /// Manufacturer/brand. Required, max 50 characters. + [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; + + /// Model name. Required, max 50 characters. + [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; + + /// Printer hardware type: "Fdm" or "Resin". + [Required(ErrorMessage = "PrinterType is required.")] + [RegularExpression("^(Fdm|Resin)$", ErrorMessage = "PrinterType must be 'Fdm' or 'Resin'.")] + public string PrinterType { get; set; } = "Fdm"; + + /// Connectivity protocol: "Mqtt" or "Moonraker". + [Required(ErrorMessage = "ConnectionType is required.")] + [RegularExpression("^(Mqtt|Moonraker)$", ErrorMessage = "ConnectionType must be 'Mqtt' or 'Moonraker'.")] + public string ConnectionType { get; set; } = "Mqtt"; + + /// Hostname or IP address. Required, max 253 characters. + [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; + + /// Port number. Defaults: 8883 (MQTT/TLS), 7125 (Moonraker) if zero. + [Range(0, 65535, ErrorMessage = "Port must be between 0 and 65535.")] + public int Port { get; set; } + + /// MQTT username. Used only for MQTT connection type. + [StringLength(100, ErrorMessage = "MqttUsername must be at most 100 characters.")] + public string MqttUsername { get; set; } = string.Empty; + + /// MQTT password. Used only for MQTT connection type. + [StringLength(200, ErrorMessage = "MqttPassword must be at most 200 characters.")] + public string MqttPassword { get; set; } = string.Empty; + + /// Whether to use TLS for MQTT. + public bool MqttUseTls { get; set; } + + /// Moonraker API key. Used only for Moonraker connection type. + [StringLength(100, ErrorMessage = "ApiKey must be at most 100 characters.")] + public string ApiKey { get; set; } = string.Empty; + + /// Whether the printer is active and available for jobs. + public bool IsActive { get; set; } = true; +} \ No newline at end of file diff --git a/backend/API/DTOs/Spools/SpoolDtos.cs b/backend/API/DTOs/Spools/SpoolDtos.cs new file mode 100644 index 0000000..19e9f70 --- /dev/null +++ b/backend/API/DTOs/Spools/SpoolDtos.cs @@ -0,0 +1,193 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.Spools; + +/// +/// Response DTO for Spool entity — the core inventory unit of Extrudex. +/// Contains all spool details including denormalized material names for display. +/// +public class SpoolResponse +{ + /// Unique identifier for the spool. + public Guid Id { get; set; } + + /// Foreign key to the base material. + public Guid MaterialBaseId { get; set; } + + /// Name of the base material (e.g., "PLA", "PETG"). + public string MaterialBaseName { get; set; } = string.Empty; + + /// Foreign key to the material finish. + public Guid MaterialFinishId { get; set; } + + /// Name of the material finish (e.g., "Basic", "Matte"). + public string MaterialFinishName { get; set; } = string.Empty; + + /// Foreign key to the optional material modifier. Null if none. + public Guid? MaterialModifierId { get; set; } + + /// Name of the material modifier (e.g., "Carbon Fiber"). Null if none. + public string? MaterialModifierName { get; set; } + + /// Brand name (e.g., "Bambu Lab", "Polymaker"). + public string Brand { get; set; } = string.Empty; + + /// Human-readable color name (e.g., "Fire Engine Red"). + public string ColorName { get; set; } = string.Empty; + + /// Hex color code (e.g., "#FF0000"). + public string ColorHex { get; set; } = string.Empty; + + /// Total spool weight in grams when full. + public decimal WeightTotalGrams { get; set; } + + /// Current remaining weight in grams. + public decimal WeightRemainingGrams { get; set; } + + /// Filament diameter in millimeters. Typically 1.75mm. + public decimal FilamentDiameterMm { get; set; } + + /// Manufacturer-assigned serial number. Must be unique. + public string SpoolSerial { get; set; } = string.Empty; + + /// Purchase price per spool. Null if not tracked. + public decimal? PurchasePrice { get; set; } + + /// Date the spool was purchased or received. + public DateTime? PurchaseDate { get; set; } + + /// Whether the spool is currently active and available. + public bool IsActive { get; set; } + + /// Timestamp when this record was created (UTC). + public DateTime CreatedAt { get; set; } + + /// Timestamp when this record was last updated (UTC). + public DateTime UpdatedAt { get; set; } +} + +/// +/// Request DTO for creating a new spool. +/// All required fields must be provided. MaterialFinish is required — use "Basic" as the default. +/// +public class CreateSpoolRequest +{ + /// Foreign key to the base material. Required. + [Required(ErrorMessage = "MaterialBaseId is required.")] + public Guid MaterialBaseId { get; set; } + + /// Foreign key to the material finish. Required — default is "Basic". + [Required(ErrorMessage = "MaterialFinishId is required.")] + public Guid MaterialFinishId { get; set; } + + /// Foreign key to the optional material modifier. Null if none applies. + public Guid? MaterialModifierId { get; set; } + + /// Brand name (e.g., "Bambu Lab", "Polymaker"). Required, max 200 characters. + [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; + + /// Human-readable color name (e.g., "Fire Engine Red"). Required, max 200 characters. + [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; + + /// Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex. + [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; + + /// Total spool weight in grams when full. Must be greater than zero. + [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; } + + /// Current remaining weight in grams. Must be non-negative. + [Required(ErrorMessage = "WeightRemainingGrams is required.")] + [Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")] + public decimal WeightRemainingGrams { get; set; } + + /// Filament diameter in mm. Defaults to 1.75. Must be greater than zero. + [Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")] + public decimal FilamentDiameterMm { get; set; } = 1.75m; + + /// Manufacturer-assigned serial number. Must be unique, max 200 characters. + [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; + + /// Optional purchase price per spool. Must be non-negative if provided. + [Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")] + public decimal? PurchasePrice { get; set; } + + /// Optional purchase date. Must be a valid date if provided. + public DateTime? PurchaseDate { get; set; } + + /// Whether the spool is active. Defaults to true. + public bool IsActive { get; set; } = true; +} + +/// +/// Request DTO for updating an existing spool. +/// All required fields must be provided for a full update. +/// +public class UpdateSpoolRequest +{ + /// Foreign key to the base material. Required. + [Required(ErrorMessage = "MaterialBaseId is required.")] + public Guid MaterialBaseId { get; set; } + + /// Foreign key to the material finish. Required. + [Required(ErrorMessage = "MaterialFinishId is required.")] + public Guid MaterialFinishId { get; set; } + + /// Foreign key to the optional material modifier. Null if none applies. + public Guid? MaterialModifierId { get; set; } + + /// Brand name. Required, max 200 characters. + [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; + + /// Human-readable color name. Required, max 200 characters. + [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; + + /// Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex. + [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; + + /// Total spool weight in grams when full. Must be greater than zero. + [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; } + + /// Current remaining weight in grams. Must be non-negative. + [Required(ErrorMessage = "WeightRemainingGrams is required.")] + [Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")] + public decimal WeightRemainingGrams { get; set; } + + /// Filament diameter in mm. Must be greater than zero. + [Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")] + public decimal FilamentDiameterMm { get; set; } = 1.75m; + + /// Manufacturer-assigned serial number. Must be unique, max 200 characters. + [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; + + /// Optional purchase price per spool. Must be non-negative if provided. + [Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")] + public decimal? PurchasePrice { get; set; } + + /// Optional purchase date. + public DateTime? PurchaseDate { get; set; } + + /// Whether the spool is active. + public bool IsActive { get; set; } = true; +} \ No newline at end of file diff --git a/backend/API/DTOs/Spools/SpoolQueryDtos.cs b/backend/API/DTOs/Spools/SpoolQueryDtos.cs new file mode 100644 index 0000000..066123a --- /dev/null +++ b/backend/API/DTOs/Spools/SpoolQueryDtos.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.Spools; + +/// +/// Query parameters for filtering and paginating the spool list endpoint. +/// All parameters are optional — defaults are applied when not provided. +/// +public class SpoolQueryParameters +{ + /// Page number (1-based). Defaults to 1. + [Range(1, int.MaxValue, ErrorMessage = "PageNumber must be at least 1.")] + public int PageNumber { get; set; } = 1; + + /// Number of items per page. Defaults to 20, max 100. + [Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")] + public int PageSize { get; set; } = 20; + + /// Optional filter by material base ID. + public Guid? MaterialBaseId { get; set; } + + /// Optional filter by material finish ID. + public Guid? MaterialFinishId { get; set; } + + /// Optional filter by active status. True = active only, False = inactive only. + public bool? IsActive { get; set; } +} \ No newline at end of file diff --git a/backend/API/Hubs/IPrinterClient.cs b/backend/API/Hubs/IPrinterClient.cs new file mode 100644 index 0000000..eb015e9 --- /dev/null +++ b/backend/API/Hubs/IPrinterClient.cs @@ -0,0 +1,31 @@ +namespace Extrudex.API.Hubs; + +/// +/// 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. +/// +public interface IPrinterClient +{ + /// + /// 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). + /// + /// The unique identifier of the printer that changed. + /// The new status value (e.g., "Idle", "Printing", "Offline", "Error", "Paused"). + /// Timestamp (UTC) of when this status was last observed. + /// A Task that completes when the client has processed the update. + Task PrinterStatusChanged(Guid printerId, string status, DateTime? lastSeenAt); + + /// + /// 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. + /// + /// The unique identifier of the printer. + /// Whether the printer is currently active and available for jobs. + /// Timestamp (UTC) of the last telemetry received from the printer. + /// A Task that completes when the client has processed the heartbeat. + Task PrinterHeartbeat(Guid printerId, bool isActive, DateTime? lastSeenAt); +} \ No newline at end of file diff --git a/backend/API/Hubs/PrinterHub.cs b/backend/API/Hubs/PrinterHub.cs new file mode 100644 index 0000000..935c181 --- /dev/null +++ b/backend/API/Hubs/PrinterHub.cs @@ -0,0 +1,137 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Extrudex.API.Hubs; + +/// +/// 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. +/// +/// Usage flow: +/// +/// Client connects to /hubs/printer +/// Client calls with a printer ID +/// Server adds the connection to a SignalR group named after the printer ID +/// When the backend detects a status change, it calls +/// +/// which broadcasts to all subscribers of that printer +/// +/// +/// Group naming: printer:{printerId} (lowercase GUID). +/// +/// Typed client: — all server-to-client +/// calls go through this interface for compile-time safety. +/// +public class PrinterHub : Hub +{ + /// + /// 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 . + /// + /// + /// The unique identifier of the printer to subscribe to. + /// The GUID is normalized to lowercase for consistent group naming. + /// + /// + /// Thrown if cannot be parsed as a valid GUID. + /// + public async Task JoinPrinterGroup(Guid printerId) + { + var groupName = PrinterGroupName(printerId); + await Groups.AddToGroupAsync(Context.ConnectionId, groupName); + } + + /// + /// Removes the calling connection from the SignalR group for a specific printer. + /// After leaving, the client will no longer receive updates for that printer. + /// + /// + /// The unique identifier of the printer to unsubscribe from. + /// + public async Task LeavePrinterGroup(Guid printerId) + { + var groupName = PrinterGroupName(printerId); + await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); + } + + /// + /// Overrides to perform cleanup. + /// SignalR automatically removes disconnected connections from all groups, + /// so no manual cleanup is required here. + /// + /// Exception that caused the disconnection, if any. + public override Task OnDisconnectedAsync(Exception? exception) + { + // SignalR automatically removes the connection from all groups on disconnect. + // No manual cleanup needed. + return base.OnDisconnectedAsync(exception); + } + + /// + /// Returns the SignalR group name for a given printer ID. + /// Format: printer:{printerId} (lowercase to avoid case-sensitivity issues). + /// + /// The unique identifier of the printer. + /// A consistent, lowercase group name string. + internal static string PrinterGroupName(Guid printerId) => + $"printer:{printerId.ToString().ToLowerInvariant()}"; +} + +/// +/// Extension methods for pushing real-time printer updates through +/// the of . +/// +/// 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. +/// +public static class PrinterHubExtensions +{ + /// + /// 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). + /// + /// The hub context injected via DI. + /// The unique identifier of the printer that changed. + /// The new status string (e.g., "Idle", "Printing", "Offline"). + /// Timestamp (UTC) of when the status was observed, or null if unknown. + /// A Task that completes when the message has been sent to all group members. + public static async Task PushPrinterStatusAsync( + this IHubContext hubContext, + Guid printerId, + string status, + DateTime? lastSeenAt = null) + { + var groupName = PrinterHub.PrinterGroupName(printerId); + await hubContext.Clients.Group(groupName) + .PrinterStatusChanged(printerId, status, lastSeenAt); + } + + /// + /// 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. + /// + /// The hub context injected via DI. + /// The unique identifier of the printer. + /// Whether the printer is currently active and accepting jobs. + /// Timestamp (UTC) of the last telemetry from the printer, or null. + /// A Task that completes when the message has been sent to all group members. + public static async Task PushPrinterHeartbeatAsync( + this IHubContext hubContext, + Guid printerId, + bool isActive, + DateTime? lastSeenAt = null) + { + var groupName = PrinterHub.PrinterGroupName(printerId); + await hubContext.Clients.Group(groupName) + .PrinterHeartbeat(printerId, isActive, lastSeenAt); + } +} \ No newline at end of file diff --git a/backend/API/Validators/MaterialValidators.cs b/backend/API/Validators/MaterialValidators.cs new file mode 100644 index 0000000..a726c59 --- /dev/null +++ b/backend/API/Validators/MaterialValidators.cs @@ -0,0 +1,100 @@ +using Extrudex.API.DTOs.Materials; +using FluentValidation; + +namespace Extrudex.API.Validators; + +/// +/// Validation rules for creating a MaterialBase. +/// +public class CreateMaterialBaseRequestValidator : AbstractValidator +{ + 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."); + } +} + +/// +/// Validation rules for updating a MaterialBase. +/// +public class UpdateMaterialBaseRequestValidator : AbstractValidator +{ + 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."); + } +} + +/// +/// Validation rules for creating a MaterialFinish. +/// +public class CreateMaterialFinishRequestValidator : AbstractValidator +{ + 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."); + } +} + +/// +/// Validation rules for updating a MaterialFinish. +/// +public class UpdateMaterialFinishRequestValidator : AbstractValidator +{ + 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."); + } +} + +/// +/// Validation rules for creating a MaterialModifier. +/// +public class CreateMaterialModifierRequestValidator : AbstractValidator +{ + 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."); + } +} + +/// +/// Validation rules for updating a MaterialModifier. +/// +public class UpdateMaterialModifierRequestValidator : AbstractValidator +{ + 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."); + } +} \ No newline at end of file diff --git a/backend/API/Validators/PrintJobValidators.cs b/backend/API/Validators/PrintJobValidators.cs new file mode 100644 index 0000000..058e927 --- /dev/null +++ b/backend/API/Validators/PrintJobValidators.cs @@ -0,0 +1,106 @@ +using Extrudex.API.DTOs.PrintJobs; +using FluentValidation; + +namespace Extrudex.API.Validators; + +/// +/// Validation rules for creating a PrintJob. +/// +public class CreatePrintJobRequestValidator : AbstractValidator +{ + 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."); + }); + } +} + +/// +/// Validation rules for updating a PrintJob. +/// +public class UpdatePrintJobRequestValidator : AbstractValidator +{ + 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."); + }); + } +} + +/// +/// Validation rules for updating a PrintJob status. +/// +public class UpdatePrintJobStatusRequestValidator : AbstractValidator +{ + 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."); + } +} \ No newline at end of file diff --git a/backend/API/Validators/PrinterValidators.cs b/backend/API/Validators/PrinterValidators.cs new file mode 100644 index 0000000..06dcad5 --- /dev/null +++ b/backend/API/Validators/PrinterValidators.cs @@ -0,0 +1,82 @@ +using Extrudex.API.DTOs.Printers; +using FluentValidation; + +namespace Extrudex.API.Validators; + +/// +/// Validation rules for creating a Printer. +/// +public class CreatePrinterRequestValidator : AbstractValidator +{ + 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."); + } +} + +/// +/// Validation rules for updating a Printer. +/// +public class UpdatePrinterRequestValidator : AbstractValidator +{ + 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."); + } +} \ No newline at end of file diff --git a/backend/API/Validators/SpoolValidators.cs b/backend/API/Validators/SpoolValidators.cs new file mode 100644 index 0000000..10f8b8c --- /dev/null +++ b/backend/API/Validators/SpoolValidators.cs @@ -0,0 +1,96 @@ +using Extrudex.API.DTOs.Spools; +using FluentValidation; + +namespace Extrudex.API.Validators; + +/// +/// Validation rules for creating a Spool. +/// +public class CreateSpoolRequestValidator : AbstractValidator +{ + 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."); + }); + } +} + +/// +/// Validation rules for updating a Spool. +/// +public class UpdateSpoolRequestValidator : AbstractValidator +{ + 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."); + }); + } +} \ No newline at end of file diff --git a/backend/Domain/Base/AuditableEntity.cs b/backend/Domain/Base/AuditableEntity.cs new file mode 100644 index 0000000..8c8a223 --- /dev/null +++ b/backend/Domain/Base/AuditableEntity.cs @@ -0,0 +1,19 @@ +namespace Extrudex.Domain.Base; + +/// +/// 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. +/// +public abstract class AuditableEntity : BaseEntity +{ + /// + /// Timestamp indicating when this entity was first created (UTC). + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Timestamp indicating when this entity was last modified (UTC). + /// + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/backend/Domain/Base/BaseEntity.cs b/backend/Domain/Base/BaseEntity.cs new file mode 100644 index 0000000..3337a27 --- /dev/null +++ b/backend/Domain/Base/BaseEntity.cs @@ -0,0 +1,12 @@ +namespace Extrudex.Domain.Base; + +/// +/// Base entity providing a primary key identifier. +/// +public abstract class BaseEntity +{ + /// + /// Unique identifier for the entity. + /// + public Guid Id { get; set; } = Guid.NewGuid(); +} \ No newline at end of file diff --git a/backend/Domain/Entities/AmsSlot.cs b/backend/Domain/Entities/AmsSlot.cs new file mode 100644 index 0000000..8929a13 --- /dev/null +++ b/backend/Domain/Entities/AmsSlot.cs @@ -0,0 +1,41 @@ +using Extrudex.Domain.Base; + +namespace Extrudex.Domain.Entities; + +/// +/// 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. +/// +public class AmsSlot : AuditableEntity +{ + /// + /// The 1-based tray/slot index within the AMS unit (1-4 per unit). + /// + public int TrayIndex { get; set; } + + /// + /// Foreign key to the AMS unit this slot belongs to. + /// + public Guid AmsUnitId { get; set; } + + /// + /// Navigation to the parent AMS unit. + /// + public AmsUnit AmsUnit { get; set; } = null!; + + /// + /// Foreign key to the spool currently loaded in this slot. Null if empty. + /// + public Guid? SpoolId { get; set; } + + /// + /// Navigation to the spool currently loaded in this slot. Null if no spool is loaded. + /// + public Spool? Spool { get; set; } + + /// + /// Remaining filament weight in grams as reported by the AMS. + /// Bambu Lab AMS reports remaining weight per tray. + /// + public decimal? RemainingWeightG { get; set; } +} \ No newline at end of file diff --git a/backend/Domain/Entities/AmsUnit.cs b/backend/Domain/Entities/AmsUnit.cs new file mode 100644 index 0000000..605058b --- /dev/null +++ b/backend/Domain/Entities/AmsUnit.cs @@ -0,0 +1,30 @@ +using Extrudex.Domain.Base; + +namespace Extrudex.Domain.Entities; + +/// +/// Represents an AMS (Automatic Material System) unit installed on a Bambu Lab printer. +/// Each AMS unit contains multiple slots that hold spools. +/// +public class AmsUnit : AuditableEntity +{ + /// + /// The 1-based index of this AMS unit on the printer (e.g., AMS 1, AMS 2). + /// + public int UnitIndex { get; set; } + + /// + /// Foreign key to the parent Printer this AMS unit is installed on. + /// + public Guid PrinterId { get; set; } + + /// + /// Navigation to the parent Printer. + /// + public Printer Printer { get; set; } = null!; + + /// + /// Navigation collection of slots in this AMS unit. + /// + public ICollection Slots { get; set; } = new List(); +} \ No newline at end of file diff --git a/backend/Domain/Entities/MaterialBase.cs b/backend/Domain/Entities/MaterialBase.cs new file mode 100644 index 0000000..634aefb --- /dev/null +++ b/backend/Domain/Entities/MaterialBase.cs @@ -0,0 +1,36 @@ +using Extrudex.Domain.Base; + +namespace Extrudex.Domain.Entities; + +/// +/// Base polymer/material type. This is a lookup table enforcing consistent +/// material naming across all spools. Free-text material names are not allowed. +/// +public class MaterialBase : AuditableEntity +{ + /// + /// Human-readable name of the base material (e.g., "PLA", "PETG", "ABS"). + /// + public string Name { get; set; } = string.Empty; + + /// + /// Density of the material in g/cm³ (g/mL). Used for deriving grams consumed + /// from mm extruded: grams = mm × cross_section_area × density. + /// + public decimal DensityGperCm3 { get; set; } + + /// + /// Navigation collection of finishes available for this material base. + /// + public ICollection Finishes { get; set; } = new List(); + + /// + /// Navigation collection of modifiers applicable to this material base. + /// + public ICollection Modifiers { get; set; } = new List(); + + /// + /// Navigation collection of spools made from this material base. + /// + public ICollection Spools { get; set; } = new List(); +} \ No newline at end of file diff --git a/backend/Domain/Entities/MaterialFinish.cs b/backend/Domain/Entities/MaterialFinish.cs new file mode 100644 index 0000000..3bb84f7 --- /dev/null +++ b/backend/Domain/Entities/MaterialFinish.cs @@ -0,0 +1,30 @@ +using Extrudex.Domain.Base; + +namespace Extrudex.Domain.Entities; + +/// +/// Surface finish descriptor for a material. This is REQUIRED on every spool +/// record. The default value is "Basic" (not "Standard"). +/// +public class MaterialFinish : AuditableEntity +{ + /// + /// Human-readable name of the finish (e.g., "Basic", "Matte", "Silk", "Glitter"). + /// + public string Name { get; set; } = string.Empty; + + /// + /// Foreign key to the parent MaterialBase. A finish belongs to exactly one base material. + /// + public Guid MaterialBaseId { get; set; } + + /// + /// Navigation to the parent MaterialBase. + /// + public MaterialBase MaterialBase { get; set; } = null!; + + /// + /// Navigation collection of spools with this finish. + /// + public ICollection Spools { get; set; } = new List(); +} \ No newline at end of file diff --git a/backend/Domain/Entities/MaterialModifier.cs b/backend/Domain/Entities/MaterialModifier.cs new file mode 100644 index 0000000..c1f33d1 --- /dev/null +++ b/backend/Domain/Entities/MaterialModifier.cs @@ -0,0 +1,30 @@ +using Extrudex.Domain.Base; + +namespace Extrudex.Domain.Entities; + +/// +/// Optional modifier/additive for a material (e.g., "Carbon Fiber", "Glass Fiber", +/// "Wood Fill", "Glow-in-the-Dark"). Not every spool has a modifier. +/// +public class MaterialModifier : AuditableEntity +{ + /// + /// Human-readable name of the modifier (e.g., "Carbon Fiber", "Wood Fill"). + /// + public string Name { get; set; } = string.Empty; + + /// + /// Foreign key to the parent MaterialBase. A modifier belongs to exactly one base material. + /// + public Guid MaterialBaseId { get; set; } + + /// + /// Navigation to the parent MaterialBase. + /// + public MaterialBase MaterialBase { get; set; } = null!; + + /// + /// Navigation collection of spools with this modifier. + /// + public ICollection Spools { get; set; } = new List(); +} \ No newline at end of file diff --git a/backend/Domain/Entities/PrintJob.cs b/backend/Domain/Entities/PrintJob.cs new file mode 100644 index 0000000..6483f72 --- /dev/null +++ b/backend/Domain/Entities/PrintJob.cs @@ -0,0 +1,100 @@ +using Extrudex.Domain.Base; +using Extrudex.Domain.Enums; + +namespace Extrudex.Domain.Entities; + +/// +/// 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). +/// +public class PrintJob : AuditableEntity +{ + /// + /// Foreign key to the printer that executed this print job. + /// + public Guid PrinterId { get; set; } + + /// + /// Navigation to the printer that executed this print job. + /// + public Printer Printer { get; set; } = null!; + + /// + /// Foreign key to the spool that provided filament for this print job. + /// + public Guid SpoolId { get; set; } + + /// + /// Navigation to the spool that provided filament for this print job. + /// + public Spool Spool { get; set; } = null!; + + /// + /// Human-readable name or identifier for the print job. + /// + public string PrintName { get; set; } = string.Empty; + + /// + /// Path or filename of the G-code file being printed. + /// + public string? GcodeFilePath { get; set; } + + /// + /// Total millimeters of filament extruded during this print job. + /// The primary input to the COGS derivation formula. + /// + public decimal MmExtruded { get; set; } + + /// + /// Derived grams consumed for this print, calculated as: + /// mm_extruded × cross_section_area × material_density_at_print. + /// + public decimal GramsDerived { get; set; } + + /// + /// Calculated cost of goods sold (COGS) for this print job. + /// Derived from grams consumed and the spool's purchase price. + /// + public decimal? CostPerPrint { get; set; } + + /// + /// Timestamp when the print job started (UTC). + /// + public DateTime? StartedAt { get; set; } + + /// + /// Timestamp when the print job completed or failed (UTC). + /// + public DateTime? CompletedAt { get; set; } + + /// + /// Current status of the print job. + /// + public JobStatus Status { get; set; } = JobStatus.Queued; + + /// + /// The source of the print job data (which integration path provided it). + /// + public DataSource DataSource { get; set; } + + /// + /// 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. + /// + public decimal FilamentDiameterAtPrintMm { get; set; } + + /// + /// 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. + /// + public decimal MaterialDensityAtPrint { get; set; } + + /// + /// Optional notes about the print job (e.g., "First layer adhesion issues"). + /// + public string? Notes { get; set; } +} \ No newline at end of file diff --git a/backend/Domain/Entities/Printer.cs b/backend/Domain/Entities/Printer.cs new file mode 100644 index 0000000..b28419e --- /dev/null +++ b/backend/Domain/Entities/Printer.cs @@ -0,0 +1,97 @@ +using Extrudex.Domain.Base; +using Extrudex.Domain.Enums; + +namespace Extrudex.Domain.Entities; + +/// +/// Represents a 3D printer in the fleet. Stores connection details for +/// MQTT (Bambu Lab) or Moonraker (Elegoo Centauri Carbon) integration. +/// +public class Printer : AuditableEntity +{ + /// + /// Current operational status of the printer, updated via real-time telemetry. + /// + public PrinterStatus Status { get; set; } = PrinterStatus.Offline; + + /// + /// Human-readable name for the printer (e.g., "Bambu X1C #1", "Elegoo Centauri"). + /// + public string Name { get; set; } = string.Empty; + + /// + /// Manufacturer/brand of the printer (e.g., "Bambu Lab", "Elegoo"). + /// + public string Manufacturer { get; set; } = string.Empty; + + /// + /// Model name (e.g., "X1 Carbon", "Centauri Carbon"). + /// + public string Model { get; set; } = string.Empty; + + /// + /// The hardware type of the printer (FDM or Resin). + /// + public PrinterType PrinterType { get; set; } = PrinterType.Fdm; + + /// + /// The connectivity protocol used by this printer (MQTT or Moonraker). + /// + public ConnectionType ConnectionType { get; set; } = ConnectionType.Mqtt; + + /// + /// Hostname or IP address for connecting to the printer. + /// + public string HostnameOrIp { get; set; } = string.Empty; + + /// + /// Port number for the printer connection. Defaults: 8883 (MQTT/TLS), 7125 (Moonraker). + /// + public int Port { get; set; } + + /// + /// MQTT username for Bambu Lab printer authentication. + /// Stored only for MQTT connection type printers. + /// + public string MqttUsername { get; set; } = string.Empty; + + /// + /// MQTT password for Bambu Lab printer authentication. + /// Stored only for MQTT connection type printers. + /// + public string MqttPassword { get; set; } = string.Empty; + + /// + /// Whether to use TLS for the MQTT connection. Bambu Lab printers + /// require TLS on port 8883. + /// + public bool MqttUseTls { get; set; } + + /// + /// Moonraker API key for Elegoo Centauri Carbon authentication. + /// Stored only for Moonraker connection type printers. + /// + public string ApiKey { get; set; } = string.Empty; + + /// + /// Whether this printer is currently active and available for print jobs. + /// Inactive printers are retained for historical records. + /// + public bool IsActive { get; set; } = true; + + /// + /// Timestamp of the last status update received from the printer (UTC). + /// Used to detect stale connections. + /// + public DateTime? LastSeenAt { get; set; } + + /// + /// Navigation collection of AMS units installed on this printer. + /// + public ICollection AmsUnits { get; set; } = new List(); + + /// + /// Navigation collection of print jobs executed on this printer. + /// + public ICollection PrintJobs { get; set; } = new List(); +} \ No newline at end of file diff --git a/backend/Domain/Entities/Spool.cs b/backend/Domain/Entities/Spool.cs new file mode 100644 index 0000000..0356002 --- /dev/null +++ b/backend/Domain/Entities/Spool.cs @@ -0,0 +1,105 @@ +using Extrudex.Domain.Base; + +namespace Extrudex.Domain.Entities; + +/// +/// 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. +/// +public class Spool : AuditableEntity +{ + /// + /// Foreign key to the base material. Every spool must specify a material base. + /// + public Guid MaterialBaseId { get; set; } + + /// + /// Navigation to the base material (e.g., PLA, PETG, ABS). + /// + public MaterialBase MaterialBase { get; set; } = null!; + + /// + /// Foreign key to the material finish. REQUIRED on every spool — default is "Basic". + /// + public Guid MaterialFinishId { get; set; } + + /// + /// Navigation to the material finish (e.g., Basic, Matte, Silk, Glitter). + /// + public MaterialFinish MaterialFinish { get; set; } = null!; + + /// + /// Foreign key to the optional material modifier. Null if no modifier applies. + /// + public Guid? MaterialModifierId { get; set; } + + /// + /// Navigation to the optional material modifier (e.g., Carbon Fiber, Wood Fill). + /// + public MaterialModifier? MaterialModifier { get; set; } + + /// + /// Human-readable brand name (e.g., "Bambu Lab", "Polymaker", "eSUN"). + /// + public string Brand { get; set; } = string.Empty; + + /// + /// Human-readable color name (e.g., "Fire Engine Red", "Galaxy Black"). + /// + public string ColorName { get; set; } = string.Empty; + + /// + /// Hex color code for the filament (e.g., "#FF0000" for red). + /// Enables color-based filtering and visual identification. + /// + public string ColorHex { get; set; } = string.Empty; + + /// + /// Total spool weight in grams when full (brand new, unopened). + /// + public decimal WeightTotalGrams { get; set; } + + /// + /// Current remaining weight in grams. Updated via AMS data or manual check-in. + /// + public decimal WeightRemainingGrams { get; set; } + + /// + /// Filament diameter in millimeters. Typically 1.75mm for FDM printers. + /// Used in the COGS derivation formula: grams = mm × cross_section_area × density. + /// + public decimal FilamentDiameterMm { get; set; } = 1.75m; + + /// + /// Manufacturer-assigned serial number for the spool. Used for barcode/QR scanning. + /// Must be unique across all spools. + /// + public string SpoolSerial { get; set; } = string.Empty; + + /// + /// Purchase price per spool in the system currency. Used for COGS calculations. + /// + public decimal? PurchasePrice { get; set; } + + /// + /// Date the spool was purchased or received. + /// + public DateTime? PurchaseDate { get; set; } + + /// + /// Whether the spool is currently active and available for use. + /// Inactive spools are retained for historical COGS records. + /// + public bool IsActive { get; set; } = true; + + /// + /// Navigation collection of AMS slots where this spool is loaded. + /// + public ICollection AmsSlots { get; set; } = new List(); + + /// + /// Navigation collection of print jobs that consumed filament from this spool. + /// + public ICollection PrintJobs { get; set; } = new List(); +} \ No newline at end of file diff --git a/backend/Domain/Enums/ConnectionType.cs b/backend/Domain/Enums/ConnectionType.cs new file mode 100644 index 0000000..1e3ff7e --- /dev/null +++ b/backend/Domain/Enums/ConnectionType.cs @@ -0,0 +1,13 @@ +namespace Extrudex.Domain.Enums; + +/// +/// Describes how the backend communicates with a printer. +/// +public enum ConnectionType +{ + /// Bambu Lab printers communicating via MQTT over TLS. + Mqtt = 0, + + /// Klipper-based printers (Elegoo) communicating via Moonraker REST/WebSocket. + Moonraker = 1 +} \ No newline at end of file diff --git a/backend/Domain/Enums/DataSource.cs b/backend/Domain/Enums/DataSource.cs new file mode 100644 index 0000000..bdbf6bc --- /dev/null +++ b/backend/Domain/Enums/DataSource.cs @@ -0,0 +1,16 @@ +namespace Extrudex.Domain.Enums; + +/// +/// Indicates where the print job data originated from. +/// +public enum DataSource +{ + /// Data reported by a Bambu Lab printer via MQTT. + Mqtt = 0, + + /// Data reported by an Elegoo/Klipper printer via Moonraker. + Moonraker = 1, + + /// Manually entered by a user. + Manual = 2 +} \ No newline at end of file diff --git a/backend/Domain/Enums/JobStatus.cs b/backend/Domain/Enums/JobStatus.cs new file mode 100644 index 0000000..8df01c8 --- /dev/null +++ b/backend/Domain/Enums/JobStatus.cs @@ -0,0 +1,22 @@ +namespace Extrudex.Domain.Enums; + +/// +/// Represents the current lifecycle status of a print job. +/// +public enum JobStatus +{ + /// Job has been created but not yet sent to the printer. + Queued = 0, + + /// Printer is actively printing this job. + Printing = 1, + + /// Job completed successfully. + Completed = 2, + + /// Job was cancelled by the user. + Cancelled = 3, + + /// Job failed due to an error. + Failed = 4 +} \ No newline at end of file diff --git a/backend/Domain/Enums/PrinterStatus.cs b/backend/Domain/Enums/PrinterStatus.cs new file mode 100644 index 0000000..e437a6b --- /dev/null +++ b/backend/Domain/Enums/PrinterStatus.cs @@ -0,0 +1,32 @@ +namespace Extrudex.Domain.Enums; + +/// +/// Represents the current operational status of a printer. +/// +public enum PrinterStatus +{ + /// + /// Printer is online and idle, ready to accept jobs. + /// + Idle = 0, + + /// + /// Printer is currently printing. + /// + Printing = 1, + + /// + /// Printer is offline or unreachable. + /// + Offline = 2, + + /// + /// Printer is in an error state. + /// + Error = 3, + + /// + /// Printer is paused. + /// + Paused = 4 +} \ No newline at end of file diff --git a/backend/Domain/Enums/PrinterType.cs b/backend/Domain/Enums/PrinterType.cs new file mode 100644 index 0000000..be461fc --- /dev/null +++ b/backend/Domain/Enums/PrinterType.cs @@ -0,0 +1,13 @@ +namespace Extrudex.Domain.Enums; + +/// +/// Identifies the type of 3D printer hardware. +/// +public enum PrinterType +{ + /// FDM/FFF filament-based printer. + Fdm = 0, + + /// Resin-based SLA/DLP/LCD printer. + Resin = 1 +} \ No newline at end of file diff --git a/backend/Domain/Enums/QrResourceType.cs b/backend/Domain/Enums/QrResourceType.cs new file mode 100644 index 0000000..f68cb9e --- /dev/null +++ b/backend/Domain/Enums/QrResourceType.cs @@ -0,0 +1,24 @@ +namespace Extrudex.Domain.Enums; + +/// +/// Defines the resource types that support QR code generation. +/// Each type maps to a distinct API route for QR code retrieval. +/// +public enum QrResourceType +{ + /// + /// QR code for a filament spool — links to spool detail/scan view. + /// + Spool, + + /// + /// QR code for a printer — links to printer detail/monitor view. + /// + Printer, + + /// + /// QR code for a storage location — links to location inventory view. + /// Reserved for future use when Location entities are introduced. + /// + Location +} \ No newline at end of file diff --git a/backend/Domain/Interfaces/IQrCodeService.cs b/backend/Domain/Interfaces/IQrCodeService.cs new file mode 100644 index 0000000..63f94ea --- /dev/null +++ b/backend/Domain/Interfaces/IQrCodeService.cs @@ -0,0 +1,41 @@ +using Extrudex.Domain.Enums; + +namespace Extrudex.Domain.Interfaces; + +/// +/// 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. +/// +public interface IQrCodeService +{ + /// + /// 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. + /// + /// The type of resource (Spool, Printer, Location). + /// The unique identifier of the resource. + /// + /// Pixel density per QR module. Higher values produce larger images. + /// Default (20) balances readability and label size. + /// + /// A byte array containing the PNG image data. + byte[] GeneratePng(QrResourceType resourceType, Guid id, int pixelsPerModule = 20); + + /// + /// Generates an SVG QR code image for the specified resource type and ID. + /// SVG is resolution-independent and ideal for printing at any scale. + /// + /// The type of resource (Spool, Printer, Location). + /// The unique identifier of the resource. + /// A string containing the SVG markup. + string GenerateSvg(QrResourceType resourceType, Guid id); + + /// + /// Constructs the URL that will be encoded into the QR code for the given resource. + /// + /// The type of resource. + /// The unique identifier of the resource. + /// The absolute URL to be encoded in the QR code. + string GetResourceUrl(QrResourceType resourceType, Guid id); +} \ No newline at end of file diff --git a/backend/Extrudex.csproj b/backend/Extrudex.csproj new file mode 100644 index 0000000..f4f38a8 --- /dev/null +++ b/backend/Extrudex.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + Extrudex + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/Extrudex.sln b/backend/Extrudex.sln new file mode 100644 index 0000000..d57b257 --- /dev/null +++ b/backend/Extrudex.sln @@ -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 \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/AmsSlotConfiguration.cs b/backend/Infrastructure/Data/Configurations/AmsSlotConfiguration.cs new file mode 100644 index 0000000..cb84e1a --- /dev/null +++ b/backend/Infrastructure/Data/Configurations/AmsSlotConfiguration.cs @@ -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 +{ + public override void Configure(EntityTypeBuilder 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); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/AmsUnitConfiguration.cs b/backend/Infrastructure/Data/Configurations/AmsUnitConfiguration.cs new file mode 100644 index 0000000..3280838 --- /dev/null +++ b/backend/Infrastructure/Data/Configurations/AmsUnitConfiguration.cs @@ -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 +{ + public override void Configure(EntityTypeBuilder 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"); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/BaseEntityConfiguration.cs b/backend/Infrastructure/Data/Configurations/BaseEntityConfiguration.cs new file mode 100644 index 0000000..818c301 --- /dev/null +++ b/backend/Infrastructure/Data/Configurations/BaseEntityConfiguration.cs @@ -0,0 +1,63 @@ +using Extrudex.Domain.Base; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Extrudex.Infrastructure.Data.Configurations; + +/// +/// 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 +/// +/// The entity type to configure. +public abstract class BaseEntityConfiguration : IEntityTypeConfiguration + where TEntity : BaseEntity +{ + public virtual void Configure(EntityTypeBuilder 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); + } + } + + /// + /// 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. + /// + private static void ConfigureAuditColumns(EntityTypeBuilder builder) + { + builder.Property("CreatedAt") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + builder.Property("UpdatedAt") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + } + + /// + /// Converts PascalCase or camelCase to snake_case. + /// + 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(); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/MaterialBaseConfiguration.cs b/backend/Infrastructure/Data/Configurations/MaterialBaseConfiguration.cs new file mode 100644 index 0000000..21d973f --- /dev/null +++ b/backend/Infrastructure/Data/Configurations/MaterialBaseConfiguration.cs @@ -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 +{ + public override void Configure(EntityTypeBuilder 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"); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/MaterialFinishConfiguration.cs b/backend/Infrastructure/Data/Configurations/MaterialFinishConfiguration.cs new file mode 100644 index 0000000..e54fbcc --- /dev/null +++ b/backend/Infrastructure/Data/Configurations/MaterialFinishConfiguration.cs @@ -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 +{ + public override void Configure(EntityTypeBuilder 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"); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/MaterialModifierConfiguration.cs b/backend/Infrastructure/Data/Configurations/MaterialModifierConfiguration.cs new file mode 100644 index 0000000..881fa0c --- /dev/null +++ b/backend/Infrastructure/Data/Configurations/MaterialModifierConfiguration.cs @@ -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 +{ + public override void Configure(EntityTypeBuilder 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"); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/PrintJobConfiguration.cs b/backend/Infrastructure/Data/Configurations/PrintJobConfiguration.cs new file mode 100644 index 0000000..83dfda7 --- /dev/null +++ b/backend/Infrastructure/Data/Configurations/PrintJobConfiguration.cs @@ -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 +{ + public override void Configure(EntityTypeBuilder 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() + .HasMaxLength(50) + .HasDefaultValue(JobStatus.Queued) + .IsRequired(); + + builder.Property(e => e.DataSource) + .HasColumnName("data_source") + .HasConversion() + .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); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/PrinterConfiguration.cs b/backend/Infrastructure/Data/Configurations/PrinterConfiguration.cs new file mode 100644 index 0000000..5d4d5fc --- /dev/null +++ b/backend/Infrastructure/Data/Configurations/PrinterConfiguration.cs @@ -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 +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(e => e.Name) + .HasColumnName("name") + .IsRequired() + .HasMaxLength(200); + + builder.Property(e => e.Status) + .HasColumnName("status") + .HasConversion() + .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() + .HasMaxLength(50) + .IsRequired(); + + builder.Property(e => e.ConnectionType) + .HasColumnName("connection_type") + .HasConversion() + .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"); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/SpoolConfiguration.cs b/backend/Infrastructure/Data/Configurations/SpoolConfiguration.cs new file mode 100644 index 0000000..fbc46f8 --- /dev/null +++ b/backend/Infrastructure/Data/Configurations/SpoolConfiguration.cs @@ -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 +{ + public override void Configure(EntityTypeBuilder 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"); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/ExtrudexDbContext.cs b/backend/Infrastructure/Data/ExtrudexDbContext.cs new file mode 100644 index 0000000..f6ec61a --- /dev/null +++ b/backend/Infrastructure/Data/ExtrudexDbContext.cs @@ -0,0 +1,78 @@ +using Extrudex.Domain.Base; +using Extrudex.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Extrudex.Infrastructure.Data; + +/// +/// Main EF Core database context for the Extrudex system. +/// Handles entity registration, snake_case naming, and automatic timestamp management. +/// +public class ExtrudexDbContext : DbContext +{ + public ExtrudexDbContext(DbContextOptions options) : base(options) { } + + // Lookup tables + public DbSet MaterialBases => Set(); + public DbSet MaterialFinishes => Set(); + public DbSet MaterialModifiers => Set(); + + // Core entities + public DbSet Spools => Set(); + public DbSet Printers => Set(); + public DbSet AmsUnits => Set(); + public DbSet AmsSlots => Set(); + public DbSet PrintJobs => Set(); + + 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().HasData(SeedData.MaterialBases); + modelBuilder.Entity().HasData(SeedData.MaterialFinishes); + modelBuilder.Entity().HasData(SeedData.MaterialModifiers); + } + + /// + /// Automatically set UpdatedAt on auditable entities during SaveChanges. + /// + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + SetAuditTimestamps(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override async Task SaveChangesAsync( + bool acceptAllChangesOnSuccess, + CancellationToken cancellationToken = default) + { + SetAuditTimestamps(); + return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + /// + /// Sets UpdatedAt on all auditable entities that have been modified. + /// Sets CreatedAt on all auditable entities that are being added. + /// + private void SetAuditTimestamps() + { + var entries = ChangeTracker.Entries(); + + 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; + } + } + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/Seed/SeedData.cs b/backend/Infrastructure/Data/Seed/SeedData.cs new file mode 100644 index 0000000..9c32e55 --- /dev/null +++ b/backend/Infrastructure/Data/Seed/SeedData.cs @@ -0,0 +1,121 @@ +using Extrudex.Domain.Entities; + +namespace Extrudex.Infrastructure.Data; + +/// +/// 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"). +/// +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 } + ]; +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/QrCodeService.cs b/backend/Infrastructure/Services/QrCodeService.cs new file mode 100644 index 0000000..de3a846 --- /dev/null +++ b/backend/Infrastructure/Services/QrCodeService.cs @@ -0,0 +1,67 @@ +using Extrudex.Domain.Enums; +using Extrudex.Domain.Interfaces; +using QRCoder; + +namespace Extrudex.Infrastructure.Services; + +/// +/// 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. +/// +public class QrCodeService : IQrCodeService +{ + private const string BaseUrl = "https://extrudex.app"; + + /// + 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 + } + + /// + 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); + } + + /// + 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}"; + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs new file mode 100644 index 0000000..86ff1c4 --- /dev/null +++ b/backend/Program.cs @@ -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(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(); + +// ── 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("/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}"; +} \ No newline at end of file diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json new file mode 100644 index 0000000..8edfdd9 --- /dev/null +++ b/backend/appsettings.Development.json @@ -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" + } +} \ No newline at end of file diff --git a/backend/appsettings.json b/backend/appsettings.json new file mode 100644 index 0000000..d924e27 --- /dev/null +++ b/backend/appsettings.json @@ -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" + } +} \ No newline at end of file diff --git a/design/01-filament-inventory-list.md b/design/01-filament-inventory-list.md new file mode 100644 index 0000000..7112a9b --- /dev/null +++ b/design/01-filament-inventory-list.md @@ -0,0 +1,344 @@ +# Filament Inventory List — Screen Specification + +> **Screen ID:** FIL-001 +> **Source of Truth:** [Material Design 3](https://m3.material.io/) +> **Tone:** Modern Industrial/Maker +> **Theme:** Dark Mode, High-Contrast +> **Last Updated:** 2026-04-20 + +--- + +## 1. Objective + +Provide a single, scannable view of all filament spools in inventory. Users (shop operators) must be able to: + +- Rapidly assess stock levels at a glance (which spools are low/critical). +- Search by material type, brand, color, or internal tracking ID. +- Filter by status (Available, In Use, Low Stock, Depleted). +- Navigate to spool detail or initiate the Smart Intake workflow. + +This is the **primary landing screen** for both the kiosk and the mobile PWA. + +--- + +## 2. Screen Inventory + +| Element | MD3 Component | Notes | +|---------|--------------|-------| +| Top App Bar | `md-top-app-bar` (medium) | Title + search toggle + intake FAB | +| Search Bar | `md-search-bar` | Expandable on tap; collapses to icon on kiosk | +| Filter Chips | `md-chip-set` (filter) | Status filters: All / Available / In Use / Low / Depleted | +| Inventory List | `md-list` (3-line) | Spool cards as list items | +| Low-Stock Badge | `md-badge` | Tonal badge on list items | +| Extended FAB | `md-fab` (extended) | "Smart Intake" CTA | +| Navigation Rail | `md-navigation-rail` | Kiosk only — Inventory / Printers / Jobs / Settings | +| Bottom Navigation | `md-navigation-bar` | Mobile only — same destinations | +| Empty State | Illustration + `md-text-button` | When no spools match filter | + +--- + +## 3. Layout Specification + +### Title +**"Filament Inventory"** — displayed in the Top App Bar headline slot. + +### Sections + +#### A. Top App Bar (56dp mobile / 64dp kiosk) +- **Leading:** Menu icon (kiosk) / Back arrow (mobile, if deep-linked) +- **Title:** "Filament Inventory" +- **Trailing actions:** Search icon (toggles search bar), Overflow menu (Sort by, Export) + +#### B. Search Bar (0dp collapsed → 56dp expanded) +- Triggered by search icon tap or pull-down gesture on mobile +- Placeholder: "Search spools, brands, colors…" +- Auto-focus on expand; dismiss on back/clear +- Real-time filtering as user types + +#### C. Filter Chip Row (52dp height, horizontal scroll) +- Chips: `All` | `Available` | `In Use` | `Low Stock` | `Depleted` +- Default selection: `All` +- Multi-select disabled — single filter active at a time +- Chip styling: Tonal variants per status color + - Available: `green` tonal + - In Use: `blue` tonal (primary) + - Low Stock: `yellow` tonal (warning) + - Depleted: `red` tonal (error) + +#### D. Inventory List (flex, scrollable) +- Each item is a **3-line list item** with leading/trailing elements: + +``` +┌──────────────────────────────────────────────────┐ +│ [Color Swatch] PLA Basic - Matte Black │ +│ Bambu Lab • Slot A3 • 842g left │ +│ ▓▓▓▓▓▓▓▓▓▓░░ 67% [Low] badge │ +├──────────────────────────────────────────────────┤ +│ [Color Swatch] PETG Basic - Transparent │ +│ Polymaker • Shelf B2 • 210g left │ +│ ▓▓▓░░░░░░░░░ 21% [Critical] ⚠ │ +└──────────────────────────────────────────────────┘ +``` + +- **Leading:** Circular color swatch (40dp) — matches filament color +- **Line 1:** Material name (MaterialBase + Finish + Modifier) — `titleMedium`, `onSurface` +- **Line 2:** Brand • Location • Remaining weight — `bodyMedium`, `onSurfaceVariant` +- **Line 3:** Linear progress indicator + percentage text — `labelSmall`, `onSurfaceVariant` +- **Trailing:** Status badge (tonal chip) + chevron icon +- **Divider:** Full-width between items + +**Sort order (default):** Low stock first, then alphabetical by material name. + +#### E. Extended FAB (Bottom-end, mobile; Bottom-end, kiosk) +- Label: **"Smart Intake"** + `qr_code_scanner` icon +- Container: `primaryContainer` color +- On tap → navigates to Smart Intake Scan State + +#### F. Navigation (Screen-level) +- **Kiosk:** Navigation Rail on leading edge (80dp wide) + - Destinations: Inventory (active), Printers, Jobs, Settings +- **Mobile:** Bottom Navigation Bar (80dp height) + - Same destinations + +### Primary CTA +**Smart Intake** (Extended FAB) — always visible, anchored bottom-end. + +### Secondary Actions +- Tap list item → Spool Detail View +- Overflow → Sort options (Name A-Z, Name Z-A, Weight Low→High, Weight High→Low, Recently Used) +- Overflow → Export inventory (CSV) + +### Key Components +- Search bar with real-time filtering +- Filter chip set (single-select) +- 3-line list items with progress indicators +- Color swatch leading element +- Status tonal badges +- Extended FAB + +### States +| State | Visual | +|-------|--------| +| **Loading** | `md-circular-progress` centered, list skeleton (shimmer) | +| **Empty (no spools)** | Spool icon illustration + "No spools in inventory" + "Add your first spool" text button | +| **Empty (no filter matches)** | "No spools match your filter" + Clear filter chip button | +| **Error** | Error illustration + "Couldn't load inventory" + Retry button | +| **Low Stock item** | Yellow tonal badge "Low" + progress bar < 25% yellow | +| **Critical item** | Red tonal badge "Critical" + progress bar < 10% red + pulsing dot | +| **Depleted item** | Red tonal badge "Depleted" + progress bar 0% + strikethrough title | + +--- + +## 4. UX Rationale + +1. **Progress bars beat numbers alone.** A visual progress indicator communicates remaining spool life faster than parsing "210g / 1000g" — critical in a busy workshop where decisions are made in seconds. + +2. **Low stock first (default sort).** Operators need to see what's running out before what's plentiful. This prevents the "surprise empty spool" problem mid-print. + +3. **Color swatches as leading elements.** Filament identification is primarily visual — operators scan for color before reading text. The swatch leverages pre-attentive processing. + +4. **Single-select filters over multi-select.** Workshop operators don't construct complex queries. They want one quick filter tap. Multi-select adds cognitive load for minimal practical benefit. + +5. **Smart Intake FAB, not a menu item.** Intake is the most frequent action (new spools arrive regularly). It deserves the highest-affordance control on screen — a FAB is unmissable. + +6. **Search collapses by default.** On kiosk, screen space is precious. The search icon expands the bar only when needed, preserving vertical list space. + +7. **Empty states with action.** An empty inventory list should immediately offer the path forward — "Add your first spool" links directly to Smart Intake. + +--- + +## 5. Visual Direction + +### Typography (MD3 Type Scale) + +| Role | Token | Size | Weight | Line Height | +|------|-------|------|--------|-------------| +| App Bar Title | `titleLarge` | 22sp | 400 | 28sp | +| List Item Line 1 | `titleMedium` | 16sp | 500 | 24sp | +| List Item Line 2 | `bodyMedium` | 14sp | 400 | 20sp | +| List Item Line 3 | `labelSmall` | 11sp | 500 | 16sp | +| Filter Chip Text | `labelLarge` | 14sp | 500 | 20sp | +| FAB Label | `labelLarge` | 14sp | 500 | 20sp | +| Empty State Title | `headlineSmall` | 24sp | 400 | 32sp | +| Empty State Body | `bodyMedium` | 14sp | 400 | 20sp | + +### Spacing (MD3 8dp grid) + +| Element | Spacing | +|---------|---------| +| App Bar internal padding | 16dp horizontal, 12dp vertical | +| Filter chip row padding | 16dp horizontal, 8dp vertical | +| Chip gap | 8dp | +| List item padding | 16dp horizontal, 12dp vertical (top/bottom of 3-line) | +| List item internal gap | 16dp (leading to content), 16dp (content to trailing) | +| FAB margin | 16dp from edges | +| Content area padding | 0dp (list is edge-to-edge with dividers) | + +### Color (MD3 Dark Theme — "Industrial Maker") + +Based on a **blue-grey** primary seed for the industrial feel, with **teal** as secondary (maker accent). + +| Role | Token | Value (Dark) | Usage | +|------|-------|-------------|-------| +| Background | `surface` | `#1C1B1F` | Screen background | +| On Background | `onSurface` | `#E6E1E5` | Primary text | +| Surface Variant | `surfaceContainer` | `#211F26` | App bar, list items | +| On Surface Variant | `onSurfaceVariant` | `#CAC4D0` | Secondary text, chip outlines | +| Primary | `primary` | `#A8CEDA` | FAB container, active states | +| On Primary | `onPrimary` | `#00303E` | FAB label text | +| Primary Container | `primaryContainer` | `#004D63` | FAB container (tonal), active chip fill | +| On Primary Container | `onPrimaryContainer` | `#A8CEDA` | Active chip text | +| Secondary | `secondary` | `#B1CCC7` | Navigation rail active icon | +| Tertiary | `tertiary` | `#EFB8C8` | Accent (not used here) | +| Error | `error` | `#F2B8B5` | Depleted badge, critical states | +| Error Container | `errorContainer` | `#8C1D18` | Critical badge background | +| On Error Container | `onErrorContainer` | `#F2B8B5` | Critical badge text | +| **Custom: Warning** | — | `#FFD580` | Low stock indicator | +| **Custom: Warning Container** | — | `#5D4200` | Low stock badge background | +| **Custom: Success** | — | `#8BD0A0` | Available badge text | +| **Custom: Success Container** | — | `#00522E` | Available badge background | +| Outline | `outline` | `#938F99` | Dividers, chip outlines | +| Outline Variant | `outlineVariant` | `#49454F` | Subtle borders | + +### Color Swatches +Spool color swatches use the actual filament color as a circular filled element (40dp diameter) with a 2dp `outlineVariant` border for visibility on dark backgrounds. For transparent/clear filaments, use a diagonal hatch pattern fill. + +--- + +## 6. Responsiveness + +### Kiosk (800×480, Raspberry Pi 5 Touchscreen) + +``` +┌──────┬──────────────────────────────────────────────┐ +│ NAV │ Filament Inventory [🔍] [⋮] │ +│ RAIL │──────────────────────────────────────────────│ +│ │ [All] [Available] [In Use] [Low] [Depleted] │ +│ 📦 │──────────────────────────────────────────────│ +│ 🖨️ │ ● PLA Basic - Matte Black │ +│ 📋 │ Bambu Lab • Slot A3 • 842g │ +│ ⚙️ │ ▓▓▓▓▓▓▓▓▓▓░░ 67% [Low] │ +│ │──────────────────────────────────────────────│ +│ │ ● PETG Basic - Transparent │ +│ │ Polymaker • Shelf B2 • 210g │ +│ │ ▓▓▓░░░░░░░░░ 21% [Critical] │ +│ │──────────────────────────────────────────────│ +│ │ ● ABS Basic - White │ +│ │ eSUN • Slot B1 • 950g │ +│ │ ▓▓▓▓▓▓▓▓▓▓▓▓ 95% [Available] │ +│ │ [+ Smart │ +│ │ Intake] ↗ │ +└──────┴──────────────────────────────────────────────┘ +``` + +- **Navigation Rail:** 80dp wide, pinned left +- **List area:** 720dp × 420dp usable +- **Visible items:** ~4-5 spools without scrolling +- **Touch targets:** All interactive elements ≥ 48dp (exceeds 44dp minimum for workshop glove use) +- **FAB:** Bottom-right, 16dp margin from rail and screen edge + +### Mobile PWA (375×812, e.g., iPhone 14) + +``` +┌──────────────────────────────────┐ +│ Filament Inventory [🔍] [⋮] │ +│──────────────────────────────────│ +│ [All] [Avail] [In Use] [Low] [×]│ +│──────────────────────────────────│ +│ ● PLA Basic - Matte Black │ +│ Bambu Lab • Slot A3 • 842g │ +│ ▓▓▓▓▓▓▓▓▓▓░░ 67% [Low] │ +│──────────────────────────────────│ +│ ● PETG Basic - Transparent │ +│ Polymaker • Shelf B2 • 210g │ +│ ▓▓▓░░░░░░░░░ 21% [Critical] │ +│──────────────────────────────────│ +│ ● ABS Basic - White │ +│ eSUN • Slot B1 • 950g │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓ 95% [Avail] │ +│──────────────────────────────────│ +│ │ +│ [+ Smart Intake] ↗ │ +│──────────────────────────────────│ +│ 📦 🖨️ 📋 ⚙️ │ +│ Inventory Printers Jobs Settings │ +└──────────────────────────────────┘ +``` + +- **Bottom Navigation:** 80dp, pinned bottom +- **List area:** 375dp × ~600dp usable +- **Visible items:** ~5-6 spools without scrolling +- **FAB:** Floating above bottom nav, right-aligned +- **Filter chips:** Horizontally scrollable when more than screen width + +### Key Adaptations +| Property | Kiosk (800×480) | Mobile (375×812) | +|----------|-----------------|-------------------| +| Navigation | Rail (left) | Bottom bar | +| Search | Collapsed icon by default | Same, but swipe-down gesture also expands | +| List item density | Comfortable (3-line, 72dp) | Same density | +| FAB position | Bottom-right, above list | Bottom-right, above nav bar | +| Filter chips | All visible at once | Horizontally scrollable | +| Sort access | Overflow menu | Overflow menu | + +--- + +## 7. Developer Handoff Notes + +### Angular Material Components + +| UI Element | Angular Material Component | Notes | +|-----------|--------------------------|-------| +| Top App Bar | `` | Use `@angular/material/toolbar`. Medium emphasis variant. | +| Search | Custom wrapper + `` | No native mat-search-bar in Angular Material; implement custom expandable search with `@ViewChild` animation. | +| Filter Chips | `` | Use `mat-chip-option` with `selected` binding. Single-select: deselect others on select. | +| List Items | `` + `` | 3-line variant with `matListItemTitle`, `matListItemLine`, `matListItemMeta`. | +| Progress Bar | `` | Use `mode="determinate"` with `[value]` binding. Custom color classes for warning/error thresholds. | +| Badge | `` | Overlay on list items. Use `matBadgeColor` for status colors. | +| Extended FAB | `