Compare commits

...

3 Commits

Author SHA1 Message Date
a5a9f42d06 CUB-111: design PostgreSQL schema for Extrudex Go backend
Some checks failed
Dev Build / build-test (pull_request) Failing after 1m38s
- Add migration 000001: initial schema with all lookup tables, core entities,
  and app settings table
- Add migration 000002: seed data for printer types, job statuses, material
  bases, finishes, modifiers, and default settings
- Add Go model structs in internal/models with json tags and pointer types
  for nullable fields
- Add go.mod with pgx dependency

Key decisions:
- FK ON DELETE: RESTRICT on material_base/finish/printer, SET NULL on
  optional parents (modifier, spool on print_jobs), CASCADE for usage_logs
- Soft-delete (deleted_at) on spools and print_jobs
- Lookup tables for printer_type and job_status (no raw enums)
- Partial indexes for active spools, barcode scans, low-stock queries
- Settings table with JSONB values for flexible app config
- All identifiers snake_case
2026-05-06 11:51:00 -04:00
42285c5dac Merge pull request 'CUB-33: Integrate Moonraker filament usage polling' (#33) from agent/dex/CUB-33-moonraker-usage-polling-v2 into dev
Some checks failed
Dev Build / build-test (push) Failing after 2m26s
2026-04-29 17:18:05 -04:00
9cd619b5ee CUB-33: integrate Moonraker filament usage polling
Some checks failed
Dev Build / build-test (pull_request) Failing after 2m21s
2026-04-29 11:50:18 -04:00
11 changed files with 1062 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
using Extrudex.Domain.Entities;
namespace Extrudex.Domain.Interfaces;
/// <summary>
/// Service for persisting and querying filament usage records.
/// Tracks consumption per print job and per spool for COGS and inventory tracking.
/// </summary>
public interface IFilamentUsageService
{
/// <summary>
/// Records a new filament usage entry for a print job.
/// </summary>
/// <param name="printJobId">The print job that consumed the filament.</param>
/// <param name="spoolId">The spool that provided the filament.</param>
/// <param name="printerId">The printer that executed the print.</param>
/// <param name="gramsUsed">Grams of filament consumed.</param>
/// <param name="mmExtruded">Millimeters of filament extruded.</param>
/// <param name="notes">Optional notes about this usage record.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created FilamentUsage entity.</returns>
Task<FilamentUsage> RecordUsageAsync(
Guid printJobId,
Guid spoolId,
Guid printerId,
decimal gramsUsed,
decimal mmExtruded,
string? notes = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves all filament usage records for a specific print job.
/// </summary>
/// <param name="printJobId">The print job ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of filament usage records for the print job.</returns>
Task<IReadOnlyList<FilamentUsage>> GetByPrintJobAsync(
Guid printJobId,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves all filament usage records for a specific spool.
/// </summary>
/// <param name="spoolId">The spool ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of filament usage records for the spool.</returns>
Task<IReadOnlyList<FilamentUsage>> GetBySpoolAsync(
Guid spoolId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,79 @@
using Extrudex.Domain.Entities;
using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Extrudex.Infrastructure.Services;
/// <summary>
/// EF Corebacked implementation of the filament usage service.
/// Persists usage records to the database and provides query methods
/// for retrieving usage by print job or spool.
/// </summary>
public class FilamentUsageService : IFilamentUsageService
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<FilamentUsageService> _logger;
public FilamentUsageService(
ExtrudexDbContext dbContext,
ILogger<FilamentUsageService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <inheritdoc />
public async Task<FilamentUsage> RecordUsageAsync(
Guid printJobId,
Guid spoolId,
Guid printerId,
decimal gramsUsed,
decimal mmExtruded,
string? notes = null,
CancellationToken cancellationToken = default)
{
var usage = new FilamentUsage
{
PrintJobId = printJobId,
SpoolId = spoolId,
PrinterId = printerId,
GramsUsed = gramsUsed,
MmExtruded = mmExtruded,
RecordedAt = DateTime.UtcNow,
Notes = notes
};
_dbContext.FilamentUsages.Add(usage);
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Recorded filament usage: {Grams}g / {Mm}mm for print job {JobId} on spool {SpoolId}",
gramsUsed, mmExtruded, printJobId, spoolId);
return usage;
}
/// <inheritdoc />
public async Task<IReadOnlyList<FilamentUsage>> GetByPrintJobAsync(
Guid printJobId,
CancellationToken cancellationToken = default)
{
return await _dbContext.FilamentUsages
.Where(u => u.PrintJobId == printJobId)
.OrderByDescending(u => u.RecordedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<FilamentUsage>> GetBySpoolAsync(
Guid spoolId,
CancellationToken cancellationToken = default)
{
return await _dbContext.FilamentUsages
.Where(u => u.SpoolId == spoolId)
.OrderByDescending(u => u.RecordedAt)
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,390 @@
using Extrudex.Domain.DTOs.Moonraker;
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Extrudex.Infrastructure.Services;
/// <summary>
/// Configuration options for the Moonraker usage polling service.
/// </summary>
public class MoonrakerPollerOptions
{
/// <summary>
/// How often to poll each Moonraker printer for filament usage data.
/// Default: 30 seconds.
/// </summary>
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Timeout for individual Moonraker HTTP requests.
/// Default: 10 seconds.
/// </summary>
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Whether the polling service is enabled. Default: true.
/// Set to false to disable polling (e.g., in development or testing).
/// </summary>
public bool Enabled { get; set; } = true;
}
/// <summary>
/// Background service that periodically polls Moonraker-connected printers
/// for filament usage data. When a print job is detected as complete,
/// the usage data is persisted to the FilamentUsage table via
/// <see cref="IFilamentUsageService"/>.
///
/// <para>Polling logic:</para>
/// <list type="number">
/// <item>Query the database for all active printers with ConnectionType == Moonraker.</item>
/// <item>For each printer, call <see cref="IMoonrakerClient.GetPrintStatsAsync"/> for live data
/// and <see cref="IMoonrakerClient.GetPrintHistoryAsync"/> for completed job history.</item>
/// <item>If usage data is available and the print state is "complete",
/// create or update a FilamentUsage record.</item>
/// <item>If the printer is unreachable or returns malformed data, log a warning
/// and continue to the next printer (no crash).</item>
/// </list>
///
/// <para>Error handling:</para>
/// <list type="bullet">
/// <item>API unreachable: logged as warning, poller continues for other printers.</item>
/// <item>Malformed response: logged as warning, poller continues.</item>
/// <item>Database errors: logged as error, poller continues.</item>
/// </list>
/// </summary>
public class MoonrakerUsagePoller : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<MoonrakerUsagePoller> _logger;
private readonly MoonrakerPollerOptions _options;
/// <summary>
/// Tracks which Moonraker print jobs have already been recorded,
/// keyed by "printerId:gcodeFileName" to avoid duplicate recording.
/// </summary>
private readonly HashSet<string> _recordedJobs = new();
public MoonrakerUsagePoller(
IServiceScopeFactory scopeFactory,
ILogger<MoonrakerUsagePoller> logger,
IOptions<MoonrakerPollerOptions> options)
{
_scopeFactory = scopeFactory;
_logger = logger;
_options = options.Value;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Enabled)
{
_logger.LogInformation("Moonraker usage poller is disabled via configuration.");
return;
}
_logger.LogInformation(
"Moonraker usage poller starting. Poll interval: {Interval}",
_options.PollInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await PollAllPrintersAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Unexpected error in Moonraker usage poller cycle. Continuing.");
}
await Task.Delay(_options.PollInterval, stoppingToken);
}
_logger.LogInformation("Moonraker usage poller stopping.");
}
private async Task PollAllPrintersAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ExtrudexDbContext>();
var moonrakerClient = scope.ServiceProvider.GetRequiredService<IMoonrakerClient>();
var usageService = scope.ServiceProvider.GetRequiredService<IFilamentUsageService>();
var printers = await dbContext.Printers
.Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker)
.ToListAsync(cancellationToken);
if (printers.Count == 0)
{
_logger.LogDebug("No active Moonraker printers found.");
return;
}
_logger.LogDebug("Polling {Count} Moonraker printer(s).", printers.Count);
foreach (var printer in printers)
{
await PollPrinterAsync(
printer, moonrakerClient, usageService, dbContext, cancellationToken);
}
}
private async Task PollPrinterAsync(
Printer printer,
IMoonrakerClient moonrakerClient,
IFilamentUsageService usageService,
ExtrudexDbContext dbContext,
CancellationToken cancellationToken)
{
_logger.LogDebug(
"Polling Moonraker printer {PrinterName} ({Host}:{Port})",
printer.Name, printer.HostnameOrIp, printer.Port);
try
{
var printStats = await moonrakerClient.GetPrintStatsAsync(
printer.HostnameOrIp,
printer.Port,
printer.ApiKey,
cancellationToken);
if (printStats is null)
{
_logger.LogDebug(
"No print stats available from printer {PrinterName}.", printer.Name);
return;
}
printer.LastSeenAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogDebug(
"Printer {PrinterName}: state={State}, filament={Mm}mm, file={File}",
printer.Name, printStats.State, printStats.FilamentUsedMm, printStats.Filename);
decimal mmExtruded = printStats.FilamentUsedMm;
if (mmExtruded <= 0)
{
_logger.LogDebug(
"Printer {PrinterName} has no filament usage to record.", printer.Name);
return;
}
if (!IsCompleteState(printStats.State))
{
_logger.LogDebug(
"Printer {PrinterName} print state '{State}' is not complete; skipping.",
printer.Name, printStats.State);
return;
}
string gcodeFileName = printStats.Filename ?? $"unknown-{Guid.NewGuid():N}";
var deduplicationKey = $"{printer.Id}:{gcodeFileName}";
if (_recordedJobs.Contains(deduplicationKey))
{
_logger.LogDebug(
"Printer {PrinterName} job '{File}' already recorded; skipping.",
printer.Name, gcodeFileName);
return;
}
DateTime? startedAt = null;
DateTime? completedAt = null;
try
{
var history = await moonrakerClient.GetPrintHistoryAsync(
printer.HostnameOrIp, printer.Port, printer.ApiKey,
limit: 1, cancellationToken);
if (history.Items.Count > 0)
{
var latestJob = history.Items[0];
startedAt = latestJob.StartTime;
completedAt = latestJob.EndTime;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"Could not fetch history for printer {PrinterName}; proceeding with stats only.",
printer.Name);
}
var printJob = await FindOrCreatePrintJobAsync(
dbContext, printer, mmExtruded, gcodeFileName,
startedAt, completedAt, cancellationToken);
if (printJob is null)
{
_logger.LogWarning(
"Could not find or create print job for printer {PrinterName}. No active spool found.",
printer.Name);
return;
}
var spool = await dbContext.Spools.FindAsync(
new object[] { printJob.SpoolId }, cancellationToken);
var gramsUsed = CalculateGramsUsed(mmExtruded, spool);
await usageService.RecordUsageAsync(
printJobId: printJob.Id,
spoolId: printJob.SpoolId,
printerId: printer.Id,
gramsUsed: gramsUsed,
mmExtruded: mmExtruded,
notes: $"Moonraker auto-recorded: {gcodeFileName}",
cancellationToken: cancellationToken);
_recordedJobs.Add(deduplicationKey);
_logger.LogInformation(
"Recorded Moonraker usage for printer {PrinterName}: {Mm}mm / {Grams}g, job '{File}'",
printer.Name, mmExtruded, gramsUsed, gcodeFileName);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex,
"Moonraker API unreachable for printer {PrinterName} ({Host}:{Port}). Will retry next cycle.",
printer.Name, printer.HostnameOrIp, printer.Port);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogWarning(ex,
"Moonraker request timed out for printer {PrinterName} ({Host}:{Port}).",
printer.Name, printer.HostnameOrIp, printer.Port);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Unexpected error polling Moonraker printer {PrinterName}. Continuing to next printer.",
printer.Name);
}
}
private static bool IsCompleteState(string state) =>
state.Equals("complete", StringComparison.OrdinalIgnoreCase) ||
state.Equals("completed", StringComparison.OrdinalIgnoreCase);
private async Task<PrintJob?> FindOrCreatePrintJobAsync(
ExtrudexDbContext dbContext,
Printer printer,
decimal mmExtruded,
string gcodeFileName,
DateTime? startedAt,
DateTime? completedAt,
CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(gcodeFileName))
{
var existingJob = await dbContext.PrintJobs
.Where(j => j.PrinterId == printer.Id &&
j.GcodeFilePath == gcodeFileName &&
j.DataSource == DataSource.Moonraker &&
j.Status != JobStatus.Cancelled)
.OrderByDescending(j => j.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (existingJob is not null)
{
existingJob.MmExtruded = mmExtruded;
existingJob.GramsDerived = CalculateGramsUsed(
mmExtruded,
await dbContext.Spools.FindAsync(
new object[] { existingJob.SpoolId }, cancellationToken));
existingJob.Status = JobStatus.Completed;
existingJob.CompletedAt = completedAt ?? DateTime.UtcNow;
existingJob.StartedAt ??= startedAt;
await dbContext.SaveChangesAsync(cancellationToken);
return existingJob;
}
}
var spool = await FindActiveSpoolForPrinterAsync(dbContext, printer, cancellationToken);
if (spool is null) return null;
var gramsDerived = CalculateGramsUsed(mmExtruded, spool);
var newJob = new PrintJob
{
PrinterId = printer.Id,
SpoolId = spool.Id,
PrintName = gcodeFileName ?? "Moonraker Print",
GcodeFilePath = gcodeFileName,
MmExtruded = mmExtruded,
GramsDerived = gramsDerived,
FilamentDiameterAtPrintMm = spool.FilamentDiameterMm,
MaterialDensityAtPrint = GetMaterialDensity(spool),
DataSource = DataSource.Moonraker,
Status = JobStatus.Completed,
StartedAt = startedAt ?? DateTime.UtcNow,
CompletedAt = completedAt ?? DateTime.UtcNow,
Notes = "Auto-created by Moonraker usage poller"
};
dbContext.PrintJobs.Add(newJob);
await dbContext.SaveChangesAsync(cancellationToken);
return newJob;
}
private static async Task<Spool?> FindActiveSpoolForPrinterAsync(
ExtrudexDbContext dbContext,
Printer printer,
CancellationToken cancellationToken)
{
var amsSpool = await dbContext.AmsSlots
.Include(s => s.Spool)
.ThenInclude(s => s!.MaterialBase)
.Include(s => s.AmsUnit)
.Where(s => s.AmsUnit.PrinterId == printer.Id && s.Spool != null && s.Spool.IsActive)
.Select(s => s.Spool)
.FirstOrDefaultAsync(cancellationToken);
if (amsSpool is not null) return amsSpool;
return await dbContext.Spools
.Include(s => s.MaterialBase)
.Where(s => s.IsActive)
.OrderByDescending(s => s.WeightRemainingGrams)
.FirstOrDefaultAsync(cancellationToken);
}
private static decimal CalculateGramsUsed(decimal mmExtruded, Spool? spool)
{
if (spool is null) return 0m;
var diameterMm = spool.FilamentDiameterMm;
var densityGcm3 = GetMaterialDensity(spool);
var radiusMm = diameterMm / 2m;
var crossSectionArea = Math.PI * (double)radiusMm * (double)radiusMm;
var volumeMm3 = (double)mmExtruded * crossSectionArea;
var volumeCm3 = volumeMm3 / 1000.0;
var grams = volumeCm3 * (double)densityGcm3;
return Math.Round((decimal)grams, 2);
}
private static decimal GetMaterialDensity(Spool? spool)
{
return spool?.MaterialBase?.Name?.ToUpperInvariant() switch
{
"PLA" => 1.24m,
"PETG" => 1.27m,
"ABS" => 1.04m,
"ASA" => 1.07m,
"TPU" => 1.21m,
"NYLON" or "PA" => 1.13m,
"PC" => 1.20m,
_ => 1.24m
};
}
}

View File

@@ -61,6 +61,14 @@ builder.Services.AddSingleton<ILowStockDetector, LowStockDetector>();
// ── Usage Logging ───────────────────────────────────────────
builder.Services.AddScoped<IUsageLogService, UsageLogService>();
// ── Filament Usage Service ──────────────────────────────────
builder.Services.AddScoped<IFilamentUsageService, FilamentUsageService>();
// ── Moonraker Usage Poller (Background Service) ─────────────
builder.Services.Configure<MoonrakerPollerOptions>(
builder.Configuration.GetSection("MoonrakerPoller"));
builder.Services.AddHostedService<MoonrakerUsagePoller>();
// ── FluentValidation ──────────────────────────────────────
// Registers all validators from the API assembly into DI.
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());

