From 78a04672b59d3b44f9044f8e6590b2d6db64adc8 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Tue, 21 Jul 2026 05:39:24 +0000 Subject: [PATCH] Seed provenance + inventory: source links and seed lots (#50) (#64) Co-authored-by: Steve Dudenhoeffer --- DESIGN.md | 4 +- internal/api/api.go | 9 + internal/api/errors.go | 21 + internal/api/plantings.go | 9 +- internal/api/plants.go | 10 +- internal/api/seed_lots.go | 166 ++++++ internal/domain/domain.go | 55 +- internal/service/plantings.go | 17 + internal/service/plants.go | 38 +- internal/service/plants_test.go | 12 +- internal/service/revisions.go | 24 + internal/service/seed_lots.go | 347 +++++++++++++ internal/service/seed_lots_test.go | 519 +++++++++++++++++++ internal/store/gardens.go | 5 + internal/store/migrations/0007_seed_lots.sql | 53 ++ internal/store/plantings.go | 20 +- internal/store/plants.go | 16 +- internal/store/seed_lots.go | 217 ++++++++ 18 files changed, 1511 insertions(+), 31 deletions(-) create mode 100644 internal/api/seed_lots.go create mode 100644 internal/service/seed_lots.go create mode 100644 internal/service/seed_lots_test.go create mode 100644 internal/store/migrations/0007_seed_lots.sql create mode 100644 internal/store/seed_lots.go diff --git a/DESIGN.md b/DESIGN.md index 70508b5..1970ba4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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) ``` diff --git a/internal/api/api.go b/internal/api/api.go index 3cfb26b..dbf138d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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, diff --git a/internal/api/errors.go b/internal/api/errors.go index 2a16c31..83bc538 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "errors" "log/slog" "net/http" @@ -69,6 +70,26 @@ func writeVersionConflict(c *gin.Context, current any) { }) } +// parseNullable decodes any JSON value into (value, present) for a nullable +// column: absent → (nil, false); explicit null → (nil, true); anything else → +// the decoded value. That three-way distinction is what lets a PATCH tell "clear +// this to NULL" apart from "leave it alone", and every nullable field in the API +// needs it, so it lives here rather than in whichever file happened to want it +// first. +func parseNullable[T any](raw json.RawMessage) (*T, bool, error) { + 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 +} + // intQuery reads a non-negative integer query parameter, falling back to def on // an absent or malformed value. func intQuery(c *gin.Context, name string, def int) int { diff --git a/internal/api/plantings.go b/internal/api/plantings.go index c7dee01..03036c8 100644 --- a/internal/api/plantings.go +++ b/internal/api/plantings.go @@ -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 } diff --git a/internal/api/plants.go b/internal/api/plants.go index 6c38005..0ec9a40 100644 --- a/internal/api/plants.go +++ b/internal/api/plants.go @@ -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 } diff --git a/internal/api/seed_lots.go b/internal/api/seed_lots.go new file mode 100644 index 0000000..d70419d --- /dev/null +++ b/internal/api/seed_lots.go @@ -0,0 +1,166 @@ +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"` +} + +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 +} + +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) +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index b13ffbb..81c98a9 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -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 diff --git a/internal/service/plantings.go b/internal/service/plantings.go index b1006b9..246f749 100644 --- a/internal/service/plantings.go +++ b/internal/service/plantings.go @@ -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.checkSeedLotForPlanting(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.checkSeedLotForPlanting(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. diff --git a/internal/service/plants.go b/internal/service/plants.go index 24aa721..c664b7d 100644 --- a/internal/service/plants.go +++ b/internal/service/plants.go @@ -18,6 +18,7 @@ const ( minPlantSpacingCM = 0.1 maxPlantSpacingCM = 10_000 // 100 m — headroom for large trees/shrubs maxDaysToMaturity = 3650 // ~10 years + maxPlantVendorLen = 200 ) // plantCategories is the set of valid category enum values (mirrors the schema @@ -40,7 +41,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 +59,8 @@ type PlantPatch struct { Icon *string SetDays bool DaysToMaturity *int + SourceURL *string + Vendor *string Notes *string } @@ -73,6 +80,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 { @@ -121,6 +130,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 @@ -132,6 +147,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) } @@ -155,6 +177,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 +212,13 @@ func finalizePlant(p *domain.Plant) error { if len(p.Notes) > maxPlantNotesLen { return domain.ErrInvalidInput } + if len(p.Vendor) > maxPlantVendorLen { + 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 } diff --git a/internal/service/plants_test.go b/internal/service/plants_test.go index d5111a7..6b20854 100644 --- a/internal/service/plants_test.go +++ b/internal/service/plants_test.go @@ -184,12 +184,12 @@ func TestCreatePlantValidation(t *testing.T) { owner := seedUser(t, s, "a@example.com") 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 + {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 } diff --git a/internal/service/revisions.go b/internal/service/revisions.go index d71afa2..ee59555 100644 --- a/internal/service/revisions.go +++ b/internal/service/revisions.go @@ -417,6 +417,9 @@ type revertOps[T any] struct { // remove undoes a creation. It returns the changes it made, because deleting // an object also deletes the plantings hanging off it. remove func(ctx context.Context, row *T) ([]change, error) + // beforeRestore fixes up a snapshot whose references may have gone stale + // while it sat in history. + beforeRestore func(ctx context.Context, row *T) error // blockRemoval optionally refuses a removal, e.g. an object that has gained // plantings since. Returns a conflict reason, or "" to allow it. blockRemoval func(ctx context.Context, row *T) (string, error) @@ -445,6 +448,11 @@ func revertEntity[T any]( if err := unsnapshot(r.Before, &target); err != nil { return nil, nil, err } + if ops.beforeRestore != nil { + if err := ops.beforeRestore(ctx, &target); err != nil { + return nil, nil, err + } + } restored, err := ops.restore(ctx, &target) if err != nil { // The id may have been taken between the check above and this insert. @@ -547,6 +555,22 @@ func plantingRevertOps(s *Service) revertOps[domain.Planting] { get: s.store.GetPlanting, update: s.store.UpdatePlanting, restore: s.store.RestorePlanting, + // A snapshot can name a seed lot that has since been deleted. The live + // rows got their link nulled by ON DELETE SET NULL, but the snapshot + // still holds the old id, and restoring it would violate the foreign key + // and abort the whole revert. Drop the dangling link instead: the plant + // really was in the ground, which is the part worth restoring. + beforeRestore: func(ctx context.Context, p *domain.Planting) error { + if p.SeedLotID == nil { + return nil + } + if _, err := s.store.GetSeedLot(ctx, *p.SeedLotID); errors.Is(err, domain.ErrNotFound) { + p.SeedLotID = nil + } else if err != nil { + return err + } + return nil + }, remove: func(ctx context.Context, p *domain.Planting) ([]change, error) { if err := s.store.DeletePlanting(ctx, p.ID); err != nil { return nil, err diff --git a/internal/service/seed_lots.go b/internal/service/seed_lots.go new file mode 100644 index 0000000..9bfa8fc --- /dev/null +++ b/internal/service/seed_lots.go @@ -0,0 +1,347 @@ +package service + +import ( + "context" + "log/slog" + "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. +// Remaining may go NEGATIVE, and that is deliberate: it means more was planted +// than the lot recorded buying, which is a real thing that happens (a +// miscounted packet, a lot entered after the fact). Clamping it to zero would +// hide the discrepancy at exactly the moment it is worth seeing. +func (s *Service) fillRemaining(ctx context.Context, actorID int64, lots []domain.SeedLot) error { + if len(lots) == 0 { + return nil + } + ids := make([]int64, 0, len(lots)) + for i := range lots { + ids = append(ids, lots[i].ID) + } + uses, err := s.store.SeedLotUsage(ctx, actorID, ids) + 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 + } + 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. A store failure that isn't "no such row" passes through +// unchanged; it is not an authorization answer. +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 for a missing row; anything else is real + } + 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) { + l, err := s.ownSeedLot(ctx, actorID, lotID) + if err != nil { + return nil, err + } + return s.withRemaining(ctx, actorID, l) +} + +// 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 + } + created, err := s.store.CreateSeedLot(ctx, l) + if err != nil { + return nil, err + } + // A fresh lot has used nothing, but say so explicitly rather than letting the + // zero value stand in: an unfilled Remaining reads as "none left" on a packet + // you just bought. + return s.withRemaining(ctx, actorID, created) +} + +// withRemaining fills Used/Remaining on a single lot. +func (s *Service) withRemaining(ctx context.Context, actorID int64, l *domain.SeedLot) (*domain.SeedLot, error) { + one := []domain.SeedLot{*l} + if err := s.fillRemaining(ctx, actorID, one); err != nil { + return nil, err + } + return &one[0], nil +} + +// 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) { + 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 { + // The conflict path carries the CURRENT row back to the client to rebase + // on, so it needs its computed fields too — a 409 body reporting + // remaining=0 would be worse than no number at all. + if updated != nil { + if filled, ferr := s.withRemaining(ctx, actorID, updated); ferr == nil { + updated = filled + } + } + return updated, err + } + // The write succeeded. If the follow-up read fails, return the row without + // its computed fields rather than reporting a failed update that applied. + filled, ferr := s.withRemaining(ctx, actorID, updated) + if ferr != nil { + slog.Error("service: lot updated but remaining could not be computed", "error", ferr, "lot", lotID) + return updated, nil + } + return filled, 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 { + if _, err := s.ownSeedLot(ctx, actorID, lotID); err != nil { + return err + } + return s.store.DeleteSeedLot(ctx, lotID) +} + +// checkSeedLotForPlanting 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) checkSeedLotForPlanting(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 != "" +} diff --git a/internal/service/seed_lots_test.go b/internal/service/seed_lots_test.go new file mode 100644 index 0000000..e7a2eed --- /dev/null +++ b/internal/service/seed_lots_test.go @@ -0,0 +1,519 @@ +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, "a@example.com") + 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 = ptrInt(2026) + }) + fedco := seedLot(t, s, owner, plant.ID, 50, func(in *SeedLotInput) { + in.Vendor = "Fedco" + in.PackedForYear = ptrInt(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, "a@example.com") + 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, "a@example.com") + other := seedUser(t, s, "b@example.com") + 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, "a@example.com") + 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,", + "//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, "a@example.com") + 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, "a@example.com") + other := seedUser(t, s, "b@example.com") + 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, "a@example.com") + 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, "a@example.com") + 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, "a@example.com") + 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) + } +} + +// 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, "a@example.com") + plant := seedOwnPlant(t, s, owner, 15) + lot := seedLot(t, s, owner, plant.ID, 100, func(in *SeedLotInput) { in.CostCents = ptrInt(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) + } +} + +// TestFreshLotReportsItsFullQuantity — an unfilled Remaining reads as "none +// left" on a packet you just bought, which is the opposite of the truth. +func TestFreshLotReportsItsFullQuantity(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + plant := seedOwnPlant(t, s, owner, 15) + + lot := seedLot(t, s, owner, plant.ID, 100, nil) + if lot.Remaining != 100 || lot.Used != 0 { + t.Errorf("fresh lot: used=%v remaining=%v, want 0/100", lot.Used, lot.Remaining) + } +} + +// TestVersionConflictCarriesRemaining — the 409 body is what the client rebases +// on, so it needs the computed fields too. +func TestVersionConflictCarriesRemaining(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + 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 + if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, + }); err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + if _, err := s.UpdateSeedLot(ctx, owner, lot.ID, SeedLotPatch{Notes: strPtr("first")}, lot.Version); err != nil { + t.Fatalf("first update: %v", err) + } + 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.Remaining != 90 || current.Used != 10 { + t.Errorf("conflict row: used=%v remaining=%v, want 10/90", current.Used, current.Remaining) + } +} + +// TestRemainingGoesNegativeRatherThanHidingIt — planting more than you recorded +// buying is a real thing; clamping to zero would hide the discrepancy at exactly +// the moment it is worth seeing. +func TestRemainingGoesNegativeRatherThanHidingIt(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + 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, 5, nil) + ctx := context.Background() + + twenty := 20 + if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &twenty, SeedLotID: &lot.ID, + }); err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + got, err := s.GetSeedLot(ctx, owner, lot.ID) + if err != nil { + t.Fatalf("GetSeedLot: %v", err) + } + if got.Remaining != -15 { + t.Errorf("remaining = %v, want -15 (20 planted from a lot of 5)", got.Remaining) + } +} + +// TestCopiedGardenDoesNotInheritSeedLots — a copy is a plan, not a second +// planting out of the same packet. Carrying the link would double-count the +// original lot's usage and quietly attach a private lot to a garden that may +// later be shared. +func TestCopiedGardenDoesNotInheritSeedLots(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + 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 + if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, + }); err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + + copied, err := s.CopyGarden(ctx, owner, g.ID, "Copy") + if err != nil { + t.Fatalf("CopyGarden: %v", err) + } + full, err := s.GardenFull(ctx, owner, copied.ID) + if err != nil { + t.Fatalf("GardenFull: %v", err) + } + if len(full.Plantings) != 1 { + t.Fatalf("copy has %d plantings, want 1", len(full.Plantings)) + } + if full.Plantings[0].SeedLotID != nil { + t.Errorf("the copy inherited seed lot %v", *full.Plantings[0].SeedLotID) + } + // And the original lot's usage is unchanged: still 10, not 20. + got, _ := s.GetSeedLot(ctx, owner, lot.ID) + if got.Used != 10 { + t.Errorf("copying double-counted the lot: used = %v, want 10", got.Used) + } +} + +// TestRevertRestoresAPlantingWhoseLotIsGone — a snapshot can name a lot that has +// since been deleted; restoring it verbatim would violate the FK and abort the +// whole revert. +func TestRevertRestoresAPlantingWhoseLotIsGone(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + 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.DeletePlanting(ctx, owner, pl.ID); err != nil { + t.Fatalf("DeletePlanting: %v", err) + } + del := history(t, s, owner, g.ID)[0] + + // The lot goes away while the deletion sits in history. + if err := s.DeleteSeedLot(ctx, owner, lot.ID); err != nil { + t.Fatalf("DeleteSeedLot: %v", err) + } + + if _, conflicts, err := s.RevertChangeSet(ctx, owner, del.ID, domain.SourceUI); err != nil || len(conflicts) != 0 { + t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts) + } + restored, err := s.store.GetPlanting(ctx, pl.ID) + if err != nil { + t.Fatalf("planting not restored: %v", err) + } + if restored.SeedLotID != nil { + t.Errorf("a dangling lot reference was restored: %v", *restored.SeedLotID) + } +} diff --git a/internal/store/gardens.go b/internal/store/gardens.go index 20e5b8a..68fa56b 100644 --- a/internal/store/gardens.go +++ b/internal/store/gardens.go @@ -234,6 +234,11 @@ func (d *DB) CopyGarden(ctx context.Context, srcID, ownerID int64, name string) // Unreachable: every active planting joins an object we just copied. return nil, fmt.Errorf("store: copy planting %d: source object %d not copied", p.ID, p.ObjectID) } + // A copy must not inherit the source's seed-lot attribution. The copied + // plops are a plan, not a second planting out of the same packet, and + // carrying the link would double-count that lot's usage — and quietly + // attach a private lot to a garden that may later be shared. + p.SeedLotID = nil if _, err := scanPlanting(tx.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(objectID, &p)...)); err != nil { return nil, fmt.Errorf("store: copy planting: %w", err) } diff --git a/internal/store/migrations/0007_seed_lots.sql b/internal/store/migrations/0007_seed_lots.sql new file mode 100644 index 0000000..8021f8a --- /dev/null +++ b/internal/store/migrations/0007_seed_lots.sql @@ -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; diff --git a/internal/store/plantings.go b/internal/store/plantings.go index 14c16f7..b2077ba 100644 --- a/internal/store/plantings.go +++ b/internal/store/plantings.go @@ -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) { diff --git a/internal/store/plants.go b/internal/store/plants.go index e1b53ee..f6b06b9 100644 --- a/internal/store/plants.go +++ b/internal/store/plants.go @@ -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) { diff --git a/internal/store/seed_lots.go b/internal/store/seed_lots.go new file mode 100644 index 0000000..688a6cb --- /dev/null +++ b/internal/store/seed_lots.go @@ -0,0 +1,217 @@ +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 +}