286 lines
8.6 KiB
Go
286 lines
8.6 KiB
Go
|
|
package repositories
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/CubeCraft-Creations/Extrudex/backend/internal/models"
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
)
|
||
|
|
|
||
|
|
// FilamentRepository handles database queries for filament_spools.
|
||
|
|
type FilamentRepository struct {
|
||
|
|
pool *pgxpool.Pool
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewFilamentRepository creates a FilamentRepository backed by the given pool.
|
||
|
|
func NewFilamentRepository(pool *pgxpool.Pool) *FilamentRepository {
|
||
|
|
return &FilamentRepository{pool: pool}
|
||
|
|
}
|
||
|
|
|
||
|
|
// FilamentFilter holds query parameters for listing filament spools.
|
||
|
|
type FilamentFilter struct {
|
||
|
|
Material string // filter by material_base name (case-insensitive)
|
||
|
|
Finish string // filter by material_finish name (case-insensitive)
|
||
|
|
Color string // filter by exact color_hex match
|
||
|
|
LowStock bool // if true, filter for remaining_grams <= low_stock_threshold_grams
|
||
|
|
Limit int
|
||
|
|
Offset int
|
||
|
|
}
|
||
|
|
|
||
|
|
// spoolScanFields is the common SELECT column list for filament spools with JOINs.
|
||
|
|
const spoolScanFields = `
|
||
|
|
s.id, s.name,
|
||
|
|
s.material_base_id,
|
||
|
|
COALESCE(mb.name, '') as material_base_name,
|
||
|
|
COALESCE(mb.density_g_cm3, 0) as material_base_density_g_cm3,
|
||
|
|
COALESCE(mb.extrusion_temp_min, NULL::int) as material_base_extrusion_temp_min,
|
||
|
|
COALESCE(mb.extrusion_temp_max, NULL::int) as material_base_extrusion_temp_max,
|
||
|
|
COALESCE(mb.bed_temp_min, NULL::int) as material_base_bed_temp_min,
|
||
|
|
COALESCE(mb.bed_temp_max, NULL::int) as material_base_bed_temp_max,
|
||
|
|
COALESCE(mb.created_at, s.created_at) as material_base_created_at,
|
||
|
|
COALESCE(mb.updated_at, s.created_at) as material_base_updated_at,
|
||
|
|
s.material_finish_id,
|
||
|
|
COALESCE(mf.name, '') as material_finish_name,
|
||
|
|
mf.description as material_finish_description,
|
||
|
|
COALESCE(mf.created_at, s.created_at) as material_finish_created_at,
|
||
|
|
COALESCE(mf.updated_at, s.created_at) as material_finish_updated_at,
|
||
|
|
s.material_modifier_id,
|
||
|
|
mm.name as material_modifier_name,
|
||
|
|
mm.description as material_modifier_description,
|
||
|
|
mm.created_at as material_modifier_created_at,
|
||
|
|
mm.updated_at as material_modifier_updated_at,
|
||
|
|
s.color_hex, s.brand, s.diameter_mm,
|
||
|
|
s.initial_grams, s.remaining_grams, s.spool_weight_grams,
|
||
|
|
s.cost_usd, s.low_stock_threshold_grams,
|
||
|
|
s.notes, s.barcode,
|
||
|
|
s.deleted_at, s.created_at, s.updated_at`
|
||
|
|
|
||
|
|
const spoolFromJoins = `
|
||
|
|
FROM filament_spools s
|
||
|
|
LEFT JOIN material_bases mb ON s.material_base_id = mb.id
|
||
|
|
LEFT JOIN material_finishes mf ON s.material_finish_id = mf.id
|
||
|
|
LEFT JOIN material_modifiers mm ON s.material_modifier_id = mm.id`
|
||
|
|
|
||
|
|
// scanSpoolWithJoins scans a full spool row including all JOINed tables.
|
||
|
|
func scanSpoolWithJoins(row interface{ Scan(...interface{}) error }) (models.FilamentSpool, error) {
|
||
|
|
var s models.FilamentSpool
|
||
|
|
var mb models.MaterialBase
|
||
|
|
var mf models.MaterialFinish
|
||
|
|
var mfDesc *string
|
||
|
|
var modifierID *int
|
||
|
|
var modName, modDesc *string
|
||
|
|
var modCreatedAt, modUpdatedAt *time.Time
|
||
|
|
|
||
|
|
err := row.Scan(
|
||
|
|
&s.ID, &s.Name,
|
||
|
|
&s.MaterialBaseID,
|
||
|
|
&mb.Name, &mb.DensityGCm3,
|
||
|
|
&mb.ExtrusionTempMin, &mb.ExtrusionTempMax,
|
||
|
|
&mb.BedTempMin, &mb.BedTempMax,
|
||
|
|
&mb.CreatedAt, &mb.UpdatedAt,
|
||
|
|
&s.MaterialFinishID,
|
||
|
|
&mf.Name, &mfDesc,
|
||
|
|
&mf.CreatedAt, &mf.UpdatedAt,
|
||
|
|
&modifierID,
|
||
|
|
&modName, &modDesc,
|
||
|
|
&modCreatedAt, &modUpdatedAt,
|
||
|
|
&s.ColorHex, &s.Brand, &s.DiameterMM,
|
||
|
|
&s.InitialGrams, &s.RemainingGrams, &s.SpoolWeightGrams,
|
||
|
|
&s.CostUSD, &s.LowStockThresholdGrams,
|
||
|
|
&s.Notes, &s.Barcode,
|
||
|
|
&s.DeletedAt, &s.CreatedAt, &s.UpdatedAt,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return s, err
|
||
|
|
}
|
||
|
|
|
||
|
|
mb.ID = s.MaterialBaseID
|
||
|
|
s.MaterialBase = &mb
|
||
|
|
|
||
|
|
mf.ID = s.MaterialFinishID
|
||
|
|
if mfDesc != nil {
|
||
|
|
mf.Description = mfDesc
|
||
|
|
}
|
||
|
|
s.MaterialFinish = &mf
|
||
|
|
|
||
|
|
s.MaterialModifierID = modifierID
|
||
|
|
if modifierID != nil && modName != nil {
|
||
|
|
mm := models.MaterialModifier{
|
||
|
|
ID: *modifierID,
|
||
|
|
Name: *modName,
|
||
|
|
}
|
||
|
|
if modDesc != nil {
|
||
|
|
mm.Description = modDesc
|
||
|
|
}
|
||
|
|
if modCreatedAt != nil {
|
||
|
|
mm.CreatedAt = *modCreatedAt
|
||
|
|
}
|
||
|
|
if modUpdatedAt != nil {
|
||
|
|
mm.UpdatedAt = *modUpdatedAt
|
||
|
|
}
|
||
|
|
s.MaterialModifier = &mm
|
||
|
|
}
|
||
|
|
|
||
|
|
return s, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetAll returns filament spools matching the given filters, with pagination.
|
||
|
|
// Returns results, total matching count, and any error.
|
||
|
|
func (r *FilamentRepository) GetAll(ctx context.Context, filter FilamentFilter) ([]models.FilamentSpool, int, error) {
|
||
|
|
conditions := []string{"s.deleted_at IS NULL"}
|
||
|
|
args := []interface{}{}
|
||
|
|
argIdx := 1
|
||
|
|
|
||
|
|
if filter.Material != "" {
|
||
|
|
conditions = append(conditions, fmt.Sprintf("LOWER(mb.name) = LOWER($%d)", argIdx))
|
||
|
|
args = append(args, filter.Material)
|
||
|
|
argIdx++
|
||
|
|
}
|
||
|
|
if filter.Finish != "" {
|
||
|
|
conditions = append(conditions, fmt.Sprintf("LOWER(mf.name) = LOWER($%d)", argIdx))
|
||
|
|
args = append(args, filter.Finish)
|
||
|
|
argIdx++
|
||
|
|
}
|
||
|
|
if filter.Color != "" {
|
||
|
|
conditions = append(conditions, fmt.Sprintf("s.color_hex = $%d", argIdx))
|
||
|
|
args = append(args, filter.Color)
|
||
|
|
argIdx++
|
||
|
|
}
|
||
|
|
if filter.LowStock {
|
||
|
|
conditions = append(conditions, "s.remaining_grams <= s.low_stock_threshold_grams")
|
||
|
|
}
|
||
|
|
|
||
|
|
whereClause := ""
|
||
|
|
if len(conditions) > 0 {
|
||
|
|
whereClause = "WHERE " + strings.Join(conditions, " AND ")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Count total.
|
||
|
|
var total int
|
||
|
|
countQuery := "SELECT COUNT(*) " + spoolFromJoins + " " + whereClause
|
||
|
|
if err := r.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Query with pagination.
|
||
|
|
dataQuery := "SELECT " + spoolScanFields + " " + spoolFromJoins + " " +
|
||
|
|
whereClause +
|
||
|
|
" ORDER BY s.name ASC" +
|
||
|
|
fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||
|
|
|
||
|
|
dataArgs := make([]interface{}, len(args))
|
||
|
|
copy(dataArgs, args)
|
||
|
|
dataArgs = append(dataArgs, filter.Limit, filter.Offset)
|
||
|
|
|
||
|
|
rows, err := r.pool.Query(ctx, dataQuery, dataArgs...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
var spools []models.FilamentSpool
|
||
|
|
for rows.Next() {
|
||
|
|
s, err := scanSpoolWithJoins(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
spools = append(spools, s)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
if spools == nil {
|
||
|
|
spools = []models.FilamentSpool{}
|
||
|
|
}
|
||
|
|
|
||
|
|
return spools, total, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetByID returns a single filament spool by ID with JOINed data.
|
||
|
|
// Returns nil if not found or soft-deleted.
|
||
|
|
func (r *FilamentRepository) GetByID(ctx context.Context, id int) (*models.FilamentSpool, error) {
|
||
|
|
query := "SELECT " + spoolScanFields + " " + spoolFromJoins +
|
||
|
|
" WHERE s.id = $1 AND s.deleted_at IS NULL"
|
||
|
|
|
||
|
|
row := r.pool.QueryRow(ctx, query, id)
|
||
|
|
s, err := scanSpoolWithJoins(row)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &s, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create inserts a new filament spool and returns the created spool with JOINed data.
|
||
|
|
func (r *FilamentRepository) Create(ctx context.Context, spool *models.FilamentSpool) (*models.FilamentSpool, error) {
|
||
|
|
var id int
|
||
|
|
err := r.pool.QueryRow(ctx, `
|
||
|
|
INSERT INTO filament_spools (
|
||
|
|
name, material_base_id, material_finish_id, material_modifier_id,
|
||
|
|
color_hex, brand, diameter_mm, initial_grams, remaining_grams,
|
||
|
|
spool_weight_grams, cost_usd, low_stock_threshold_grams,
|
||
|
|
notes, barcode
|
||
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||
|
|
RETURNING id
|
||
|
|
`,
|
||
|
|
spool.Name, spool.MaterialBaseID, spool.MaterialFinishID, spool.MaterialModifierID,
|
||
|
|
spool.ColorHex, spool.Brand, spool.DiameterMM, spool.InitialGrams, spool.RemainingGrams,
|
||
|
|
spool.SpoolWeightGrams, spool.CostUSD, spool.LowStockThresholdGrams,
|
||
|
|
spool.Notes, spool.Barcode,
|
||
|
|
).Scan(&id)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
return r.GetByID(ctx, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update applies partial updates to an existing filament spool.
|
||
|
|
// Only non-nil fields in the update map are applied.
|
||
|
|
// Returns the updated spool.
|
||
|
|
func (r *FilamentRepository) Update(ctx context.Context, id int, updates map[string]interface{}) (*models.FilamentSpool, error) {
|
||
|
|
if len(updates) == 0 {
|
||
|
|
return r.GetByID(ctx, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
setClauses := []string{"updated_at = NOW()"}
|
||
|
|
args := []interface{}{}
|
||
|
|
argIdx := 1
|
||
|
|
|
||
|
|
for col, val := range updates {
|
||
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, argIdx))
|
||
|
|
args = append(args, val)
|
||
|
|
argIdx++
|
||
|
|
}
|
||
|
|
|
||
|
|
args = append(args, id)
|
||
|
|
query := fmt.Sprintf("UPDATE filament_spools SET %s WHERE id = $%d AND deleted_at IS NULL",
|
||
|
|
strings.Join(setClauses, ", "), argIdx)
|
||
|
|
|
||
|
|
result, err := r.pool.Exec(ctx, query, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if result.RowsAffected() == 0 {
|
||
|
|
return nil, nil // not found or deleted
|
||
|
|
}
|
||
|
|
|
||
|
|
return r.GetByID(ctx, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
// SoftDelete marks a filament spool as deleted by setting deleted_at = NOW().
|
||
|
|
// Returns true if a row was affected.
|
||
|
|
func (r *FilamentRepository) SoftDelete(ctx context.Context, id int) (bool, error) {
|
||
|
|
result, err := r.pool.Exec(ctx, `
|
||
|
|
UPDATE filament_spools
|
||
|
|
SET deleted_at = NOW(), updated_at = NOW()
|
||
|
|
WHERE id = $1 AND deleted_at IS NULL
|
||
|
|
`, id)
|
||
|
|
if err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
return result.RowsAffected() > 0, nil
|
||
|
|
}
|