Seed provenance + inventory: source links on plants, seed_lots for what you actually bought #50

Closed
opened 2026-07-21 04:17:28 +00:00 by steve · 0 comments
Owner

Design: DESIGN.md

Context

The goal: "German Red Garlic" should link back to the Johnny's Seeds page it came from, and pansy should know how much is left.

Today plants is a flat species catalog — name, category, spacing, color, icon, days-to-maturity, notes. No source, no vendor, no quantity. The catalog currently holds 32 built-ins and zero custom rows, and there are zero plantings, so this is the cheapest possible moment to change the plant model.

Two deliberate modelling calls:

  1. Keep the catalog 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 is a property of a purchase, not of 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.

Schema

ALTER TABLE plants ADD COLUMN source_url TEXT NOT NULL DEFAULT '';
ALTER TABLE plants ADD COLUMN vendor     TEXT NOT NULL DEFAULT '';

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);

ALTER TABLE plantings ADD COLUMN seed_lot_id INTEGER REFERENCES seed_lots (id) ON DELETE SET NULL;

owner_id is on the lot, not inherited from the plant: a lot may reference a built-in plant (you can buy generic Garlic from Johnny's) while still being yours. ON DELETE SET NULL on the planting link means retiring a lot never destroys planting history.

Scope

  • Migration 0007_seed_lots.sql.
  • internal/store/seed_lots.go + internal/service/seed_lots.go: CRUD scoped to the owner, version-guarded like every other mutable resource. A lot is visible only to its owner — lots are not shared along with a garden.
  • remaining on a lot: quantity minus the summed effective count of active plantings attributed to it. Computed in the service like DerivedCount, never stored.
  • source_url validation: http/https only, length-capped. It renders as a link, so treat it as untrusted input.
  • API: GET,POST /seed-lots, PATCH,DELETE /seed-lots/:id, and lots included when reading a plant. Accept seedLotId on planting create/update.

Out of scope

  • Automatic decrement on planting. Attribution + a computed remaining is the honest version; silently mutating a stored quantity is the kind of thing that drifts and can't be audited.
  • Scanning/importing from vendor sites. Paste a URL.
  • Sharing lots between users.

Key files

internal/store/plants.go · internal/service/plants.go (ownership + built-in rules to mirror) · internal/store/migrations/

Dependencies

None.

Acceptance criteria

  • A custom plant can carry a Johnny's URL and vendor; the built-ins keep working untouched with empty strings.
  • Two lots of the same plant, different vendors and years, coexist and report remaining independently.
  • A lot referencing a built-in plant is owned by, and visible only to, its creator.
  • Planting from a lot and then removing the plop returns the remaining to its prior value.
  • Another user gets ErrNotFound for your lots. go test ./... green.
Design: [DESIGN.md](https://gitea.stevedudenhoeffer.com/steve/pansy/src/branch/main/DESIGN.md) ## Context The goal: "German Red Garlic" should link back to the Johnny's Seeds page it came from, and pansy should know how much is left. Today `plants` is a flat species catalog — name, category, spacing, color, icon, days-to-maturity, notes. No source, no vendor, no quantity. The catalog currently holds **32 built-ins and zero custom rows**, and there are **zero plantings**, so this is the cheapest possible moment to change the plant model. **Two deliberate modelling calls:** 1. **Keep the catalog 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 is a property of a purchase, not of 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. ## Schema ```sql ALTER TABLE plants ADD COLUMN source_url TEXT NOT NULL DEFAULT ''; ALTER TABLE plants ADD COLUMN vendor TEXT NOT NULL DEFAULT ''; 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); ALTER TABLE plantings ADD COLUMN seed_lot_id INTEGER REFERENCES seed_lots (id) ON DELETE SET NULL; ``` `owner_id` is on the lot, not inherited from the plant: a lot may reference a **built-in** plant (you can buy generic Garlic from Johnny's) while still being yours. `ON DELETE SET NULL` on the planting link means retiring a lot never destroys planting history. ## Scope - [ ] Migration `0007_seed_lots.sql`. - [ ] `internal/store/seed_lots.go` + `internal/service/seed_lots.go`: CRUD scoped to the owner, version-guarded like every other mutable resource. A lot is visible only to its owner — lots are *not* shared along with a garden. - [ ] `remaining` on a lot: `quantity` minus the summed effective count of active plantings attributed to it. Computed in the service like `DerivedCount`, never stored. - [ ] `source_url` validation: `http`/`https` only, length-capped. It renders as a link, so treat it as untrusted input. - [ ] API: `GET,POST /seed-lots`, `PATCH,DELETE /seed-lots/:id`, and lots included when reading a plant. Accept `seedLotId` on planting create/update. ## Out of scope - Automatic decrement on planting. Attribution + a computed `remaining` is the honest version; silently mutating a stored quantity is the kind of thing that drifts and can't be audited. - Scanning/importing from vendor sites. Paste a URL. - Sharing lots between users. ## Key files `internal/store/plants.go` · `internal/service/plants.go` (ownership + built-in rules to mirror) · `internal/store/migrations/` ## Dependencies None. ## Acceptance criteria - A custom plant can carry a Johnny's URL and vendor; the built-ins keep working untouched with empty strings. - Two lots of the same plant, different vendors and years, coexist and report `remaining` independently. - A lot referencing a built-in plant is owned by, and visible only to, its creator. - Planting from a lot and then removing the plop returns the `remaining` to its prior value. - Another user gets `ErrNotFound` for your lots. `go test ./...` green.
steve added the backendplants labels 2026-07-21 04:17:28 +00:00
steve closed this issue 2026-07-21 05:39:25 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/pansy#50