Address Gadfly review on seed lots
Build image / build-and-push (push) Successful in 15s

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
This commit is contained in:
2026-07-21 01:38:54 -04:00
co-authored by Claude Opus 4.8
parent 5eedcaf945
commit 94aed26e14
9 changed files with 284 additions and 47 deletions
+28 -7
View File
@@ -9,6 +9,10 @@ import (
"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,
@@ -67,8 +71,11 @@ func (d *DB) ListSeedLotsForOwner(ctx context.Context, ownerID int64, plantID *i
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`
// 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 {
@@ -161,21 +168,35 @@ type SeedLotUse struct {
SpacingCM float64
}
// SeedLotUsage returns the ACTIVE plantings attributed to any of the owner's
// lots — the "used" half of a lot's remaining quantity.
// 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.
func (d *DB) SeedLotUsage(ctx context.Context, ownerID int64) ([]SeedLotUse, error) {
//
// 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`,
ownerID)
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)
}