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
15 changed files with 1223 additions and 31 deletions
Showing only changes of commit 81481ede2f - Show all commits
+3 -1
View File
@@ -21,7 +21,8 @@ SQLite, centimeters everywhere (display-side imperial conversion only), `version
- **gardens** — owner_id, name, width_cm, height_cm, unit_pref (`metric`|`imperial`), notes.
- **garden_shares** — (garden_id, user_id) unique, role `viewer`|`editor`, created_by. Owner is implicit via `gardens.owner_id`, never a share row.
- **garden_objects** — one polymorphic table: `kind` (`bed`|`grow_bag`|`container`|`in_ground`|`tree`|`path`|`structure`), name, `shape` (`rect`|`circle`; `polygon` + `points` JSON reserved in schema, not in the v1 editor), center `x_cm`,`y_cm` in garden space, `width_cm`,`height_cm` (circle: width=diameter), `rotation_deg`, `z_index`, `plantable` bool, nullable `color` override, `props` JSON for kind-specific extras (bed height, tree species…), notes.
- **plants** — catalog. `owner_id NULL` = built-in (~30 seeded via migration, read-only, clone to customize). name, category (`vegetable`|`herb`|`flower`|`fruit`|`tree_shrub`|`cover`), `spacing_cm` (mature spacing), `color` hex, `icon` (emoji string — zero assets in v1), nullable `days_to_maturity`, notes. Users see built-ins + their own.
- **seed_lots** — one purchase: vendor, source URL, sku, lot code, purchase date, packed-for year, quantity + unit, cost, germination %. `owner_id` is on the LOT, not inherited from the plant, so you can record buying generic built-in Garlic from Johnny's and keep it private. `plantings.seed_lot_id` (ON DELETE SET NULL) attributes a plop to a purchase; **`remaining` is derived** (quantity summed effective count of active plantings), never stored — a decremented column drifts the moment a planting is edited behind its back and can't be audited afterwards. Lots are never shared along with a garden.
- **plants** — catalog, plus `source_url`/`vendor` provenance for the variety itself. `owner_id NULL` = built-in (~30 seeded via migration, read-only, clone to customize). name, category (`vegetable`|`herb`|`flower`|`fruit`|`tree_shrub`|`cover`), `spacing_cm` (mature spacing), `color` hex, `icon` (emoji string — zero assets in v1), nullable `days_to_maturity`, notes. Users see built-ins + their own.
- **change_sets** / **revisions** — the undo substrate. A change set groups the row-level revisions one logical operation produced (`source` ui/agent/api, `summary`, nullable `agent_run_id`, nullable `reverts_id`); a revision is `(entity_type, entity_id, op, before, after)` with full JSON row snapshots. The unit of undo is the **operation, not the row**. Revert is itself a change set, so an undo can be undone; it is version-guarded per entity and reports conflicts rather than clobbering a later edit. Garden *deletion* is deliberately not revertible (the cascade is invisible to the service, and would take the history with it).
- **plantings** ("plops") — object_id, plant_id, center `x_cm`,`y_cm` **in the parent object's local frame** (moving/rotating a bed moves its plants for free), `radius_cm` (a plop is a circular patch), nullable `count` (NULL = derived: `max(1, round(π·r² / spacing_cm²))`), nullable label, `planted_at`/`removed_at` dates. "Clear bed" sets `removed_at`; v1 shows rows where `removed_at IS NULL`. Year-over-year history and a future plan/scenario layer build on these dates without schema surgery.
@@ -59,6 +60,7 @@ POST /gardens/:id/copy ← deep-copy a garden you own (objects + active p
POST /gardens/:id/objects PATCH,DELETE /objects/:id
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
```
+9
View File
@@ -124,6 +124,15 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
plants.PATCH("/:id", h.updatePlant)
plants.DELETE("/:id", h.deletePlant)
// Seed lots: what the actor bought, and what's left. Private to the buyer,
// so these hang off the session actor rather than off a garden.
seedLots := v1.Group("/seed-lots", h.requireAuth())
seedLots.GET("", h.listSeedLots)
seedLots.POST("", h.createSeedLot)
seedLots.GET("/:id", h.getSeedLot)
seedLots.PATCH("/:id", h.updateSeedLot)
seedLots.DELETE("/:id", h.deleteSeedLot)
// Public, unauthenticated read of a garden by its share token. Deliberately
// NOT behind requireAuth: the token is the capability, so a logged-out visitor
// opens a shared link without being redirected to /login or OIDC. Only GET,
+8 -1
View File
@@ -22,12 +22,13 @@ type plantingCreateRequest struct {
Count *int `json:"count"`
Label *string `json:"label"`
PlantedAt *string `json:"plantedAt"`
SeedLotID *int64 `json:"seedLotId"`
}
func (r plantingCreateRequest) toInput() service.PlantingInput {
return service.PlantingInput{
PlantID: r.PlantID, XCM: r.XCM, YCM: r.YCM, RadiusCM: r.RadiusCM,
Count: r.Count, Label: r.Label, PlantedAt: r.PlantedAt,
Count: r.Count, Label: r.Label, PlantedAt: r.PlantedAt, SeedLotID: r.SeedLotID,
}
}
@@ -45,6 +46,7 @@ type plantingUpdateRequest struct {
Label json.RawMessage `json:"label"`
PlantedAt json.RawMessage `json:"plantedAt"`
RemovedAt json.RawMessage `json:"removedAt"`
SeedLotID json.RawMessage `json:"seedLotId"`
Version int64 `json:"version" binding:"required,min=1"`
}
@@ -61,6 +63,10 @@ func (r plantingUpdateRequest) toPatch() (service.PlantingPatch, error) {
if err != nil {
return service.PlantingPatch{}, err
}
seedLot, setSeedLot, err := parseNullable[int64](r.SeedLotID)
if err != nil {
return service.PlantingPatch{}, err
}
removedAt, setRemovedAt, err := parseNullableString(r.RemovedAt)
if err != nil {
return service.PlantingPatch{}, err
@@ -71,6 +77,7 @@ func (r plantingUpdateRequest) toPatch() (service.PlantingPatch, error) {
SetLabel: setLabel, Label: label,
SetPlantedAt: setPlantedAt, PlantedAt: plantedAt,
SetRemovedAt: setRemovedAt, RemovedAt: removedAt,
SetSeedLotID: setSeedLot, SeedLotID: seedLot,
}, nil
}
+8 -2
View File
@@ -20,13 +20,16 @@ type plantCreateRequest struct {
Color string `json:"color" binding:"required"`
Icon string `json:"icon" binding:"required"`
DaysToMaturity *int `json:"daysToMaturity"`
SourceURL string `json:"sourceUrl"`
Vendor string `json:"vendor"`
Notes string `json:"notes"`
}
func (r plantCreateRequest) toInput() service.PlantInput {
return service.PlantInput{
Name: r.Name, Category: r.Category, SpacingCM: r.SpacingCM,
Color: r.Color, Icon: r.Icon, DaysToMaturity: r.DaysToMaturity, Notes: r.Notes,
Color: r.Color, Icon: r.Icon, DaysToMaturity: r.DaysToMaturity,
SourceURL: r.SourceURL, Vendor: r.Vendor, Notes: r.Notes,
}
}
@@ -41,6 +44,8 @@ type plantUpdateRequest struct {
Color *string `json:"color"`
Icon *string `json:"icon"`
DaysToMaturity json.RawMessage `json:"daysToMaturity"`
SourceURL *string `json:"sourceUrl"`
Vendor *string `json:"vendor"`
Notes *string `json:"notes"`
Version int64 `json:"version" binding:"required,min=1"`
}
@@ -53,7 +58,8 @@ func (r plantUpdateRequest) toPatch() (service.PlantPatch, error) {
return service.PlantPatch{
Name: r.Name, Category: r.Category, SpacingCM: r.SpacingCM,
Color: r.Color, Icon: r.Icon,
SetDays: setDays, DaysToMaturity: days, Notes: r.Notes,
SetDays: setDays, DaysToMaturity: days,
SourceURL: r.SourceURL, Vendor: r.Vendor, Notes: r.Notes,
}, nil
}
+182
View File
@@ -0,0 +1,182 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Seed lots (#50): what you actually bought, and what's left of it. Private to
// the buyer — a lot is never shared along with a garden — so every handler here
// scopes to the session actor with no garden in the picture.
// seedLotCreateRequest is the body for POST /seed-lots.
type seedLotCreateRequest struct {
PlantID int64 `json:"plantId" binding:"required"`
Vendor string `json:"vendor"`
SourceURL string `json:"sourceUrl"`
SKU string `json:"sku"`
LotCode string `json:"lotCode"`
PurchasedAt *string `json:"purchasedAt"`
PackedForYear *int `json:"packedForYear"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit" binding:"required"`
CostCents *int `json:"costCents"`
GerminationPct *float64 `json:"germinationPct"`
Notes string `json:"notes"`
}
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
return service.SeedLotInput{
PlantID: r.PlantID, Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU,
LotCode: r.LotCode, PurchasedAt: r.PurchasedAt, PackedForYear: r.PackedForYear,
Quantity: r.Quantity, Unit: r.Unit, CostCents: r.CostCents,
GerminationPct: r.GerminationPct, Notes: r.Notes,
}
}
// seedLotUpdateRequest is the body for PATCH /seed-lots/:id: every field
// optional, plus the required current version. The nullable columns are
// json.RawMessage so an explicit null (clear it) is distinguishable from an
// absent field (leave it alone). plantId is not accepted — see SeedLotPatch.
type seedLotUpdateRequest struct {
Vendor *string `json:"vendor"`
SourceURL *string `json:"sourceUrl"`
SKU *string `json:"sku"`
LotCode *string `json:"lotCode"`
PurchasedAt json.RawMessage `json:"purchasedAt"`
PackedForYear json.RawMessage `json:"packedForYear"`
Quantity *float64 `json:"quantity"`
Unit *string `json:"unit"`
CostCents json.RawMessage `json:"costCents"`
GerminationPct json.RawMessage `json:"germinationPct"`
Notes *string `json:"notes"`
Version int64 `json:"version" binding:"required"`
Review

🟠 seedLotUpdateRequest.Version missing min=1 binding validation

error-handling · flagged by 1 model

  • internal/api/seed_lots.go:60seedLotUpdateRequest.Version only declares binding:"required", omitting min=1. The plant (plants.go:49) and planting (plantings.go:76) update requests both enforce binding:"required,min=1". A version of 0 or a negative value therefore passes API validation and reaches the service, where it will fail the SQL version = ? guard and surface as a version conflict instead of an early INVALID_INPUT error. Verified by comparing the struct tags acr…

🪰 Gadfly · advisory

🟠 **seedLotUpdateRequest.Version missing min=1 binding validation** _error-handling · flagged by 1 model_ - **`internal/api/seed_lots.go:60`** — `seedLotUpdateRequest.Version` only declares `binding:"required"`, omitting `min=1`. The plant (`plants.go:49`) and planting (`plantings.go:76`) update requests both enforce `binding:"required,min=1"`. A version of `0` or a negative value therefore passes API validation and reaches the service, where it will fail the SQL `version = ?` guard and surface as a version conflict instead of an early `INVALID_INPUT` error. Verified by comparing the struct tags acr… <sub>🪰 Gadfly · advisory</sub>
}
func (r seedLotUpdateRequest) toPatch() (service.SeedLotPatch, error) {
p := service.SeedLotPatch{
Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU, LotCode: r.LotCode,
Quantity: r.Quantity, Unit: r.Unit, Notes: r.Notes,
}
var err error
if p.PurchasedAt, p.SetPurchasedAt, err = parseNullable[string](r.PurchasedAt); err != nil {
return p, err
}
if p.PackedForYear, p.SetPackedForYear, err = parseNullable[int](r.PackedForYear); err != nil {
return p, err
}
if p.CostCents, p.SetCostCents, err = parseNullable[int](r.CostCents); err != nil {
return p, err
}
if p.GerminationPct, p.SetGerminationPct, err = parseNullable[float64](r.GerminationPct); err != nil {
return p, err
}
return p, nil
}
// parseNullable decodes any JSON value into (value, present) for a nullable
// column. Absent → (nil, false); null → (nil, true); otherwise the decoded value.
func parseNullable[T any](raw json.RawMessage) (*T, bool, error) {
Review

🟠 Generic parseNullable helper trapped in seed_lots.go but used cross-file

maintainability · flagged by 4 models

  • internal/api/seed_lots.go:86 — The new generic parseNullable[T any] is introduced in seed_lots.go but immediately consumed by plantings.go. This creates a hidden cross-file dependency: a handler in the planting file relies on a helper tucked inside a seed-lot file. The generic is cleaner than the existing parseNullableInt/parseNullableString duplicates, but it should live in a shared API utility file (or at least in a file whose name doesn't imply seed-lot ownership). Fix:

🪰 Gadfly · advisory

🟠 **Generic parseNullable helper trapped in seed_lots.go but used cross-file** _maintainability · flagged by 4 models_ - **`internal/api/seed_lots.go:86`** — The new generic `parseNullable[T any]` is introduced in `seed_lots.go` but immediately consumed by `plantings.go`. This creates a hidden cross-file dependency: a handler in the planting file relies on a helper tucked inside a seed-lot file. The generic is cleaner than the existing `parseNullableInt`/`parseNullableString` duplicates, but it should live in a shared API utility file (or at least in a file whose name doesn't imply seed-lot ownership). **Fix:**… <sub>🪰 Gadfly · advisory</sub>
if len(raw) == 0 {
return nil, false, nil
}
if string(raw) == "null" {
return nil, true, nil
}
var v T
if err := json.Unmarshal(raw, &v); err != nil {
return nil, false, err
}
return &v, true, nil
}
func (h *handlers) listSeedLots(c *gin.Context) {
var plantID *int64
if raw := c.Query("plantId"); raw != "" {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id < 1 {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid plantId")
return
}
plantID = &id
}
lots, err := h.svc.ListSeedLots(c.Request.Context(), mustActor(c).ID, plantID)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, lots)
}
func (h *handlers) createSeedLot(c *gin.Context) {
var req seedLotCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid seed lot: plantId and unit are required")
return
}
lot, err := h.svc.CreateSeedLot(c.Request.Context(), mustActor(c).ID, req.toInput())
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusCreated, lot)
}
func (h *handlers) getSeedLot(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
lot, err := h.svc.GetSeedLot(c.Request.Context(), mustActor(c).ID, id)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, lot)
}
func (h *handlers) updateSeedLot(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
var req seedLotUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid update: a current version is required")
return
}
patch, err := req.toPatch()
if err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid update payload")
return
}
lot, err := h.svc.UpdateSeedLot(c.Request.Context(), mustActor(c).ID, id, patch, req.Version)
if err != nil {
if errors.Is(err, domain.ErrVersionConflict) {
writeVersionConflict(c, lot)
return
}
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, lot)
}
func (h *handlers) deleteSeedLot(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.svc.DeleteSeedLot(c.Request.Context(), mustActor(c).ID, id); err != nil {
writeServiceError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+52 -3
View File
@@ -109,6 +109,14 @@ const (
OpCreate = "create"
OpUpdate = "update"
OpDelete = "delete"
// Units a seed lot's quantity can be counted in (mirrors the schema CHECK).
UnitSeeds = "seeds"
UnitGrams = "grams"
UnitOunces = "ounces"
UnitPackets = "packets"
UnitBulbs = "bulbs"
UnitPlants = "plants"
)
// ChangeSet groups the revisions produced by one logical operation — the unit of
@@ -280,6 +288,11 @@ type GardenObject struct {
}
// Plant is a catalog entry. OwnerID nil means a read-only seeded built-in.
//
// SourceURL/Vendor are provenance for the variety itself — the page you'd go
// back to in order to buy it again. What you actually bought, and how much is
// left, lives in SeedLot instead: the same variety may come from two vendors in
// two years, and a single pair of columns here would collapse that.
type Plant struct {
ID int64 `json:"id"`
OwnerID *int64 `json:"ownerId,omitempty"` // nil = built-in
@@ -289,12 +302,44 @@ type Plant struct {
Color string `json:"color"`
Icon string `json:"icon"`
DaysToMaturity *int `json:"daysToMaturity,omitempty"`
SourceURL string `json:"sourceUrl"`
Vendor string `json:"vendor"`
Notes string `json:"notes"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// SeedLot is one purchase: a specific packet of a specific variety, from a
// specific vendor, in a specific year. Owned by the buyer even when it points at
// a built-in plant, and never shared along with a garden.
type SeedLot struct {
ID int64 `json:"id"`
OwnerID int64 `json:"ownerId"`
PlantID int64 `json:"plantId"`
Vendor string `json:"vendor"`
SourceURL string `json:"sourceUrl"`
SKU string `json:"sku"`
LotCode string `json:"lotCode"`
PurchasedAt *string `json:"purchasedAt,omitempty"`
PackedForYear *int `json:"packedForYear,omitempty"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
CostCents *int `json:"costCents,omitempty"`
GerminationPct *float64 `json:"germinationPct,omitempty"`
Notes string `json:"notes"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
// Used is the summed effective count of active plantings attributed to this
// lot, and Remaining is Quantity - Used. Both are computed by the service and
// never stored: a stored running total drifts the moment anything edits a
// planting behind its back, and can't be audited afterwards.
Used float64 `json:"used"`
Remaining float64 `json:"remaining"`
}
// Planting ("plop") is a circular patch of one plant, positioned in its parent
// object's local frame. Count nil means it is derived from area / spacing².
type Planting struct {
@@ -308,9 +353,13 @@ type Planting struct {
Label *string `json:"label,omitempty"`
PlantedAt *string `json:"plantedAt,omitempty"`
RemovedAt *string `json:"removedAt,omitempty"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
// SeedLotID attributes this planting to a purchase, so a lot can report what
// is left. Nullable: most plantings never name a lot, and retiring a lot sets
// this back to NULL rather than destroying planting history.
SeedLotID *int64 `json:"seedLotId,omitempty"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
// DerivedCount is the plant count implied by the plop's area and the plant's
// mature spacing: max(1, round(π·r² / spacing²)). It is computed by the
+17
View File
@@ -50,6 +50,9 @@ type PlantingInput struct {
Count *int
Label *string
PlantedAt *string
// SeedLotID attributes this planting to a purchase, so the lot can report
// what's left. Optional.
SeedLotID *int64
}
// PlantingPatch is a partial update. The nullable fields (Count/Label/PlantedAt/
@@ -68,6 +71,8 @@ type PlantingPatch struct {
PlantedAt *string
SetRemovedAt bool
RemovedAt *string
SetSeedLotID bool
SeedLotID *int64
}
// CreatePlanting places a plop in a plantable object the actor can edit.
@@ -93,6 +98,7 @@ func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, i
Count: in.Count,
Label: trimStringPtr(in.Label),
PlantedAt: in.PlantedAt,
SeedLotID: in.SeedLotID,
}
if p.PlantedAt == nil {
today := s.now().UTC().Format(dateLayout)
@@ -101,6 +107,9 @@ func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, i
if err := finalizePlanting(p, o, true); err != nil {
return nil, err
}
if err := s.seedLotForPlanting(ctx, actorID, p.SeedLotID, p.PlantID); err != nil {
return nil, err
}
created, err := s.store.CreatePlanting(ctx, p)
if err != nil {
return nil, err
@@ -148,6 +157,11 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
if err := finalizePlanting(pl, o, checkBounds); err != nil {
return nil, err
}
// Re-checked on every update, not just when the lot changes: a plant swap can
// leave a previously-valid attribution pointing at a lot of the wrong variety.
if err := s.seedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID); err != nil {
return nil, err
}
pl.Version = version
updated, err := s.store.UpdatePlanting(ctx, pl)
@@ -249,6 +263,9 @@ func applyPlantingPatch(pl *domain.Planting, p PlantingPatch) {
if p.SetRemovedAt {
pl.RemovedAt = p.RemovedAt
}
if p.SetSeedLotID {
pl.SeedLotID = p.SeedLotID
}
}
// finalizePlanting validates a built/merged plop against its parent object.
+23 -1
View File
@@ -40,7 +40,11 @@ type PlantInput struct {
Color string
Icon string
DaysToMaturity *int
Notes string
// Where this variety came from — the page you'd go back to to buy it again.
// What you actually bought, and how much is left, is a SeedLot instead.
SourceURL string
Vendor string
Notes string
}
// PlantPatch is a partial update: nil fields are left unchanged. DaysToMaturity
@@ -54,6 +58,8 @@ type PlantPatch struct {
Icon *string
SetDays bool
DaysToMaturity *int
SourceURL *string
Vendor *string
Notes *string
}
@@ -73,6 +79,8 @@ func (s *Service) CreatePlant(ctx context.Context, actorID int64, in PlantInput)
Color: in.Color,
Icon: strings.TrimSpace(in.Icon),
DaysToMaturity: in.DaysToMaturity,
SourceURL: strings.TrimSpace(in.SourceURL),
Vendor: strings.TrimSpace(in.Vendor),
Notes: strings.TrimSpace(in.Notes),
}
if err := finalizePlant(p); err != nil {
@@ -155,6 +163,12 @@ func applyPlantPatch(p *domain.Plant, patch PlantPatch) {
if patch.SetDays {
p.DaysToMaturity = patch.DaysToMaturity
}
if patch.SourceURL != nil {
p.SourceURL = strings.TrimSpace(*patch.SourceURL)
}
if patch.Vendor != nil {
p.Vendor = strings.TrimSpace(*patch.Vendor)
}
if patch.Notes != nil {
p.Notes = strings.TrimSpace(*patch.Notes)
}
@@ -184,5 +198,13 @@ func finalizePlant(p *domain.Plant) error {
if len(p.Notes) > maxPlantNotesLen {
return domain.ErrInvalidInput
}
if len(p.Vendor) > maxLotTextLen {
return domain.ErrInvalidInput
}
// Rendered as a clickable link, so the scheme check is load-bearing (see
// validSourceURL) rather than tidiness.
if !validSourceURL(p.SourceURL) {
return domain.ErrInvalidInput
}
return nil
}
+6 -6
View File
@@ -184,12 +184,12 @@ func TestCreatePlantValidation(t *testing.T) {
owner := seedUser(t, s, "[email protected]")
bad := []PlantInput{
{Name: "", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // empty name
{Name: "x", Category: "spaceship", SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // bad category
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 0, Color: "#4a7c3f", Icon: "🌿"}, // zero spacing
{Name: "x", Category: domain.CategoryHerb, SpacingCM: -5, Color: "#4a7c3f", Icon: "🌿"}, // negative spacing
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "nope", Icon: "🌿"}, // bad color
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: ""}, // empty icon
{Name: "", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // empty name
Review

🟡 Broken comment alignment in table-driven test

maintainability · flagged by 2 models

  • internal/service/plants_test.go:187 — Formatting regression: the comment-aligned spacing in the table-driven test was broken (columns no longer align with their // comments), making the test harder to scan. gofmt won't fix comment alignment. Fix: Re-align the comment offsets to restore the readable table layout.

🪰 Gadfly · advisory

🟡 **Broken comment alignment in table-driven test** _maintainability · flagged by 2 models_ - **`internal/service/plants_test.go:187`** — Formatting regression: the comment-aligned spacing in the table-driven test was broken (columns no longer align with their `//` comments), making the test harder to scan. `gofmt` won't fix comment alignment. **Fix:** Re-align the comment offsets to restore the readable table layout. <sub>🪰 Gadfly · advisory</sub>
{Name: "x", Category: "spaceship", SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // bad category
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 0, Color: "#4a7c3f", Icon: "🌿"}, // zero spacing
{Name: "x", Category: domain.CategoryHerb, SpacingCM: -5, Color: "#4a7c3f", Icon: "🌿"}, // negative spacing
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "nope", Icon: "🌿"}, // bad color
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: ""}, // empty icon
{Name: strings.Repeat("x", maxPlantNameLen+1), Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // name too long
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿", DaysToMaturity: ptrInt(0)}, // days must be >= 1
}
+314
View File
@@ -0,0 +1,314 @@
package service
import (
"context"
"net/url"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// Seed provenance and inventory (#50). A lot is one purchase — a specific packet
// of a specific variety, from a specific vendor, in a specific year — and it is
// owned by the buyer even when it points at a built-in plant. Lots are private:
// they are never shared along with a garden, because what you paid for seed is
// not part of a garden plan.
const (
maxLotTextLen = 200
maxLotNotesLen = 10_000
maxSourceURLLen = 2000
maxLotQuantity = 1_000_000
minPackedYear = 1900
maxPackedYear = 2200
)
// lotUnits is the set of valid quantity units (mirrors the schema CHECK).
var lotUnits = map[string]struct{}{
domain.UnitSeeds: {},
domain.UnitGrams: {},
domain.UnitOunces: {},
domain.UnitPackets: {},
domain.UnitBulbs: {},
domain.UnitPlants: {},
}
// SeedLotInput is the payload for recording a purchase.
type SeedLotInput struct {
PlantID int64
Vendor string
SourceURL string
SKU string
LotCode string
PurchasedAt *string
PackedForYear *int
Quantity float64
Unit string
CostCents *int
GerminationPct *float64
Notes string
}
// SeedLotPatch is a partial update; nil fields are left unchanged. The nullable
// columns use a Set* flag to tell "clear to NULL" from "leave alone". PlantID is
// absent deliberately: re-pointing a purchase at a different variety would
// rewrite history rather than correct it.
type SeedLotPatch struct {
Vendor *string
SourceURL *string
SKU *string
LotCode *string
SetPurchasedAt bool
PurchasedAt *string
SetPackedForYear bool
PackedForYear *int
Quantity *float64
Unit *string
SetCostCents bool
CostCents *int
SetGerminationPct bool
GerminationPct *float64
Notes *string
}
// ListSeedLots returns the actor's own lots, each with Used/Remaining filled in.
// Optionally narrowed to one plant.
func (s *Service) ListSeedLots(ctx context.Context, actorID int64, plantID *int64) ([]domain.SeedLot, error) {
lots, err := s.store.ListSeedLotsForOwner(ctx, actorID, plantID)
if err != nil {
return nil, err
}
if err := s.fillRemaining(ctx, actorID, lots); err != nil {
return nil, err
}
return lots, nil
}
// fillRemaining computes Used and Remaining for a slice of the actor's lots.
//
// Deliberately derived, never stored. The alternative — decrementing a column
// when you plant — drifts the moment anything edits or removes a planting behind
// its back, and once it has drifted there is no way to tell what the true number
// was. Attribution plus arithmetic can always be recomputed and audited.
func (s *Service) fillRemaining(ctx context.Context, actorID int64, lots []domain.SeedLot) error {
Review

🟠 GetSeedLot and UpdateSeedLot fetch all actor plantings to compute remaining for one lot

performance · flagged by 2 models

  • internal/service/seed_lots.go:93-114 and internal/store/seed_lots.go:159-184GetSeedLot and UpdateSeedLot each call fillRemaining for a single lot, but fillRemaining always executes SeedLotUsage(ctx, actorID) which fetches every active planting attributed to any of the actor's lots. For a single-lot read this is unnecessary work: if a user has 50 lots with hundreds of total plantings, computing derivedCount for every planting just to sum one lot's usage is wasteful. *…

🪰 Gadfly · advisory

🟠 **GetSeedLot and UpdateSeedLot fetch all actor plantings to compute remaining for one lot** _performance · flagged by 2 models_ - `internal/service/seed_lots.go:93-114` and `internal/store/seed_lots.go:159-184` — `GetSeedLot` and `UpdateSeedLot` each call `fillRemaining` for a single lot, but `fillRemaining` always executes `SeedLotUsage(ctx, actorID)` which fetches **every** active planting attributed to **any** of the actor's lots. For a single-lot read this is unnecessary work: if a user has 50 lots with hundreds of total plantings, computing `derivedCount` for every planting just to sum one lot's usage is wasteful. *… <sub>🪰 Gadfly · advisory</sub>
if len(lots) == 0 {
return nil
}
uses, err := s.store.SeedLotUsage(ctx, actorID)
if err != nil {
return err
}
used := make(map[int64]float64, len(lots))
for _, u := range uses {
n := derivedCount(u.RadiusCM, u.SpacingCM)
if u.Count != nil {
n = *u.Count
}
used[u.LotID] += float64(n)
}
for i := range lots {
lots[i].Used = used[lots[i].ID]
lots[i].Remaining = lots[i].Quantity - lots[i].Used
Review

🟠 fillRemaining can produce negative Remaining with no guard or docs

error-handling · flagged by 1 model

  • internal/service/seed_lots.go:111Remaining can go negative without guard or documented behavior fillRemaining does Quantity - Used with no floor. If a user plants more than they bought, or later reduces Quantity below current usage, Remaining becomes negative. The field is described as "how much is left," so a negative value is surprising and could confuse display logic. Fix: Cap at zero (max(0, Quantity - Used)) or, if negative is intentional to show over-commitment,…

🪰 Gadfly · advisory

🟠 **fillRemaining can produce negative Remaining with no guard or docs** _error-handling · flagged by 1 model_ - **`internal/service/seed_lots.go:111` — `Remaining` can go negative without guard or documented behavior** `fillRemaining` does `Quantity - Used` with no floor. If a user plants more than they bought, or later reduces `Quantity` below current usage, `Remaining` becomes negative. The field is described as "how much is left," so a negative value is surprising and could confuse display logic. **Fix:** Cap at zero (`max(0, Quantity - Used)`) or, if negative is intentional to show over-commitment,… <sub>🪰 Gadfly · advisory</sub>
}
return nil
}
// ownSeedLot loads a lot and enforces that the actor owns it. Someone else's lot
// is ErrNotFound, not ErrForbidden — existence stays masked, same as gardens and
// other users' plants.
func (s *Service) ownSeedLot(ctx context.Context, actorID, lotID int64) (*domain.SeedLot, error) {
l, err := s.store.GetSeedLot(ctx, lotID)
if err != nil {
return nil, err // ErrNotFound
Review

Comment // ErrNotFound on ownSeedLot's return is inaccurate for non-NotFound wrapped store errors

maintainability · flagged by 1 model

  • internal/service/seed_lots.go:122 — misleading inline comment. return nil, err // ErrNotFound asserts the returned error is ErrNotFound, but ownSeedLot returns whatever store.GetSeedLot returns, which includes wrapped non-NotFound errors (the store wraps scan failures in fmt.Errorf("store: get seed lot: %w", err)). The comment is wrong for those paths and could mislead a reader tracing error handling. Verified by reading GetSeedLot (store/seed_lots.go:48-58), which only maps…

🪰 Gadfly · advisory

⚪ **Comment `// ErrNotFound` on ownSeedLot's return is inaccurate for non-NotFound wrapped store errors** _maintainability · flagged by 1 model_ - **`internal/service/seed_lots.go:122` — misleading inline comment.** `return nil, err // ErrNotFound` asserts the returned error is `ErrNotFound`, but `ownSeedLot` returns whatever `store.GetSeedLot` returns, which includes wrapped non-NotFound errors (the store wraps scan failures in `fmt.Errorf("store: get seed lot: %w", err)`). The comment is wrong for those paths and could mislead a reader tracing error handling. Verified by reading `GetSeedLot` (store/seed_lots.go:48-58), which only maps… <sub>🪰 Gadfly · advisory</sub>
}
if l.OwnerID != actorID {
return nil, domain.ErrNotFound
}
return l, nil
}
// GetSeedLot returns one of the actor's own lots, with Used/Remaining filled in.
func (s *Service) GetSeedLot(ctx context.Context, actorID, lotID int64) (*domain.SeedLot, error) {
Review

🟠 GetSeedLot/UpdateSeedLot over-fetch all owner's active plantings via unscoped SeedLotUsage to compute one lot's remaining

performance · flagged by 2 models

  • internal/service/seed_lots.go:131 (GetSeedLot) and :186 (UpdateSeedLot) — single-lot reads over-fetch the entire owner's active plantings. Both paths build a one-element slice and call s.fillRemaining(ctx, actorID, one), which (seed_lots.go:97) calls s.store.SeedLotUsage(ctx, actorID). That store query (internal/store/seed_lots.go:159-166) selects every active planting joined across all the owner's lots (WHERE sl.owner_id = ? AND pl.removed_at IS NULL, no `pl.seed_lot_id…

🪰 Gadfly · advisory

🟠 **GetSeedLot/UpdateSeedLot over-fetch all owner's active plantings via unscoped SeedLotUsage to compute one lot's remaining** _performance · flagged by 2 models_ - **`internal/service/seed_lots.go:131` (`GetSeedLot`) and `:186` (`UpdateSeedLot`) — single-lot reads over-fetch the entire owner's active plantings.** Both paths build a one-element slice and call `s.fillRemaining(ctx, actorID, one)`, which (seed_lots.go:97) calls `s.store.SeedLotUsage(ctx, actorID)`. That store query (`internal/store/seed_lots.go:159-166`) selects every active planting joined across *all* the owner's lots (`WHERE sl.owner_id = ? AND pl.removed_at IS NULL`, no `pl.seed_lot_id… <sub>🪰 Gadfly · advisory</sub>
l, err := s.ownSeedLot(ctx, actorID, lotID)
if err != nil {
return nil, err
}
one := []domain.SeedLot{*l}
if err := s.fillRemaining(ctx, actorID, one); err != nil {
return nil, err
}
return &one[0], nil
}
// CreateSeedLot records a purchase owned by the actor. The plant must be one the
// actor can see — a built-in or their own — since you can perfectly well buy
// generic Garlic from a vendor.
func (s *Service) CreateSeedLot(ctx context.Context, actorID int64, in SeedLotInput) (*domain.SeedLot, error) {
if _, err := s.visiblePlant(ctx, actorID, in.PlantID); err != nil {
return nil, err // ErrInvalidInput for an unknown or someone else's plant
}
l := &domain.SeedLot{
OwnerID: actorID,
PlantID: in.PlantID,
Vendor: strings.TrimSpace(in.Vendor),
SourceURL: strings.TrimSpace(in.SourceURL),
SKU: strings.TrimSpace(in.SKU),
LotCode: strings.TrimSpace(in.LotCode),
PurchasedAt: in.PurchasedAt,
PackedForYear: in.PackedForYear,
Quantity: in.Quantity,
Unit: in.Unit,
CostCents: in.CostCents,
GerminationPct: in.GerminationPct,
Notes: strings.TrimSpace(in.Notes),
}
if err := finalizeSeedLot(l); err != nil {
return nil, err
}
return s.store.CreateSeedLot(ctx, l)
Review

🔴 CreateSeedLot returns a lot with Remaining/Used unset (reports remaining: 0 instead of the purchased quantity)

correctness, error-handling · flagged by 2 models

  • internal/service/seed_lots.go:168CreateSeedLot returns s.store.CreateSeedLot(ctx, l) directly without calling s.fillRemaining. domain.SeedLot.Remaining/Used are computed-only fields, zero-valued at construction, so a freshly created lot is returned with remaining: 0 regardless of the purchased quantity (e.g. buying 100 seeds shows "remaining": 0 in the create response instead of 100). Confirmed no test checks Remaining/Used on the CreateSeedLot return value — `seed_lot…

🪰 Gadfly · advisory

🔴 **CreateSeedLot returns a lot with Remaining/Used unset (reports remaining: 0 instead of the purchased quantity)** _correctness, error-handling · flagged by 2 models_ - `internal/service/seed_lots.go:168` — `CreateSeedLot` returns `s.store.CreateSeedLot(ctx, l)` directly without calling `s.fillRemaining`. `domain.SeedLot.Remaining`/`Used` are computed-only fields, zero-valued at construction, so a freshly created lot is returned with `remaining: 0` regardless of the purchased quantity (e.g. buying 100 seeds shows `"remaining": 0` in the create response instead of 100). Confirmed no test checks `Remaining`/`Used` on the `CreateSeedLot` return value — `seed_lot… <sub>🪰 Gadfly · advisory</sub>
}
// UpdateSeedLot applies a partial, version-guarded patch to the actor's own lot.
func (s *Service) UpdateSeedLot(ctx context.Context, actorID, lotID int64, patch SeedLotPatch, version int64) (*domain.SeedLot, error) {
Review

🔴 CreateSeedLot returns lot without computed Used/Remaining fields

correctness, maintainability · flagged by 2 models

  • internal/service/seed_lots.go:175CreateSeedLot returns the newly created lot without calling fillRemaining, so Used and Remaining are both zero in the 201 Created response. A client that reads remaining immediately after creation sees 0 instead of the lot's actual Quantity. The existing pattern in CreatePlanting (created.DerivedCount = …) shows write endpoints should return complete, derived fields.

🪰 Gadfly · advisory

🔴 **CreateSeedLot returns lot without computed Used/Remaining fields** _correctness, maintainability · flagged by 2 models_ * **`internal/service/seed_lots.go:175`** — `CreateSeedLot` returns the newly created lot without calling `fillRemaining`, so `Used` and `Remaining` are both zero in the `201 Created` response. A client that reads `remaining` immediately after creation sees `0` instead of the lot's actual `Quantity`. The existing pattern in `CreatePlanting` (`created.DerivedCount = …`) shows write endpoints should return complete, derived fields. <sub>🪰 Gadfly · advisory</sub>
l, err := s.ownSeedLot(ctx, actorID, lotID)
if err != nil {
return nil, err
}
applySeedLotPatch(l, patch)
if err := finalizeSeedLot(l); err != nil {
return nil, err
}
l.Version = version
updated, err := s.store.UpdateSeedLot(ctx, l)
if err != nil {
return updated, err
}
one := []domain.SeedLot{*updated}
if ferr := s.fillRemaining(ctx, actorID, one); ferr != nil {
return nil, ferr
}
return &one[0], nil
}
// DeleteSeedLot removes the actor's own lot. Plantings attributed to it survive
// with a null link (ON DELETE SET NULL) — the plants really were in the ground,
// whatever happened to the record of the packet.
func (s *Service) DeleteSeedLot(ctx context.Context, actorID, lotID int64) error {
Review

🟠 Seed-lot mutations not recorded in history, breaking project invariant

maintainability · flagged by 1 model

  • internal/service/seed_lots.go:196DeleteSeedLot never calls s.record(...), and domain.go has no EntitySeedLot constant. CLAUDE.md states as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existing record helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: a…

🪰 Gadfly · advisory

🟠 **Seed-lot mutations not recorded in history, breaking project invariant** _maintainability · flagged by 1 model_ - **`internal/service/seed_lots.go:196`** — `DeleteSeedLot` never calls `s.record(...)`, and `domain.go` has no `EntitySeedLot` constant. `CLAUDE.md` states as an invariant: *"Every service mutation lands in history (#48). If you add one, record it."* The existing `record` helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. **Fix:** a… <sub>🪰 Gadfly · advisory</sub>
if _, err := s.ownSeedLot(ctx, actorID, lotID); err != nil {
return err
}
return s.store.DeleteSeedLot(ctx, lotID)
}
// seedLotForPlanting validates a planting's lot attribution: the lot must be the
// actor's, and must be a lot OF the plant being planted. Attributing a garlic
// planting to a tomato lot is a data error that would silently corrupt every
// remaining count downstream, so it's refused rather than stored.
func (s *Service) seedLotForPlanting(ctx context.Context, actorID int64, lotID *int64, plantID int64) error {
if lotID == nil {
return nil
}
l, err := s.ownSeedLot(ctx, actorID, *lotID)
if err != nil {
return domain.ErrInvalidInput // unknown or not yours: an invalid reference
}
if l.PlantID != plantID {
return domain.ErrInvalidInput
}
return nil
}
func applySeedLotPatch(l *domain.SeedLot, p SeedLotPatch) {
if p.Vendor != nil {
l.Vendor = strings.TrimSpace(*p.Vendor)
}
if p.SourceURL != nil {
l.SourceURL = strings.TrimSpace(*p.SourceURL)
}
if p.SKU != nil {
l.SKU = strings.TrimSpace(*p.SKU)
}
if p.LotCode != nil {
l.LotCode = strings.TrimSpace(*p.LotCode)
}
if p.SetPurchasedAt {
l.PurchasedAt = p.PurchasedAt
}
if p.SetPackedForYear {
l.PackedForYear = p.PackedForYear
}
if p.Quantity != nil {
l.Quantity = *p.Quantity
}
if p.Unit != nil {
l.Unit = *p.Unit
}
if p.SetCostCents {
l.CostCents = p.CostCents
}
if p.SetGerminationPct {
l.GerminationPct = p.GerminationPct
}
if p.Notes != nil {
l.Notes = strings.TrimSpace(*p.Notes)
}
}
// finalizeSeedLot validates a built/merged lot. Shared by create and update.
func finalizeSeedLot(l *domain.SeedLot) error {
if _, ok := lotUnits[l.Unit]; !ok {
return domain.ErrInvalidInput
}
if !isFinite(l.Quantity) || l.Quantity < 0 || l.Quantity > maxLotQuantity {
return domain.ErrInvalidInput
}
if len(l.Vendor) > maxLotTextLen || len(l.SKU) > maxLotTextLen || len(l.LotCode) > maxLotTextLen {
return domain.ErrInvalidInput
}
if len(l.Notes) > maxLotNotesLen {
return domain.ErrInvalidInput
}
if !validSourceURL(l.SourceURL) {
return domain.ErrInvalidInput
}
if !validDatePtr(l.PurchasedAt) {
return domain.ErrInvalidInput
}
if l.PackedForYear != nil && (*l.PackedForYear < minPackedYear || *l.PackedForYear > maxPackedYear) {
return domain.ErrInvalidInput
}
if l.CostCents != nil && *l.CostCents < 0 {
return domain.ErrInvalidInput
}
if l.GerminationPct != nil {
g := *l.GerminationPct
if !isFinite(g) || g < 0 || g > 100 {
return domain.ErrInvalidInput
}
}
return nil
}
// validSourceURL accepts an empty string or an absolute http(s) URL.
//
// This renders as a clickable link, so it is untrusted input in the way that
// matters: without the scheme check a "javascript:" URL stored here becomes
// script execution the moment someone clicks it. Only http and https, and only
// with a host — a relative or schemeless URL would resolve against pansy's own
// origin, which is never what a vendor link means.
func validSourceURL(raw string) bool {
if raw == "" {
return true
}
if len(raw) > maxSourceURLLen {
return false
}
u, err := url.Parse(raw)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
return u.Host != ""
}
+345
View File
@@ -0,0 +1,345 @@
package service
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// seedLot records a purchase of plantID with the given quantity in seeds.
func seedLot(t *testing.T, s *Service, owner, plantID int64, qty float64, over func(*SeedLotInput)) *domain.SeedLot {
t.Helper()
in := SeedLotInput{PlantID: plantID, Quantity: qty, Unit: domain.UnitSeeds}
if over != nil {
over(&in)
}
l, err := s.CreateSeedLot(context.Background(), owner, in)
if err != nil {
t.Fatalf("CreateSeedLot: %v", err)
}
return l
}
// builtinPlantID returns a seeded built-in's id — the case that proves a lot can
// reference a plant it doesn't own.
func builtinPlantID(t *testing.T, s *Service, actor int64) int64 {
t.Helper()
plants, err := s.ListPlants(context.Background(), actor)
if err != nil {
t.Fatalf("ListPlants: %v", err)
}
for _, p := range plants {
if p.OwnerID == nil {
return p.ID
}
}
t.Fatal("no built-in plants seeded")
return 0
}
// TestTwoLotsOfOnePlantTrackIndependently — the reason inventory belongs to a
// purchase and not to a variety: the same garlic from two vendors in two years
// is two different things.
func TestTwoLotsOfOnePlantTrackIndependently(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
johnnys := seedLot(t, s, owner, plant.ID, 100, func(in *SeedLotInput) {
in.Vendor = "Johnny's"
in.SourceURL = "https://www.johnnyseeds.com/vegetables/garlic/german-red-garlic"
in.PackedForYear = intPtr(2026)
})
fedco := seedLot(t, s, owner, plant.ID, 50, func(in *SeedLotInput) {
in.Vendor = "Fedco"
in.PackedForYear = intPtr(2025)
})
// Plant 12 out of the Johnny's lot only.
twelve := 12
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &twelve, SeedLotID: &johnnys.ID,
}); err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
lots, err := s.ListSeedLots(ctx, owner, nil)
if err != nil {
t.Fatalf("ListSeedLots: %v", err)
}
byID := map[int64]domain.SeedLot{}
for _, l := range lots {
byID[l.ID] = l
}
if got := byID[johnnys.ID]; got.Used != 12 || got.Remaining != 88 {
t.Errorf("Johnny's lot: used=%v remaining=%v, want 12/88", got.Used, got.Remaining)
}
if got := byID[fedco.ID]; got.Used != 0 || got.Remaining != 50 {
t.Errorf("Fedco lot: used=%v remaining=%v, want 0/50", got.Used, got.Remaining)
}
if byID[johnnys.ID].Vendor != "Johnny's" || byID[johnnys.ID].SourceURL == "" {
t.Errorf("provenance lost: %+v", byID[johnnys.ID])
}
}
// TestRemainingReturnsWhenAPlantingIsRemoved — the acceptance criterion, and the
// reason `remaining` is derived rather than decremented on planting: removing a
// plop has to give the seed back, without anything having to remember to.
func TestRemainingReturnsWhenAPlantingIsRemoved(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 100, nil)
ctx := context.Background()
ten := 10
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID,
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if got, _ := s.GetSeedLot(ctx, owner, lot.ID); got.Remaining != 90 {
t.Fatalf("remaining after planting = %v, want 90", got.Remaining)
}
// Soft-remove ("cleared the bed"): only ACTIVE plantings count against a lot.
today := "2026-08-01"
if _, err := s.UpdatePlanting(ctx, owner, pl.ID, PlantingPatch{
SetRemovedAt: true, RemovedAt: &today,
}, pl.Version); err != nil {
t.Fatalf("UpdatePlanting: %v", err)
}
if got, _ := s.GetSeedLot(ctx, owner, lot.ID); got.Remaining != 100 || got.Used != 0 {
t.Errorf("after removal: used=%v remaining=%v, want 0/100", got.Used, got.Remaining)
}
}
// TestLotOnBuiltinPlantIsPrivate — you can buy generic Garlic from Johnny's, so
// a lot may point at a plant it doesn't own; the LOT is still yours alone.
func TestLotOnBuiltinPlantIsPrivate(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
other := seedUser(t, s, "[email protected]")
ctx := context.Background()
builtin := builtinPlantID(t, s, owner)
lot := seedLot(t, s, owner, builtin, 25, func(in *SeedLotInput) { in.Vendor = "Johnny's" })
if _, err := s.GetSeedLot(ctx, owner, lot.ID); err != nil {
t.Fatalf("owner can't read their own lot: %v", err)
}
// Another user gets ErrNotFound, not Forbidden: existence stays masked.
if _, err := s.GetSeedLot(ctx, other, lot.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("other user read err = %v, want ErrNotFound", err)
}
if _, err := s.UpdateSeedLot(ctx, other, lot.ID, SeedLotPatch{Notes: strPtr("mine now")}, lot.Version); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("other user update err = %v, want ErrNotFound", err)
}
if err := s.DeleteSeedLot(ctx, other, lot.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("other user delete err = %v, want ErrNotFound", err)
}
if lots, _ := s.ListSeedLots(ctx, other, nil); len(lots) != 0 {
t.Errorf("other user sees %d lots", len(lots))
}
}
// TestSourceURLValidation — the field renders as a clickable link, so the scheme
// check is what stops a stored javascript: URL from becoming script execution.
func TestSourceURLValidation(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
ok := []string{
"",
"https://www.johnnyseeds.com/x",
"http://example.com/seeds?sku=123",
}
for _, u := range ok {
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Unit: domain.UnitSeeds, SourceURL: u,
}); err != nil {
t.Errorf("SourceURL %q rejected: %v", u, err)
}
}
bad := []string{
"javascript:alert(1)",
"JavaScript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"//evil.example.com", // schemeless: would resolve against pansy's origin
"/seeds/garlic", // relative, same reason
"file:///etc/passwd",
"https://", // no host
}
for _, u := range bad {
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Unit: domain.UnitSeeds, SourceURL: u,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("SourceURL %q accepted (err=%v)", u, err)
}
}
// The same rule applies to the plant's own source link.
if _, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "X", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿",
SourceURL: "javascript:alert(1)",
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("plant SourceURL accepted a javascript: URL (err=%v)", err)
}
}
// TestPlantCarriesProvenance — the headline: "German Red Garlic" links back to
// the page it came from, and the built-ins keep working untouched.
func TestPlantCarriesProvenance(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
ctx := context.Background()
p, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "German Red Garlic", Category: domain.CategoryVegetable, SpacingCM: 15,
Color: "#8a5a8a", Icon: "🧄",
SourceURL: "https://www.johnnyseeds.com/vegetables/garlic/german-red-garlic",
Vendor: "Johnny's Selected Seeds",
})
if err != nil {
t.Fatalf("CreatePlant: %v", err)
}
if p.Vendor != "Johnny's Selected Seeds" || p.SourceURL == "" {
t.Errorf("provenance not stored: %+v", p)
}
// The built-ins predate these columns and must still read cleanly.
plants, _ := s.ListPlants(ctx, owner)
for _, b := range plants {
if b.OwnerID == nil && (b.SourceURL != "" || b.Vendor != "") {
t.Errorf("built-in %q gained provenance from nowhere: %+v", b.Name, b)
}
}
}
// TestPlantingRejectsMismatchedLot — attributing a garlic planting to a tomato
// lot would silently corrupt every remaining count downstream.
func TestPlantingRejectsMismatchedLot(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
other := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
garlic := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
tomato, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "Tomato", Category: domain.CategoryVegetable, SpacingCM: 45, Color: "#c0392b", Icon: "🍅",
})
if err != nil {
t.Fatalf("CreatePlant: %v", err)
}
tomatoLot := seedLot(t, s, owner, tomato.ID, 20, nil)
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: garlic.ID, XCM: 0, YCM: 0, RadiusCM: 20, SeedLotID: &tomatoLot.ID,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("a garlic planting was attributed to a tomato lot (err=%v)", err)
}
// Someone else's lot is an invalid reference, not a leak of its existence.
othersPlant := seedOwnPlant(t, s, other, 15)
othersLot := seedLot(t, s, other, othersPlant.ID, 10, nil)
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: garlic.ID, XCM: 0, YCM: 0, RadiusCM: 20, SeedLotID: &othersLot.ID,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("another user's lot was accepted (err=%v)", err)
}
}
// TestDeletingALotKeepsPlantingHistory — the plants really were in the ground,
// whatever happened to the record of the packet.
func TestDeletingALotKeepsPlantingHistory(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 100, nil)
ctx := context.Background()
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, SeedLotID: &lot.ID,
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if err := s.DeleteSeedLot(ctx, owner, lot.ID); err != nil {
t.Fatalf("DeleteSeedLot: %v", err)
}
survived, err := s.store.GetPlanting(ctx, pl.ID)
if err != nil {
t.Fatalf("planting was destroyed with its lot: %v", err)
}
if survived.SeedLotID != nil {
t.Errorf("seedLotId = %v, want null after the lot was deleted", *survived.SeedLotID)
}
}
// TestSeedLotVersionGuard — lots are version-guarded like every other mutable row.
func TestSeedLotVersionGuard(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, nil)
ctx := context.Background()
updated, err := s.UpdateSeedLot(ctx, owner, lot.ID, SeedLotPatch{Notes: strPtr("half used")}, lot.Version)
if err != nil {
t.Fatalf("UpdateSeedLot: %v", err)
}
if updated.Notes != "half used" || updated.Version != lot.Version+1 {
t.Errorf("unexpected update: %+v", updated)
}
// Stale version → conflict, carrying the current row.
current, err := s.UpdateSeedLot(ctx, owner, lot.ID, SeedLotPatch{Notes: strPtr("stale")}, lot.Version)
if !errors.Is(err, domain.ErrVersionConflict) {
t.Fatalf("err = %v, want ErrVersionConflict", err)
}
if current == nil || current.Notes != "half used" {
t.Errorf("conflict should carry the current row, got %+v", current)
}
}
// TestSeedLotUnitAndQuantityValidation
func TestSeedLotUnitAndQuantityValidation(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: plant.ID, Unit: "handfuls"}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("unknown unit accepted: %v", err)
}
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Unit: domain.UnitSeeds, Quantity: -1,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("negative quantity accepted: %v", err)
}
pct := 140.0
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Unit: domain.UnitSeeds, GerminationPct: &pct,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("140%% germination accepted: %v", err)
}
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Unit: domain.UnitSeeds, PurchasedAt: strPtr("not-a-date"),
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("malformed purchase date accepted: %v", err)
}
}
func intPtr(v int) *int { return &v }
@@ -0,0 +1,53 @@
-- Seed provenance and inventory (#50): where "German Red Garlic" came from, and
-- how much of it is left.
--
-- Two modelling calls worth stating, because both look like omissions otherwise:
--
-- 1. The plant catalog stays FLAT. No species→variety hierarchy. A row in your
-- catalog just is the thing you plant — "German Red Garlic" is a plant row,
-- and the built-in "Garlic" is a generic starting point you clone. A
-- hierarchy buys taxonomy nobody asked for and complicates every join.
--
-- 2. Inventory belongs to a PURCHASE, not to a variety. You might buy the same
-- garlic from two vendors in two years at two prices and two germination
-- rates. Quantity columns on plants would collapse all of that into one
-- number and lose it, so lots get their own table.
--
-- Nothing here is destructive: plants gains two defaulted columns, the built-ins
-- keep working with empty strings, and plantings gains a nullable link.
ALTER TABLE plants ADD COLUMN source_url TEXT NOT NULL DEFAULT '';
ALTER TABLE plants ADD COLUMN vendor TEXT NOT NULL DEFAULT '';
-- owner_id sits on the LOT rather than being inherited from the plant, because a
-- lot may reference a built-in plant (you can buy generic Garlic from Johnny's)
-- while still being nobody's business but yours. Lots are never shared along
-- with a garden.
CREATE TABLE seed_lots (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
plant_id INTEGER NOT NULL REFERENCES plants (id) ON DELETE CASCADE,
vendor TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
sku TEXT NOT NULL DEFAULT '',
lot_code TEXT NOT NULL DEFAULT '',
purchased_at TEXT, -- 'YYYY-MM-DD'
packed_for_year INTEGER,
quantity REAL NOT NULL DEFAULT 0,
unit TEXT NOT NULL CHECK (unit IN ('seeds','grams','ounces','packets','bulbs','plants')),
cost_cents INTEGER,
germination_pct REAL,
notes TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_seed_lots_owner ON seed_lots (owner_id);
CREATE INDEX idx_seed_lots_plant ON seed_lots (plant_id);
-- Which lot a planting came out of. ON DELETE SET NULL rather than CASCADE:
-- retiring a lot must never destroy planting history — the plants really were in
-- the ground, whatever happened to the record of the packet.
ALTER TABLE plantings ADD COLUMN seed_lot_id INTEGER REFERENCES seed_lots (id) ON DELETE SET NULL;
CREATE INDEX idx_plantings_seed_lot ON plantings (seed_lot_id) WHERE seed_lot_id IS NOT NULL;
+10 -10
View File
@@ -12,13 +12,13 @@ import (
// plantingColumns lists plantings columns in the order scanPlanting expects.
// Used unqualified for direct selects; the /full read below qualifies with pl.
const plantingColumns = `id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label,
planted_at, removed_at, version, created_at, updated_at`
planted_at, removed_at, seed_lot_id, version, created_at, updated_at`
func scanPlanting(s scanner) (*domain.Planting, error) {
var p domain.Planting
if err := s.Scan(
&p.ID, &p.ObjectID, &p.PlantID, &p.XCM, &p.YCM, &p.RadiusCM,
&p.Count, &p.Label, &p.PlantedAt, &p.RemovedAt,
&p.Count, &p.Label, &p.PlantedAt, &p.RemovedAt, &p.SeedLotID,
&p.Version, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
@@ -89,12 +89,12 @@ func (d *DB) RestorePlanting(ctx context.Context, p *domain.Planting) (*domain.P
restored, err := scanPlanting(d.sql.QueryRowContext(ctx,
`INSERT INTO plantings
(id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at, removed_at,
version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
seed_lot_id, version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
RETURNING `+plantingColumns,
p.ID, p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label,
p.PlantedAt, p.RemovedAt, p.Version, p.CreatedAt,
p.PlantedAt, p.RemovedAt, p.SeedLotID, p.Version, p.CreatedAt,
))
if err != nil {
return nil, fmt.Errorf("store: restore planting: %w", err)
@@ -153,14 +153,14 @@ func (d *DB) GetPlanting(ctx context.Context, id int64) (*domain.Planting, error
// — with plantingInsertArgs supplying its parameters — so all three stay in step
// with the table. removed_at is never set here: a new plop is active, and "clear
// bed" sets removed_at later via UpdatePlanting.
const plantingInsert = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
const plantingInsert = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at, seed_lot_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING ` + plantingColumns
// plantingInsertArgs builds plantingInsert's parameters, parenting p to objectID
// (p's own object normally, and the copied object when copying a garden).
func plantingInsertArgs(objectID int64, p *domain.Planting) []any {
return []any{objectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt}
return []any{objectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.SeedLotID}
}
// CreatePlanting inserts a plop (fields already validated by the service) and
@@ -207,12 +207,12 @@ func (d *DB) UpdatePlanting(ctx context.Context, p *domain.Planting) (*domain.Pl
updated, err := scanPlanting(d.sql.QueryRowContext(ctx,
`UPDATE plantings
SET plant_id = ?, x_cm = ?, y_cm = ?, radius_cm = ?, count = ?, label = ?,
planted_at = ?, removed_at = ?,
planted_at = ?, removed_at = ?, seed_lot_id = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+plantingColumns,
p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt,
p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt, p.SeedLotID,
p.ID, p.Version,
))
if errors.Is(err, sql.ErrNoRows) {
+9 -7
View File
@@ -11,13 +11,13 @@ import (
// plantColumns lists plants columns in the order scanPlant expects.
const plantColumns = `id, owner_id, name, category, spacing_cm, color, icon,
days_to_maturity, notes, version, created_at, updated_at`
days_to_maturity, source_url, vendor, notes, version, created_at, updated_at`
func scanPlant(s scanner) (*domain.Plant, error) {
var p domain.Plant
if err := s.Scan(
&p.ID, &p.OwnerID, &p.Name, &p.Category, &p.SpacingCM, &p.Color, &p.Icon,
&p.DaysToMaturity, &p.Notes, &p.Version, &p.CreatedAt, &p.UpdatedAt,
&p.DaysToMaturity, &p.SourceURL, &p.Vendor, &p.Notes, &p.Version, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
@@ -111,10 +111,11 @@ func (d *DB) GetPlant(ctx context.Context, id int64) (*domain.Plant, error) {
// migration only, never through this path.
func (d *DB) CreatePlant(ctx context.Context, p *domain.Plant) (*domain.Plant, error) {
created, err := scanPlant(d.sql.QueryRowContext(ctx,
`INSERT INTO plants (owner_id, name, category, spacing_cm, color, icon, days_to_maturity, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`INSERT INTO plants (owner_id, name, category, spacing_cm, color, icon, days_to_maturity, source_url, vendor, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+plantColumns,
p.OwnerID, p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity, p.Notes,
p.OwnerID, p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity,
p.SourceURL, p.Vendor, p.Notes,
))
if err != nil {
return nil, fmt.Errorf("store: insert plant: %w", err)
@@ -130,12 +131,13 @@ func (d *DB) UpdatePlant(ctx context.Context, p *domain.Plant) (*domain.Plant, e
updated, err := scanPlant(d.sql.QueryRowContext(ctx,
`UPDATE plants
SET name = ?, category = ?, spacing_cm = ?, color = ?, icon = ?,
days_to_maturity = ?, notes = ?,
days_to_maturity = ?, source_url = ?, vendor = ?, notes = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+plantColumns,
p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity, p.Notes,
p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity,
p.SourceURL, p.Vendor, p.Notes,
p.ID, p.Version,
))
if errors.Is(err, sql.ErrNoRows) {
+184
View File
@@ -0,0 +1,184 @@
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) {
Review

🔴 ListSeedLotsForOwner is unbounded: no LIMIT on query

performance · flagged by 2 models

  • internal/store/seed_lots.go:63ListSeedLotsForOwner issues an unbounded SELECT with no LIMIT. Every other list endpoint in the codebase (gardens, plants, shares) carries a defensive cap (e.g. maxGardensListed = 1000, maxPlantsListed = 5000). A gardener who records seed purchases year-over-year will see response size and memory use grow without bound. Fix: add a maxSeedLotsListed constant and LIMIT ? to the query.

🪰 Gadfly · advisory

🔴 **ListSeedLotsForOwner is unbounded: no LIMIT on query** _performance · flagged by 2 models_ * **`internal/store/seed_lots.go:63`** — `ListSeedLotsForOwner` issues an unbounded `SELECT` with no `LIMIT`. Every other list endpoint in the codebase (`gardens`, `plants`, `shares`) carries a defensive cap (e.g. `maxGardensListed = 1000`, `maxPlantsListed = 5000`). A gardener who records seed purchases year-over-year will see response size and memory use grow without bound. **Fix:** add a `maxSeedLotsListed` constant and `LIMIT ?` to the query. <sub>🪰 Gadfly · advisory</sub>
query := `SELECT ` + seedLotColumns + ` FROM seed_lots WHERE owner_id = ?`
args := []any{ownerID}
if plantID != nil {
query += ` AND plant_id = ?`
args = append(args, *plantID)
}
Review

🟠 ListSeedLotsForOwner has no LIMIT cap, unlike ListPlantsForActor

performance · flagged by 2 models

  • internal/store/seed_lots.go:69-91ListSeedLotsForOwner returns an unbounded slice with no LIMIT or pagination cap. The analogous ListPlantsForActor in the same store has a maxPlantsListed = 5000 defensive backstop (internal/store/plants.go:62), but the seed-lots list query lacks any equivalent ceiling. A user who records many purchases over multiple years could cause unbounded memory allocation in both the DB and the service. Suggested fix: Add a LIMIT constant (and a note th…

🪰 Gadfly · advisory

🟠 **ListSeedLotsForOwner has no LIMIT cap, unlike ListPlantsForActor** _performance · flagged by 2 models_ - `internal/store/seed_lots.go:69-91` — `ListSeedLotsForOwner` returns an unbounded slice with no LIMIT or pagination cap. The analogous `ListPlantsForActor` in the same store has a `maxPlantsListed = 5000` defensive backstop (`internal/store/plants.go:62`), but the seed-lots list query lacks any equivalent ceiling. A user who records many purchases over multiple years could cause unbounded memory allocation in both the DB and the service. **Suggested fix:** Add a `LIMIT` constant (and a note th… <sub>🪰 Gadfly · advisory</sub>
// 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
}
Review

🟡 Concurrent lot deletion during update returns underlying error, not ErrVersionConflict, so handler mis-maps it

error-handling · flagged by 1 model

  • internal/store/seed_lots.go:118 (UpdateSeedLot conflict path): When the version-guarded update hits sql.ErrNoRows, it re-reads via GetSeedLot; if that fails (e.g. the lot was deleted concurrently), it returns (nil, gerr) rather than ErrVersionConflict. The handler at internal/api/seed_lots.go:162 only special-cases ErrVersionConflict, so a concurrent-delete-during-update surfaces as a generic service error instead of a clean 404/conflict. Minor (concurrency race), but the err…

🪰 Gadfly · advisory

🟡 **Concurrent lot deletion during update returns underlying error, not ErrVersionConflict, so handler mis-maps it** _error-handling · flagged by 1 model_ - `internal/store/seed_lots.go:118` (`UpdateSeedLot` conflict path): When the version-guarded update hits `sql.ErrNoRows`, it re-reads via `GetSeedLot`; if *that* fails (e.g. the lot was deleted concurrently), it returns `(nil, gerr)` rather than `ErrVersionConflict`. The handler at `internal/api/seed_lots.go:162` only special-cases `ErrVersionConflict`, so a concurrent-delete-during-update surfaces as a generic service error instead of a clean 404/conflict. Minor (concurrency race), but the err… <sub>🪰 Gadfly · advisory</sub>
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
}