Seed provenance + inventory: source links and seed lots (#50) #64

Merged
steve merged 3 commits from feat/seed-lots into main 2026-07-21 05:39:25 +00:00
3 changed files with 51 additions and 0 deletions
Showing only changes of commit 5eedcaf945 - Show all commits
+13
View File
@@ -129,6 +129,12 @@ func (s *Service) UpdatePlant(ctx context.Context, actorID, plantID int64, patch
// planting (even a removed one — the FK is RESTRICT) is refused with
// ErrPlantInUse rather than orphaning history; the client clones-and-edits or
// clears the plantings first.
//
// Seed lots are refused for a different reason: seed_lots.plant_id is ON DELETE
// CASCADE, so without this check deleting a plant would silently destroy the
// purchase records attached to it — vendor, cost, germination rate, all gone
// with no warning. Refusing lets the owner delete the lots deliberately if that
// is really what they meant.
func (s *Service) DeletePlant(ctx context.Context, actorID, plantID int64) error {
if _, err := s.writablePlant(ctx, actorID, plantID); err != nil {
return err
@@ -140,6 +146,13 @@ func (s *Service) DeletePlant(ctx context.Context, actorID, plantID int64) error
if n > 0 {
return domain.ErrPlantInUse
}
lots, err := s.store.CountSeedLotsForPlant(ctx, plantID)
if err != nil {
return err
}
if lots > 0 {
return domain.ErrPlantInUse
}
return s.store.DeletePlant(ctx, plantID)
}
+26
View File
@@ -343,3 +343,29 @@ func TestSeedLotUnitAndQuantityValidation(t *testing.T) {
}
func intPtr(v int) *int { return &v }
// TestDeletingAPlantWithLotsIsRefused — seed_lots.plant_id is ON DELETE CASCADE,
// so without a guard, deleting a plant would silently destroy the purchase
// records attached to it: vendor, cost, germination rate, gone with no warning.
func TestDeletingAPlantWithLotsIsRefused(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 100, func(in *SeedLotInput) { in.CostCents = intPtr(499) })
ctx := context.Background()
if err := s.DeletePlant(ctx, owner, plant.ID); !errors.Is(err, domain.ErrPlantInUse) {
t.Fatalf("DeletePlant err = %v, want ErrPlantInUse", err)
}
if _, err := s.GetSeedLot(ctx, owner, lot.ID); err != nil {
t.Errorf("the lot was destroyed anyway: %v", err)
}
// Deleting the lot deliberately then frees the plant.
if err := s.DeleteSeedLot(ctx, owner, lot.ID); err != nil {
t.Fatalf("DeleteSeedLot: %v", err)
}
if err := s.DeletePlant(ctx, owner, plant.ID); err != nil {
t.Errorf("DeletePlant after clearing lots: %v", err)
}
}
+12
View File
3
@@ -140,6 +140,18 @@ func (d *DB) DeleteSeedLot(ctx context.Context, id int64) error {
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 {