package store import ( "context" "database/sql" "errors" "fmt" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // seedLotColumns lists seed_lots columns in the order scanSeedLot expects. const seedLotColumns = `id, owner_id, plant_id, vendor, source_url, sku, lot_code, purchased_at, packed_for_year, quantity, unit, cost_cents, germination_pct, notes, version, created_at, updated_at` func scanSeedLot(s scanner) (*domain.SeedLot, error) { var l domain.SeedLot if err := s.Scan( &l.ID, &l.OwnerID, &l.PlantID, &l.Vendor, &l.SourceURL, &l.SKU, &l.LotCode, &l.PurchasedAt, &l.PackedForYear, &l.Quantity, &l.Unit, &l.CostCents, &l.GerminationPct, &l.Notes, &l.Version, &l.CreatedAt, &l.UpdatedAt, ); err != nil { return nil, err } return &l, nil } // CreateSeedLot inserts a lot (fields already validated by the service). func (d *DB) CreateSeedLot(ctx context.Context, l *domain.SeedLot) (*domain.SeedLot, error) { created, err := scanSeedLot(d.sql.QueryRowContext(ctx, `INSERT INTO seed_lots (owner_id, plant_id, vendor, source_url, sku, lot_code, purchased_at, packed_for_year, quantity, unit, cost_cents, germination_pct, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING `+seedLotColumns, l.OwnerID, l.PlantID, l.Vendor, l.SourceURL, l.SKU, l.LotCode, l.PurchasedAt, l.PackedForYear, l.Quantity, l.Unit, l.CostCents, l.GerminationPct, l.Notes, )) if err != nil { return nil, fmt.Errorf("store: insert seed lot: %w", err) } return created, nil } // GetSeedLot returns a lot by id, or domain.ErrNotFound. Ownership is the // service's check, not this one's. func (d *DB) GetSeedLot(ctx context.Context, id int64) (*domain.SeedLot, error) { l, err := scanSeedLot(d.sql.QueryRowContext(ctx, `SELECT `+seedLotColumns+` FROM seed_lots WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrNotFound } if err != nil { return nil, fmt.Errorf("store: get seed lot: %w", err) } return l, nil } // ListSeedLotsForOwner returns every lot the actor owns, newest purchase first // (undated lots last), then by id. Optionally narrowed to one plant. Always a // non-nil slice. func (d *DB) ListSeedLotsForOwner(ctx context.Context, ownerID int64, plantID *int64) ([]domain.SeedLot, error) { query := `SELECT ` + seedLotColumns + ` FROM seed_lots WHERE owner_id = ?` args := []any{ownerID} if plantID != nil { query += ` AND plant_id = ?` args = append(args, *plantID) } // NULL purchased_at sorts last: an undated lot is the least useful to see first. query += ` ORDER BY purchased_at IS NULL, purchased_at DESC, id DESC` rows, err := d.sql.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("store: list seed lots: %w", err) } defer rows.Close() lots := []domain.SeedLot{} for rows.Next() { l, err := scanSeedLot(rows) if err != nil { return nil, fmt.Errorf("store: scan seed lot: %w", err) } lots = append(lots, *l) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("store: iterate seed lots: %w", err) } return lots, nil } // UpdateSeedLot applies a version-guarded update of every mutable column (the // service merges partial patches onto the current row first). Same contract as // the other mutable resources: the updated row, or (current, ErrVersionConflict). // plant_id and owner_id are immutable — re-pointing a purchase at a different // variety or user would rewrite history rather than correct it. func (d *DB) UpdateSeedLot(ctx context.Context, l *domain.SeedLot) (*domain.SeedLot, error) { updated, err := scanSeedLot(d.sql.QueryRowContext(ctx, `UPDATE seed_lots SET vendor = ?, source_url = ?, sku = ?, lot_code = ?, purchased_at = ?, packed_for_year = ?, quantity = ?, unit = ?, cost_cents = ?, germination_pct = ?, notes = ?, version = version + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND version = ? RETURNING `+seedLotColumns, l.Vendor, l.SourceURL, l.SKU, l.LotCode, l.PurchasedAt, l.PackedForYear, l.Quantity, l.Unit, l.CostCents, l.GerminationPct, l.Notes, l.ID, l.Version, )) if errors.Is(err, sql.ErrNoRows) { current, gerr := d.GetSeedLot(ctx, l.ID) if gerr != nil { return nil, gerr } return current, domain.ErrVersionConflict } if err != nil { return nil, fmt.Errorf("store: update seed lot: %w", err) } return updated, nil } // DeleteSeedLot removes a lot. Plantings attributed to it survive with a NULL // seed_lot_id (see the migration): the plants really were in the ground, // whatever happened to the record of the packet. func (d *DB) DeleteSeedLot(ctx context.Context, id int64) error { res, err := d.sql.ExecContext(ctx, `DELETE FROM seed_lots WHERE id = ?`, id) if err != nil { return fmt.Errorf("store: delete seed lot: %w", err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("store: seed lot delete rows: %w", err) } if n == 0 { return domain.ErrNotFound } return nil } // SeedLotUse is one active planting attributed to a lot, carrying just enough to // compute its effective plant count. type SeedLotUse struct { LotID int64 RadiusCM float64 Count *int SpacingCM float64 } // SeedLotUsage returns the ACTIVE plantings attributed to any of the owner's // lots — the "used" half of a lot's remaining quantity. // // It returns the raw ingredients rather than a SUM, because the effective count // of a planting is its explicit count when it has one and the derived // π·r²/spacing² formula otherwise. That formula lives in the service; reproducing // it in SQL would guarantee the two drift apart. func (d *DB) SeedLotUsage(ctx context.Context, ownerID int64) ([]SeedLotUse, error) { rows, err := d.sql.QueryContext(ctx, `SELECT pl.seed_lot_id, pl.radius_cm, pl.count, p.spacing_cm FROM plantings pl JOIN seed_lots sl ON sl.id = pl.seed_lot_id JOIN plants p ON p.id = pl.plant_id WHERE sl.owner_id = ? AND pl.removed_at IS NULL`, ownerID) if err != nil { return nil, fmt.Errorf("store: seed lot usage: %w", err) } defer rows.Close() uses := []SeedLotUse{} for rows.Next() { var u SeedLotUse if err := rows.Scan(&u.LotID, &u.RadiusCM, &u.Count, &u.SpacingCM); err != nil { return nil, fmt.Errorf("store: scan seed lot usage: %w", err) } uses = append(uses, u) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("store: iterate seed lot usage: %w", err) } return uses, nil }