View File

@@ -23,5 +23,11 @@
},
"FilamentAlerts": {
"LowStockThresholdPercent": 20
},
"MoonrakerPoller": {
"Enabled": true,
"PollInterval": "00:00:30",
"RequestTimeout": "00:00:10"
}
}
}

7
backend/go.mod Normal file
View File

@@ -0,0 +1,7 @@
module github.com/CubeCraft-Creations/Extrudex/backend
go 1.24
require (
github.com/jackc/pgx/v5 v5.7.4
)

View File

@@ -0,0 +1,162 @@
// Package models defines the Extrudex domain model structs.
// These map 1:1 to PostgreSQL tables with snake_case JSON serialization.
// Nullable fields use pointer types; all timestamps are time.Time.
package models
import "time"
// ============================================================================
// Lookup Tables
// ============================================================================
// PrinterType represents a printer technology category (fdm, resin, etc.).
type PrinterType struct {
ID int `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// JobStatus represents a print job lifecycle state.
type JobStatus struct {
ID int `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MaterialBase represents a base material type (PLA, PETG, ABS, etc.).
// Density and temperature ranges are stored here for grams-calculation and slicing guidance.
type MaterialBase struct {
ID int `json:"id"`
Name string `json:"name"`
DensityGCm3 float64 `json:"density_g_cm3"`
ExtrusionTempMin *int `json:"extrusion_temp_min,omitempty"`
ExtrusionTempMax *int `json:"extrusion_temp_max,omitempty"`
BedTempMin *int `json:"bed_temp_min,omitempty"`
BedTempMax *int `json:"bed_temp_max,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MaterialFinish represents the visual/texture finish (Basic, Silk, Matte, etc.).
type MaterialFinish struct {
ID int `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MaterialModifier represents an additive property (Carbon Fiber, Wood-Filled, etc.).
type MaterialModifier struct {
ID int `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ============================================================================
// Core Entity Tables
// ============================================================================
// Printer represents a 3D printer in the fleet.
type Printer struct {
ID int `json:"id"`
Name string `json:"name"`
PrinterTypeID int `json:"printer_type_id"`
PrinterType *PrinterType `json:"printer_type,omitempty"` // populated on JOIN queries
Manufacturer *string `json:"manufacturer,omitempty"`
Model *string `json:"model,omitempty"`
MoonrakerURL *string `json:"moonraker_url,omitempty"`
MoonrakerAPIKey *string `json:"moonraker_api_key,omitempty"`
MQTTBrokerHost *string `json:"mqtt_broker_host,omitempty"`
MQTTTopicPrefix *string `json:"mqtt_topic_prefix,omitempty"`
MQTTTLSEnabled bool `json:"mqtt_tls_enabled"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// FilamentSpool represents a physical filament spool in inventory.
// material_finish_id defaults to 1 ("Basic"); material_modifier_id is optional.
// Grams are always physically measured values — grams_used is derived, not stored.
type FilamentSpool struct {
ID int `json:"id"`
Name string `json:"name"`
MaterialBaseID int `json:"material_base_id"`
MaterialBase *MaterialBase `json:"material_base,omitempty"` // JOIN
MaterialFinishID int `json:"material_finish_id"`
MaterialFinish *MaterialFinish `json:"material_finish,omitempty"` // JOIN
MaterialModifierID *int `json:"material_modifier_id,omitempty"`
MaterialModifier *MaterialModifier `json:"material_modifier,omitempty"` // JOIN
ColorHex string `json:"color_hex"`
Brand *string `json:"brand,omitempty"`
DiameterMM float64 `json:"diameter_mm"`
InitialGrams int `json:"initial_grams"`
RemainingGrams int `json:"remaining_grams"`
SpoolWeightGrams *int `json:"spool_weight_grams,omitempty"`
CostUSD *float64 `json:"cost_usd,omitempty"`
LowStockThresholdGrams int `json:"low_stock_threshold_grams"`
Notes *string `json:"notes,omitempty"`
Barcode *string `json:"barcode,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PrintJob represents a single print on a specific printer.
// The filament_spool_id is a convenience reference; multi-spool jobs track usage in usage_logs.
type PrintJob struct {
ID int `json:"id"`
PrinterID int `json:"printer_id"`
Printer *Printer `json:"printer,omitempty"` // JOIN
FilamentSpoolID *int `json:"filament_spool_id,omitempty"`
FilamentSpool *FilamentSpool `json:"filament_spool,omitempty"` // JOIN
JobName string `json:"job_name"`
FileName *string `json:"file_name,omitempty"`
JobStatusID int `json:"job_status_id"`
JobStatus *JobStatus `json:"job_status,omitempty"` // JOIN
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
DurationSeconds *int `json:"duration_seconds,omitempty"`
EstimatedDurationSeconds *int `json:"estimated_duration_seconds,omitempty"`
TotalMMExtruded *float64 `json:"total_mm_extruded,omitempty"`
TotalGramsUsed *float64 `json:"total_grams_used,omitempty"`
TotalCostUSD *float64 `json:"total_cost_usd,omitempty"`
Notes *string `json:"notes,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UsageLog records filament consumption for a specific spool during a print job.
// This is the atomic unit of filament tracking — grams are derived from mm_extruded.
type UsageLog struct {
ID int `json:"id"`
PrintJobID int `json:"print_job_id"`
PrintJob *PrintJob `json:"print_job,omitempty"` // JOIN
FilamentSpoolID int `json:"filament_spool_id"`
FilamentSpool *FilamentSpool `json:"filament_spool,omitempty"` // JOIN
MMExtruded float64 `json:"mm_extruded"`
GramsUsed float64 `json:"grams_used"`
CostUSD *float64 `json:"cost_usd,omitempty"`
LoggedAt time.Time `json:"logged_at"`
CreatedAt time.Time `json:"created_at"`
}
// ============================================================================
// Application Settings
// ============================================================================
// Setting represents a key-value application configuration entry.
// The value is stored as JSONB in PostgreSQL, allowing flexible typed config.
type Setting struct {
ID int `json:"id"`
Key string `json:"key"`
Value []byte `json:"value"` // raw JSON — marshalled/unmarshalled by caller
Description *string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,19 @@
-- Migration: 000001_initial_schema (rollback)
-- Description: Drop all tables and indexes created in the initial schema migration
-- Author: Hex
-- Date: 2026-05-06
BEGIN;
DROP TABLE IF EXISTS usage_logs CASCADE;
DROP TABLE IF EXISTS print_jobs CASCADE;
DROP TABLE IF EXISTS filament_spools CASCADE;
DROP TABLE IF EXISTS printers CASCADE;
DROP TABLE IF EXISTS settings CASCADE;
DROP TABLE IF EXISTS material_modifiers CASCADE;
DROP TABLE IF EXISTS material_finishes CASCADE;
DROP TABLE IF EXISTS material_bases CASCADE;
DROP TABLE IF EXISTS job_statuses CASCADE;
DROP TABLE IF EXISTS printer_types CASCADE;
COMMIT;

View File

@@ -0,0 +1,231 @@
-- Migration: 000001_initial_schema
-- Description: Create initial Extrudex schema — lookup tables, core entities, and settings
-- Author: Hex
-- Date: 2026-05-06
--
-- Design decisions:
-- - Lookup tables for material_base, material_finish, material_modifier (no free-text enums)
-- - Lookup tables for printer_type and job_status (extensible, no hard-coded enum values)
-- - FK ON DELETE: RESTRICT on critical parents (material_base, material_finish, printer),
-- SET NULL on optional parents (modifier, spool on print_jobs),
-- CASCADE for usage_logs when parent job is deleted
-- - Soft-delete (deleted_at) on spools and print_jobs for safety
-- - JSONB config column on settings for flexible app-wide configuration
-- - All identifiers snake_case per project convention
BEGIN;
-- ============================================================================
-- Lookup Tables
-- ============================================================================
-- Printer types (fdm, resin, etc.) — extensible, not a raw enum
CREATE TABLE printer_types (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Job statuses (pending, printing, paused, completed, failed, cancelled)
CREATE TABLE job_statuses (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Material base types (PLA, PETG, ABS, TPU, ASA, Nylon, PC)
CREATE TABLE material_bases (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
density_g_cm3 DECIMAL(5,3) NOT NULL,
extrusion_temp_min INT,
extrusion_temp_max INT,
bed_temp_min INT,
bed_temp_max INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Material finishes (Basic, Silk, Matte, Glossy, Satin)
CREATE TABLE material_finishes (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Material modifiers (Wood-Filled, Carbon Fiber, Glow-in-Dark, Marble)
CREATE TABLE material_modifiers (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================================
-- Core Entity Tables
-- ============================================================================
-- 3D printers in the fleet
CREATE TABLE printers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
printer_type_id INT NOT NULL,
manufacturer VARCHAR(255),
model VARCHAR(255),
moonraker_url VARCHAR(512),
moonraker_api_key VARCHAR(512),
mqtt_broker_host VARCHAR(255),
mqtt_topic_prefix VARCHAR(255),
mqtt_tls_enabled BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_printers_printer_type
FOREIGN KEY (printer_type_id) REFERENCES printer_types(id)
ON DELETE RESTRICT
);
-- Filament spools — the core inventory item
CREATE TABLE filament_spools (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
material_base_id INT NOT NULL,
material_finish_id INT NOT NULL DEFAULT 1, -- "Basic" (seed data populates this first)
material_modifier_id INT,
color_hex VARCHAR(7) NOT NULL CHECK (color_hex ~ '^#[0-9A-Fa-f]{6}$'),
brand VARCHAR(255),
diameter_mm DECIMAL(4,2) NOT NULL DEFAULT 1.75,
initial_grams INT NOT NULL CHECK (initial_grams > 0),
remaining_grams INT NOT NULL CHECK (remaining_grams >= 0),
spool_weight_grams INT, -- measured empty-spool weight (tare), nullable
cost_usd DECIMAL(10,2),
low_stock_threshold_grams INT NOT NULL DEFAULT 50,
notes TEXT,
barcode VARCHAR(255) UNIQUE,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_spools_material_base
FOREIGN KEY (material_base_id) REFERENCES material_bases(id)
ON DELETE RESTRICT,
CONSTRAINT fk_spools_material_finish
FOREIGN KEY (material_finish_id) REFERENCES material_finishes(id)
ON DELETE RESTRICT,
CONSTRAINT fk_spools_material_modifier
FOREIGN KEY (material_modifier_id) REFERENCES material_modifiers(id)
ON DELETE SET NULL
);
-- Print jobs — each job is one print on one printer
CREATE TABLE print_jobs (
id SERIAL PRIMARY KEY,
printer_id INT NOT NULL,
filament_spool_id INT, -- nullable: a job may use multiple spools (captured in usage_logs)
job_name VARCHAR(255) NOT NULL,
file_name VARCHAR(512),
job_status_id INT NOT NULL DEFAULT 1, -- "pending"
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
duration_seconds INT,
estimated_duration_seconds INT,
total_mm_extruded DECIMAL(12,2),
total_grams_used DECIMAL(10,2),
total_cost_usd DECIMAL(10,4),
notes TEXT,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_print_jobs_printer
FOREIGN KEY (printer_id) REFERENCES printers(id)
ON DELETE RESTRICT,
CONSTRAINT fk_print_jobs_spool
FOREIGN KEY (filament_spool_id) REFERENCES filament_spools(id)
ON DELETE SET NULL,
CONSTRAINT fk_print_jobs_status
FOREIGN KEY (job_status_id) REFERENCES job_statuses(id)
ON DELETE RESTRICT
);
-- Usage logs — granular tracking of filament consumed per job, per spool
CREATE TABLE usage_logs (
id SERIAL PRIMARY KEY,
print_job_id INT NOT NULL,
filament_spool_id INT NOT NULL,
mm_extruded DECIMAL(12,2) NOT NULL CHECK (mm_extruded > 0),
grams_used DECIMAL(10,2) NOT NULL CHECK (grams_used > 0),
cost_usd DECIMAL(10,4),
logged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_usage_logs_print_job
FOREIGN KEY (print_job_id) REFERENCES print_jobs(id)
ON DELETE CASCADE,
CONSTRAINT fk_usage_logs_spool
FOREIGN KEY (filament_spool_id) REFERENCES filament_spools(id)
ON DELETE RESTRICT
);
-- ============================================================================
-- Application Settings
-- ============================================================================
CREATE TABLE settings (
id SERIAL PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
value JSONB NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================================
-- Indexes
-- ============================================================================
-- Filament spools — query patterns: lookup by material, low-stock scans, barcode scans
CREATE INDEX ix_spools_material_base_id ON filament_spools(material_base_id);
CREATE INDEX ix_spools_material_finish_id ON filament_spools(material_finish_id);
CREATE INDEX ix_spools_material_modifier_id ON filament_spools(material_modifier_id);
CREATE INDEX ix_spools_remaining_grams ON filament_spools(remaining_grams)
WHERE deleted_at IS NULL; -- partial index: only active spools for low-stock queries
CREATE INDEX ix_spools_barcode ON filament_spools(barcode)
WHERE barcode IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX ix_spools_deleted_at ON filament_spools(deleted_at)
WHERE deleted_at IS NOT NULL; -- small index for soft-delete filtering
-- Printers
CREATE INDEX ix_printers_printer_type_id ON printers(printer_type_id);
CREATE INDEX ix_printers_is_active ON printers(is_active)
WHERE is_active = TRUE; -- partial index for fleet dashboard queries
-- Print jobs — query by printer, status, date range, and soft-delete filter
CREATE INDEX ix_print_jobs_printer_id ON print_jobs(printer_id);
CREATE INDEX ix_print_jobs_spool_id ON print_jobs(filament_spool_id)
WHERE filament_spool_id IS NOT NULL;
CREATE INDEX ix_print_jobs_status_id ON print_jobs(job_status_id);
CREATE INDEX ix_print_jobs_created_at ON print_jobs(created_at DESC);
CREATE INDEX ix_print_jobs_deleted_at ON print_jobs(deleted_at)
WHERE deleted_at IS NOT NULL;
-- Usage logs — always queried by job or spool
CREATE INDEX ix_usage_logs_print_job_id ON usage_logs(print_job_id);
CREATE INDEX ix_usage_logs_spool_id ON usage_logs(filament_spool_id);
CREATE INDEX ix_usage_logs_logged_at ON usage_logs(logged_at DESC);
-- Settings — key lookups
CREATE INDEX ix_settings_key ON settings(key);
COMMIT;

View File

@@ -0,0 +1,15 @@
-- Migration: 000002_seed_data (rollback)
-- Description: Remove seed data inserted in 000002
-- Author: Hex
-- Date: 2026-05-06
BEGIN;
DELETE FROM settings WHERE key IN ('default_low_stock_threshold_grams', 'default_diameter_mm', 'filament_cross_section_area_mm2');
DELETE FROM material_modifiers WHERE id IN (1, 2, 3, 4);
DELETE FROM material_finishes WHERE id IN (1, 2, 3, 4, 5);
DELETE FROM material_bases WHERE id IN (1, 2, 3, 4, 5, 6, 7);
DELETE FROM job_statuses WHERE id IN (1, 2, 3, 4, 5, 6);
DELETE FROM printer_types WHERE id IN (1, 2);
COMMIT;

View File

@@ -0,0 +1,95 @@
-- Seed Data: Extrudex common reference data
-- Author: Hex
-- Date: 2026-05-06
--
-- IMPORTANT: IDs are explicitly assigned to satisfy the DEFAULT constraints:
-- - filament_spools.material_finish_id DEFAULT 1 ("Basic")
-- - print_jobs.job_status_id DEFAULT 1 ("pending")
--
-- Density values sourced from common manufacturer specifications.
-- Temperature ranges are conservative/typical; users can override per-spool.
BEGIN;
-- ============================================================================
-- Printer Types
-- ============================================================================
INSERT INTO printer_types (id, name) VALUES
(1, 'fdm'),
(2, 'resin')
ON CONFLICT (id) DO NOTHING;
-- Reset the sequence so future inserts start after our explicit IDs
SELECT setval('printer_types_id_seq', GREATEST(2, (SELECT MAX(id) FROM printer_types)));
-- ============================================================================
-- Job Statuses
-- ============================================================================
INSERT INTO job_statuses (id, name) VALUES
(1, 'pending'),
(2, 'printing'),
(3, 'paused'),
(4, 'completed'),
(5, 'failed'),
(6, 'cancelled')
ON CONFLICT (id) DO NOTHING;
SELECT setval('job_statuses_id_seq', GREATEST(6, (SELECT MAX(id) FROM job_statuses)));
-- ============================================================================
-- Material Bases (common filament types)
-- ============================================================================
INSERT INTO material_bases (id, name, density_g_cm3, extrusion_temp_min, extrusion_temp_max, bed_temp_min, bed_temp_max) VALUES
(1, 'PLA', 1.24, 190, 220, 0, 60),
(2, 'PETG', 1.27, 230, 250, 70, 90),
(3, 'ABS', 1.04, 230, 260, 90, 110),
(4, 'TPU', 1.21, 220, 250, 0, 60),
(5, 'ASA', 1.07, 240, 260, 90, 110),
(6, 'Nylon', 1.14, 240, 280, 70, 100),
(7, 'PC', 1.20, 260, 310, 90, 120)
ON CONFLICT (id) DO NOTHING;
SELECT setval('material_bases_id_seq', GREATEST(7, (SELECT MAX(id) FROM material_bases)));
-- ============================================================================
-- Material Finishes
-- ============================================================================
-- ID 1 = "Basic" is the default for new spools (DEFAULT 1 constraint)
INSERT INTO material_finishes (id, name, description) VALUES
(1, 'Basic', 'Standard solid-color filament with no special finish'),
(2, 'Silk', 'Glossy silk-like sheen, often used for decorative prints'),
(3, 'Matte', 'Flat non-reflective surface finish'),
(4, 'Glossy', 'High-shine reflective surface'),
(5, 'Satin', 'Semi-gloss between matte and glossy')
ON CONFLICT (id) DO NOTHING;
SELECT setval('material_finishes_id_seq', GREATEST(5, (SELECT MAX(id) FROM material_finishes)));
-- ============================================================================
-- Material Modifiers
-- ============================================================================
INSERT INTO material_modifiers (id, name, description) VALUES
(1, 'Wood-Filled', 'Contains wood fibers for natural wood-like appearance and texture'),
(2, 'Carbon Fiber', 'Reinforced with carbon fibers for increased stiffness and strength'),
(3, 'Glow-in-Dark', 'Phosphorescent additive that glows after exposure to light'),
(4, 'Marble', 'Contains specks for a stone-like marble appearance')
ON CONFLICT (id) DO NOTHING;
SELECT setval('material_modifiers_id_seq', GREATEST(4, (SELECT MAX(id) FROM material_modifiers)));
-- ============================================================================
-- Default Application Settings
-- ============================================================================
INSERT INTO settings (key, value, description) VALUES
('default_low_stock_threshold_grams', '50', 'Default grams threshold for low-stock alerts on new spools'),
('default_diameter_mm', '1.75', 'Default filament diameter for new spools (1.75mm is the modern standard)'),
('filament_cross_section_area_mm2', '2.405', 'Cross-sectional area for 1.75mm filament: π × (1.75/2)²')
ON CONFLICT (key) DO NOTHING;
COMMIT;