diff --git a/backend/Dockerfile b/backend/Dockerfile index 23aacef..6fdd102 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,37 +1,25 @@ -# ── Stage 1: Build ────────────────────────────────────────── -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build -WORKDIR /src +# Build stage +FROM golang:1.24-alpine AS builder -# Copy csproj first for layer caching — restores before copying source -COPY Extrudex.csproj . -RUN dotnet restore - -# Copy the rest of the source -COPY . . -RUN dotnet publish Extrudex.csproj \ - -c Release \ - -o /app/publish \ - --no-restore - -# ── Stage 2: Runtime ──────────────────────────────────────── -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime WORKDIR /app -# Install curl for health check (not included in aspnet base image) -RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +# Copy go mod files first for caching +COPY go.mod go.sum ./ +RUN go mod download -# Non-root user for security -RUN adduser --disabled-password --gecos "" appuser -USER appuser +# Copy source and build +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server -# Copy published output from build stage -COPY --from=build /app/publish . +# Final stage +FROM alpine:latest +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +# Copy binary from builder +COPY --from=builder /app/server . -# ASP.NET Core listens on 8080 by default in .NET 8+ EXPOSE 8080 -# Health check against /health endpoint -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl --fail http://localhost:8080/health || exit 1 - -ENTRYPOINT ["dotnet", "Extrudex.dll"] \ No newline at end of file +CMD ["./server"] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..5e9cbe5 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/CubeCraft-Creations/Extrudex/backend/internal/config" + "github.com/CubeCraft-Creations/Extrudex/backend/internal/db" + "github.com/CubeCraft-Creations/Extrudex/backend/internal/router" +) + +func main() { + // Setup structured logging + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + // Load configuration + cfg, err := config.Load() + if err != nil { + slog.Error("failed to load config", "error", err) + os.Exit(1) + } + + slog.Info("config loaded", "port", cfg.Port, "cors_origin", cfg.CorsOrigin) + + // Connect to database + dbPool, err := db.NewPool(cfg.DatabaseURL) + if err != nil { + slog.Error("failed to connect to database", "error", err) + os.Exit(1) + } + defer db.ClosePool(dbPool) + + slog.Info("database connected") + + // Create router + r := router.New(cfg, dbPool) + + // Create HTTP server + server := &http.Server{ + Addr: ":" + cfg.Port, + Handler: r, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + slog.Info("server starting", "addr", server.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "error", err) + os.Exit(1) + } + }() + + // Wait for shutdown signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + slog.Info("server shutting down") + + // Graceful shutdown + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + slog.Error("server shutdown error", "error", err) + } + + db.ClosePool(dbPool) + slog.Info("server stopped") +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..dc4de1c --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,9 @@ +module github.com/CubeCraft-Creations/Extrudex/backend + +go 1.24 + +require ( + github.com/go-chi/chi/v5 v5.2.0 + github.com/jackc/pgx/v5 v5.7.4 + github.com/kelseyhightower/envconfig v1.4.0 +) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..64c4bd8 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,24 @@ +package config + +import ( + "fmt" + + "github.com/kelseyhightower/envconfig" +) + +// Config holds all application configuration loaded from environment variables. +type Config struct { + DatabaseURL string `envconfig:"database_url" required:"true"` + Port string `envconfig:"port" default:"8080"` + CorsOrigin string `envconfig:"cors_origin" default:"*"` + LogLevel string `envconfig:"log_level" default:"info"` +} + +// Load reads configuration from environment variables and returns a populated Config. +func Load() (*Config, error) { + var cfg Config + if err := envconfig.Process("", &cfg); err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + return &cfg, nil +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..e165613 --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,34 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// NewPool creates a new pgx connection pool and verifies connectivity with a ping. +func NewPool(databaseURL string) (*pgxpool.Pool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("failed to create db pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("failed to ping db: %w", err) + } + + return pool, nil +} + +// ClosePool gracefully closes the connection pool. +func ClosePool(pool *pgxpool.Pool) { + if pool != nil { + pool.Close() + } +} diff --git a/backend/internal/handlers/health.go b/backend/internal/handlers/health.go new file mode 100644 index 0000000..fcda6b0 --- /dev/null +++ b/backend/internal/handlers/health.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// HealthHandler provides a health check endpoint that verifies database connectivity. +type HealthHandler struct { + dbPool *pgxpool.Pool +} + +// NewHealthHandler creates a new HealthHandler with the given database pool. +func NewHealthHandler(dbPool *pgxpool.Pool) *HealthHandler { + return &HealthHandler{dbPool: dbPool} +} + +// ServeHTTP handles GET /health requests. +func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + dbConnected := false + if h.dbPool != nil { + if err := h.dbPool.Ping(ctx); err == nil { + dbConnected = true + } else { + slog.Warn("health check db ping failed", "error", err) + } + } + + resp := map[string]any{ + "status": "ok", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "db_connected": dbConnected, + } + + w.Header().Set("Content-Type", "application/json") + if !dbConnected { + w.WriteHeader(http.StatusServiceUnavailable) + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + slog.Error("failed to encode health response", "error", err) + } +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..19338ac --- /dev/null +++ b/backend/internal/models/models.go @@ -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"` +} diff --git a/backend/internal/repositories/.gitkeep b/backend/internal/repositories/.gitkeep new file mode 100644 index 0000000..00a3b0e --- /dev/null +++ b/backend/internal/repositories/.gitkeep @@ -0,0 +1 @@ +# Repositories diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go new file mode 100644 index 0000000..c0482fd --- /dev/null +++ b/backend/internal/router/router.go @@ -0,0 +1,45 @@ +package router + +import ( + "log/slog" + "net/http" + "time" + + "github.com/CubeCraft-Creations/Extrudex/backend/internal/config" + "github.com/CubeCraft-Creations/Extrudex/backend/internal/handlers" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/jackc/pgx/v5/pgxpool" +) + +// New creates and configures a Chi router with all middleware and handlers mounted. +func New(cfg *config.Config, dbPool *pgxpool.Pool) chi.Router { + r := chi.NewRouter() + + // Middleware + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(60 * time.Second)) + + // CORS + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", cfg.CorsOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) + }) + + // Health check + healthHandler := handlers.NewHealthHandler(dbPool) + r.Get("/health", healthHandler.ServeHTTP) + + return r +} diff --git a/backend/internal/services/.gitkeep b/backend/internal/services/.gitkeep new file mode 100644 index 0000000..8e5b66b --- /dev/null +++ b/backend/internal/services/.gitkeep @@ -0,0 +1 @@ +# Services diff --git a/backend/migrations/000001_initial_schema.down.sql b/backend/migrations/000001_initial_schema.down.sql new file mode 100644 index 0000000..a7e0f84 --- /dev/null +++ b/backend/migrations/000001_initial_schema.down.sql @@ -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; diff --git a/backend/migrations/000001_initial_schema.up.sql b/backend/migrations/000001_initial_schema.up.sql new file mode 100644 index 0000000..db086fc --- /dev/null +++ b/backend/migrations/000001_initial_schema.up.sql @@ -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; diff --git a/backend/migrations/000002_seed_data.down.sql b/backend/migrations/000002_seed_data.down.sql new file mode 100644 index 0000000..78dd8bf --- /dev/null +++ b/backend/migrations/000002_seed_data.down.sql @@ -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; diff --git a/backend/migrations/000002_seed_data.up.sql b/backend/migrations/000002_seed_data.up.sql new file mode 100644 index 0000000..6c573eb --- /dev/null +++ b/backend/migrations/000002_seed_data.up.sql @@ -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;