Build image / build-and-push (push) Successful in 4s
seed_lots.plant_id is ON DELETE CASCADE, so deleting a plant would have silently destroyed the purchase records attached to it — vendor, cost, germination rate, gone with no warning. Plantings were already guarded with ErrPlantInUse because their FK is RESTRICT; lots needed the same guard for the opposite reason, since their FK would have obliged rather than refused. Refusing lets the owner delete the lots deliberately if that is really what they meant, which is the same shape as the existing clones-and-edits path. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
197 lines
6.9 KiB
Go
197 lines
6.9 KiB
Go
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
|
|
}
|
|
|
|
// CountSeedLotsForPlant returns how many lots reference a plant. Used to refuse
|
|
// deleting a plant that has purchase records: the FK is ON DELETE CASCADE, so
|
|
// without this check the delete would silently destroy them.
|
|
func (d *DB) CountSeedLotsForPlant(ctx context.Context, plantID int64) (int, error) {
|
|
var n int
|
|
if err := d.sql.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM seed_lots WHERE plant_id = ?`, plantID).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("store: count seed lots for plant: %w", err)
|
|
}
|
|
return n, 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
|
|
}
|