Files
pansy/internal/store/seed_lots.go
T
steveandClaude Opus 4.8 94aed26e14
Build image / build-and-push (push) Successful in 15s
Address Gadfly review on seed lots
Three real bugs, two of which would have quietly corrupted inventory numbers.

CopyGarden carried seed_lot_id into the copied plantings, so copying a garden
double-counted the original lot's usage — the copy is a plan, not a second
planting out of the same packet. It also quietly attached a private lot to a
garden that may later be shared, contradicting the "lots are never shared with a
garden" invariant this feature was built on. The copy now drops the link.

RestorePlanting restored a seed_lot_id verbatim from a history snapshot. Live
rows get their link nulled by ON DELETE SET NULL when a lot is deleted, but the
snapshot still holds the old id, so undoing a planting deletion after retiring
its lot would violate the foreign key and abort the whole revert. The revert now
drops a dangling link before restoring: the plant really was in the ground,
which is the part worth restoring.

CreateSeedLot returned a lot with Used/Remaining at their zero values, so a
packet you just bought reported "0 remaining" — the exact opposite of the truth.
The version-conflict path had the same gap, and that row is what the client
rebases on, so a 409 reporting remaining=0 is worse than no number at all. Both
fill them now.

Remaining can go negative and that is deliberate — it means more was planted
than the lot recorded buying, which happens (a miscounted packet, a lot entered
after the fact). Clamping to zero would hide the discrepancy at exactly the
moment it is worth seeing. Now documented and tested rather than incidental.

Performance and tidiness: SeedLotUsage is scoped to the lots being filled
instead of scanning every planting the owner has, so reading one lot is one
lot's work; ListSeedLotsForOwner gained the LIMIT every other list read has;
parseNullable moved out of seed_lots.go into the shared request helpers, since
every nullable field in the API needs that three-way absent/null/value
distinction; finalizePlant validates against its own maxPlantVendorLen rather
than borrowing a constant named for seed lots; seedLotForPlanting became
checkSeedLotForPlanting, since it validates rather than fetches; and the
duplicate intPtr test helper gave way to the existing ptrInt.

Declined: recording seed-lot mutations in the revision history. change_sets are
anchored to a garden and lots are user-scoped, deliberately — they belong to
you, not to any one garden, so there is no garden to record them against.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:38:54 -04:00

218 lines
7.7 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// maxSeedLotsPerRead caps a lot listing. Far past any real seed shelf; it exists
// so the read is bounded rather than trusting it to stay small.
const maxSeedLotsPerRead = 1000
// 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. Capped like every other list read so one runaway account can't ask
// for an unbounded result set.
query += ` ORDER BY purchased_at IS NULL, purchased_at DESC, id DESC LIMIT ?`
args = append(args, maxSeedLotsPerRead)
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 the given lots — the
// "used" half of a lot's remaining quantity. Scoped to the lots the caller
// actually needs, so reading one lot doesn't scan every planting the owner has.
//
// 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.
//
// ownerID is still in the WHERE clause even though the ids come from rows the
// service already checked: a scoping query should not depend on its caller
// having checked scope.
func (d *DB) SeedLotUsage(ctx context.Context, ownerID int64, lotIDs []int64) ([]SeedLotUse, error) {
if len(lotIDs) == 0 {
return nil, nil
}
args := make([]any, 0, len(lotIDs)+1)
args = append(args, ownerID)
for _, id := range lotIDs {
args = append(args, id)
}
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
AND sl.id IN (`+placeholders(len(lotIDs))+`)`,
args...)
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
}