3 Commits
Author SHA1 Message Date
steveandClaude Opus 4.8 6638696b00 Address Gadfly review on the grow journal
Build image / build-and-push (push) Successful in 5s
PATCH and DELETE /journal/:id were never registered. Both handlers existed, were
covered by service-level tests, and were completely unreachable — my edit to the
router anchored on a string that only exists on another branch, so it silently
did nothing. Registered, and covered by an API-level test that walks the whole
lifecycle through the router, because that is the only kind of test that could
have caught it. Anything addressed by its own id now has one.

An entry about a plop was invisible under its bed's filter. Naming only a
planting left object_id null, so "notes about this bed" silently excluded every
note written about something growing IN the bed — the two filters disagreed
about what an entry is about, which is precisely the question the reader is
asking. The parent object is now derived from the planting.

checkJournalTarget flattened every store error to ErrInvalidInput, so a real
database failure surfaced as a 400 telling the caller their request was bad, and
never reached the logs. Only "no such row" is a bad reference now; anything else
passes through.

ListJournalEntries repeated the column scan order inline, one column different
from scanJournalEntry — the classic way for a shared column list to drift out of
step with its readers. Both now build from journalScanTargets, with the list
appending the joined author name.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:39:56 -04:00
steveandClaude Opus 4.8 baf793a9f5 Grow journal: time-stamped notes on gardens, beds and plantings (#52)
"Take notes through the season on each bed and plant." There is already
free-text notes on gardens, objects and plants, but it is a single mutable
field: writing "powdery mildew on the west bed" overwrites what you wrote in
June. The distinction worth keeping is that notes says what this thing IS, while
a journal entry says what HAPPENED, and when. Both stay.

garden_id is NOT NULL even when an entry is about a bed or a single plop, and
that is the whole trick: permission checks reuse requireGardenRole unchanged and
no second ACL path is invented. object_id/planting_id narrow the target; the
garden always anchors it. Because the garden anchors permission, the target is
checked to actually live in that garden — otherwise a valid object id from
someone else's garden would be storable here and then leak through the list
read. A plop-level entry that also names an object must name the right one, or
the two filters would disagree about what an entry is about.

observed_at is distinct from created_at: you write up Saturday's observations on
Sunday, and Saturday is the date that matters. Listing orders by observation,
newest first, with id breaking ties inside a day.

Roles follow the existing shape. Editors write, viewers read, strangers get
ErrNotFound. An author may edit their own entries; the garden owner may DELETE
any entry in their garden but may not edit one — rewriting somebody else's
observation under their name is a different act from removing it.

Deliberately not wired into the revision history, per the decision on #52:
entries are already append-shaped and individually versioned, and undoing a note
is just deleting it. There's a test asserting journal writes produce no change
sets, so that decision can't quietly reverse itself later.

Closes #52

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:39:56 -04:00
steve 78a04672b5 Seed provenance + inventory: source links and seed lots (#50) (#64)
Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:39:24 +00:00
24 changed files with 2612 additions and 31 deletions
+4 -1
View File
@@ -21,7 +21,9 @@ 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.
- **journal_entries** — the grow log: `garden_id` (NOT NULL), nullable `object_id`/`planting_id` to narrow the target, author, body, `observed_at`. `notes` on a garden/object/plant says what the thing IS and a new note overwrites the old; a journal entry says what HAPPENED and when, and entries accumulate. `garden_id` is set even for a bed- or plop-level entry so permission checks reuse `requireGardenRole` unchanged rather than inventing a second ACL path. `observed_at` is distinct from `created_at` — you write up Saturday's observations on Sunday. Deliberately **not** in the revision history: entries are already append-shaped and individually versioned.
- **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 +61,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)
```
+19
View File
@@ -85,6 +85,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load
gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first
gardens.POST("/:id/objects", h.createObject)
// The grow journal hangs off a garden even for entries about one bed or one
// plop, so it inherits the ordinary garden-role check.
gardens.GET("/:id/journal", h.listJournal)
gardens.POST("/:id/journal", h.createJournalEntry)
// Sharing (owner-managed; a recipient may remove their own share).
gardens.GET("/:id/shares", h.listShares)
@@ -117,6 +121,12 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
changeSets := v1.Group("/change-sets", h.requireAuth())
changeSets.POST("/:id/revert", h.revertChangeSet)
// Journal entries are addressed by their own id; the service resolves the
// owning garden for the permission check, same as objects and plantings.
journal := v1.Group("/journal", h.requireAuth())
journal.PATCH("/:id", h.updateJournalEntry)
journal.DELETE("/:id", h.deleteJournalEntry)
// Plant catalog: built-ins (seeded, read-only) plus the actor's own rows.
plants := v1.Group("/plants", h.requireAuth())
plants.GET("", h.listPlants)
@@ -124,6 +134,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,
+21
View File
@@ -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 {
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Grow journal (#52). Entries hang off a garden even when they're about one bed
// or one plop, which is what lets the permission check be the ordinary garden
// role check with nothing new invented.
// journalResponse is the body of GET /gardens/:id/journal.
type journalResponse struct {
Entries []domain.JournalEntry `json:"entries"`
HasMore bool `json:"hasMore"`
}
// journalCreateRequest is the body for POST /gardens/:id/journal. observedAt
// defaults to today when omitted — the common case is writing about now.
type journalCreateRequest struct {
ObjectID *int64 `json:"objectId"`
PlantingID *int64 `json:"plantingId"`
Body string `json:"body" binding:"required"`
ObservedAt *string `json:"observedAt"`
}
// journalUpdateRequest is the body for PATCH /journal/:id. Only the text and the
// date it describes are editable; an entry's target and author are fixed.
type journalUpdateRequest struct {
Body *string `json:"body"`
ObservedAt *string `json:"observedAt"`
Version int64 `json:"version" binding:"required"`
}
func (h *handlers) listJournal(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
q := service.JournalQuery{
Limit: intQuery(c, "limit", 0),
Offset: intQuery(c, "offset", 0),
}
var err error
if q.ObjectID, err = optionalIDQuery(c, "objectId"); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid objectId")
return
}
if q.PlantingID, err = optionalIDQuery(c, "plantingId"); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid plantingId")
return
}
if from := c.Query("from"); from != "" {
q.From = &from
}
if to := c.Query("to"); to != "" {
q.To = &to
}
entries, hasMore, err := h.svc.ListJournal(c.Request.Context(), mustActor(c).ID, gardenID, q)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, journalResponse{Entries: entries, HasMore: hasMore})
}
func (h *handlers) createJournalEntry(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
var req journalCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid entry: a body is required")
return
}
e, err := h.svc.CreateJournalEntry(c.Request.Context(), mustActor(c).ID, gardenID, service.JournalInput{
ObjectID: req.ObjectID, PlantingID: req.PlantingID, Body: req.Body, ObservedAt: req.ObservedAt,
})
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusCreated, e)
}
func (h *handlers) updateJournalEntry(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
var req journalUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid update: a current version is required")
return
}
e, err := h.svc.UpdateJournalEntry(c.Request.Context(), mustActor(c).ID, id,
service.JournalPatch{Body: req.Body, ObservedAt: req.ObservedAt}, req.Version)
if err != nil {
if errors.Is(err, domain.ErrVersionConflict) {
writeVersionConflict(c, e)
return
}
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, e)
}
func (h *handlers) deleteJournalEntry(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.svc.DeleteJournalEntry(c.Request.Context(), mustActor(c).ID, id); err != nil {
writeServiceError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// optionalIDQuery reads a positive int64 query parameter, or nil when absent.
// A present-but-malformed value is an error rather than a silent nil, so a typo
// doesn't quietly widen a filter to the whole garden.
func optionalIDQuery(c *gin.Context, name string) (*int64, error) {
raw := c.Query(name)
if raw == "" {
return nil, nil
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id < 1 {
return nil, errors.New("invalid id")
}
return &id, nil
}
+157
View File
@@ -0,0 +1,157 @@
package api
import (
"net/http"
"strconv"
"testing"
)
func journalPath(gardenID int64) string {
return "/api/v1/gardens/" + strconv.FormatInt(gardenID, 10) + "/journal"
}
func journalEntryPath(id int64) string {
return "/api/v1/journal/" + strconv.FormatInt(id, 10)
}
// TestJournalCrudAPI walks the whole entry lifecycle over HTTP.
//
// It exists because the service-level tests could not see that PATCH and DELETE
// were never registered on the router: the handlers were written, tested through
// the service, and completely unreachable. Anything addressed by its own id
// needs at least one test that goes through the router.
func TestJournalCrudAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
"body": "First frost tonight", "observedAt": "2026-10-12",
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
}
entry := decodeMap(t, w.Body.Bytes())
id := int64(entry["id"].(float64))
if entry["observedAt"] != "2026-10-12" || entry["body"] != "First frost tonight" {
t.Errorf("unexpected entry: %+v", entry)
}
// List it back.
w = doJSON(t, r, http.MethodGet, journalPath(gid), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
}
body := decodeMap(t, w.Body.Bytes())
entries, _ := body["entries"].([]any)
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1: %s", len(entries), w.Body.String())
}
if first := entries[0].(map[string]any); first["authorName"] == "" {
t.Error("author name missing from the list read")
}
// PATCH — the route this test exists for.
w = doJSON(t, r, http.MethodPatch, journalEntryPath(id), map[string]any{
"body": "First frost, tomatoes done", "version": entry["version"],
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String())
}
updated := decodeMap(t, w.Body.Bytes())
if updated["body"] != "First frost, tomatoes done" {
t.Errorf("body = %v", updated["body"])
}
// A stale version conflicts, carrying the current row.
w = doJSON(t, r, http.MethodPatch, journalEntryPath(id), map[string]any{
"body": "stale", "version": entry["version"],
}, cookie)
if w.Code != http.StatusConflict {
t.Errorf("stale patch: status %d, want 409", w.Code)
}
if current, _ := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); current == nil {
t.Error("409 should carry the current row")
}
// DELETE — likewise.
w = doJSON(t, r, http.MethodDelete, journalEntryPath(id), nil, cookie)
if w.Code != http.StatusNoContent {
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
}
w = doJSON(t, r, http.MethodGet, journalPath(gid), nil, cookie)
if entries, _ := decodeMap(t, w.Body.Bytes())["entries"].([]any); len(entries) != 0 {
t.Errorf("%d entries survived the delete", len(entries))
}
}
// TestJournalFiltersAPI covers the query parameters, including a malformed one.
func TestJournalFiltersAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
}, cookie)
objectID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
"body": "garden level", "observedAt": "2026-05-01",
}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
"objectId": objectID, "body": "bed level", "observedAt": "2026-06-01",
}, cookie)
countAt := func(query string) int {
t.Helper()
w := doJSON(t, r, http.MethodGet, journalPath(gid)+query, nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("list%s: status %d, body %s", query, w.Code, w.Body.String())
}
entries, _ := decodeMap(t, w.Body.Bytes())["entries"].([]any)
return len(entries)
}
if n := countAt(""); n != 2 {
t.Errorf("unfiltered = %d, want 2", n)
}
if n := countAt("?objectId=" + strconv.FormatInt(objectID, 10)); n != 1 {
t.Errorf("by object = %d, want 1", n)
}
if n := countAt("?from=2026-05-15&to=2026-12-31"); n != 1 {
t.Errorf("by date range = %d, want 1", n)
}
// A malformed filter is an error, not a silently ignored one that widens the
// query back to the whole garden.
w = doJSON(t, r, http.MethodGet, journalPath(gid)+"?objectId=abc", nil, cookie)
if w.Code != http.StatusBadRequest {
t.Errorf("malformed objectId: status %d, want 400", w.Code)
}
w = doJSON(t, r, http.MethodGet, journalPath(gid)+"?from=nonsense", nil, cookie)
if w.Code != http.StatusBadRequest {
t.Errorf("malformed from: status %d, want 400", w.Code)
}
}
// TestJournalRequiresAccessAPI — anonymous is 401, a stranger is 404.
func TestJournalRequiresAccessAPI(t *testing.T) {
r := authEngine(t, localCfg())
owner := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, owner, "G")
stranger := registerAndCookie(t, r, "[email protected]")
if w := doJSON(t, r, http.MethodGet, journalPath(gid), nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous list: status %d, want 401", w.Code)
}
if w := doJSON(t, r, http.MethodGet, journalPath(gid), nil, stranger); w.Code != http.StatusNotFound {
t.Errorf("stranger list: status %d, want 404", w.Code)
}
w := doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"body": "nope"}, stranger)
if w.Code != http.StatusNotFound {
t.Errorf("stranger write: status %d, want 404", w.Code)
}
if w := doJSON(t, r, http.MethodDelete, journalEntryPath(1), nil, stranger); w.Code != http.StatusNotFound {
t.Errorf("stranger delete: status %d, want 404", w.Code)
}
}
+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
}
+166
View File
@@ -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)
}
+78 -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
@@ -157,6 +165,32 @@ type Revision struct {
After *string `json:"after,omitempty"`
}
// JournalEntry is one time-stamped observation: what happened, and when.
// Distinct from the mutable `notes` on a garden/object/plant, which says what
// the thing IS — writing a new note overwrites the old one, while entries
// accumulate.
//
// GardenID is set even when the entry is about a bed or a plop, so permission
// checks reuse the garden role machinery unchanged; ObjectID/PlantingID narrow
// the target without inventing a second ACL path.
type JournalEntry struct {
ID int64 `json:"id"`
GardenID int64 `json:"gardenId"`
ObjectID *int64 `json:"objectId,omitempty"`
PlantingID *int64 `json:"plantingId,omitempty"`
AuthorID int64 `json:"authorId"`
Body string `json:"body"`
// ObservedAt is when it happened ('YYYY-MM-DD'), which is not when it was
// written down: you write up Saturday's observations on Sunday.
ObservedAt string `json:"observedAt"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
// AuthorName is resolved for display by the service, never persisted.
AuthorName string `json:"authorName,omitempty"`
}
// RevertConflict reports one entity a revert deliberately left alone, because
// reverting it would have discarded a change made after the change set being
// reverted. The rest of the change set still reverts.
@@ -280,6 +314,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 +328,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 +379,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
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"context"
"errors"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
)
// The grow journal (#52): what happened, and when. Distinct from the mutable
// `notes` on a garden/object/plant, which says what the thing IS — a new note
// overwrites the old one, while entries accumulate across a season.
//
// Deliberately NOT wired into the revision history (#48). Entries are already
// append-shaped and individually versioned, and undoing a note is just deleting
// it; running them through change sets would add a layer that buys nothing.
const (
maxJournalBodyLen = 10_000
maxJournalPageSize = 200
defaultJournalPageSize = 50
)
// JournalInput is the payload for writing an entry. ObjectID/PlantingID are
// optional and narrow the target; ObservedAt defaults to today.
type JournalInput struct {
ObjectID *int64
PlantingID *int64
Body string
ObservedAt *string
}
// JournalPatch is a partial update. Only the text and the date it describes are
// editable — see UpdateJournalEntry.
type JournalPatch struct {
Body *string
ObservedAt *string
}
// JournalQuery narrows a garden's journal for reading.
type JournalQuery struct {
ObjectID *int64
PlantingID *int64
From *string
To *string
Limit int
Offset int
}
// ListJournal returns a garden's entries, most recently observed first, for an
// actor who can at least view the garden.
func (s *Service) ListJournal(ctx context.Context, actorID, gardenID int64, q JournalQuery) ([]domain.JournalEntry, bool, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
return nil, false, err
}
if !validDatePtr(q.From) || !validDatePtr(q.To) {
return nil, false, domain.ErrInvalidInput
}
limit := q.Limit
if limit <= 0 {
limit = defaultJournalPageSize
}
if limit > maxJournalPageSize {
limit = maxJournalPageSize
}
offset := q.Offset
if offset < 0 {
offset = 0
}
// One row past the page answers hasMore without a second COUNT (same shape as
// GardenHistory).
entries, err := s.store.ListJournalEntries(ctx, gardenID, store.JournalFilter{
ObjectID: q.ObjectID, PlantingID: q.PlantingID, From: q.From, To: q.To,
Limit: limit + 1, Offset: offset,
})
if err != nil {
return nil, false, err
}
if len(entries) > limit {
return entries[:limit], true, nil
}
return entries, false, nil
}
// CreateJournalEntry writes an entry against a garden the actor can edit.
//
// An entry may target the garden, one of its beds, or one plop in it — and the
// target is checked to actually belong to this garden. Without that, a valid
// object id from someone else's garden would be accepted and then leak through
// the list read, since the garden anchors the permission check.
func (s *Service) CreateJournalEntry(ctx context.Context, actorID, gardenID int64, in JournalInput) (*domain.JournalEntry, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
return nil, err
}
objectID, err := s.resolveJournalTarget(ctx, gardenID, in.ObjectID, in.PlantingID)
if err != nil {
return nil, err
}
e := &domain.JournalEntry{
GardenID: gardenID,
ObjectID: objectID,
PlantingID: in.PlantingID,
AuthorID: actorID,
Body: strings.TrimSpace(in.Body),
}
if in.ObservedAt != nil {
e.ObservedAt = *in.ObservedAt
} else {
e.ObservedAt = s.now().UTC().Format(dateLayout)
}
if err := finalizeJournalEntry(e); err != nil {
return nil, err
}
return s.store.CreateJournalEntry(ctx, e)
}
// resolveJournalTarget validates an entry's target and returns the object id to
// store against it.
//
// A plop-level entry gets its parent object filled in even when the caller
// didn't name one. Without that, "notes about this bed" would silently exclude
// every note written about a plant IN the bed — the bed filter and the plop
// filter would disagree about what an entry is about, which is exactly the
// question the reader is asking.
//
// A store failure that isn't "no such row" is passed through rather than
// flattened to ErrInvalidInput: a database problem is not the caller's bad
// request, and mapping it to a 400 would hide it from the logs entirely.
func (s *Service) resolveJournalTarget(ctx context.Context, gardenID int64, objectID, plantingID *int64) (*int64, error) {
if objectID != nil {
o, err := s.store.GetObject(ctx, *objectID)
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.ErrInvalidInput
}
if err != nil {
return nil, err
}
if o.GardenID != gardenID {
return nil, domain.ErrInvalidInput
}
}
if plantingID == nil {
return objectID, nil
}
pl, err := s.store.GetPlanting(ctx, *plantingID)
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.ErrInvalidInput
}
if err != nil {
return nil, err
}
o, err := s.store.GetObject(ctx, pl.ObjectID)
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.ErrInvalidInput
}
if err != nil {
return nil, err
}
if o.GardenID != gardenID {
return nil, domain.ErrInvalidInput
}
// A plop-level entry that ALSO names an object must name the right one.
if objectID != nil && *objectID != pl.ObjectID {
return nil, domain.ErrInvalidInput
}
return &pl.ObjectID, nil
}
// journalEntryForWrite loads an entry and enforces who may change it: the author
// may edit their own, and the garden's OWNER may delete any — the same shape as
// sharing, where the owner is the backstop for content in their garden. A
// non-editor of the garden can't reach it at all, and an entry in a garden the
// actor can't see is ErrNotFound (existence masked, as everywhere else).
func (s *Service) journalEntryForWrite(ctx context.Context, actorID, entryID int64, ownerMayAct bool) (*domain.JournalEntry, error) {
e, err := s.store.GetJournalEntry(ctx, entryID)
if err != nil {
return nil, err // ErrNotFound
}
g, err := s.requireGardenRole(ctx, actorID, e.GardenID, roleEditor)
if err != nil {
return nil, err
}
if e.AuthorID == actorID {
return e, nil
}
if ownerMayAct && g.OwnerID == actorID {
return e, nil
}
// Visible (they can edit this garden) but not theirs to change.
return nil, domain.ErrForbidden
}
// UpdateJournalEntry edits the text or the observed date of the actor's OWN
// entry. Deliberately author-only, including for the garden owner: rewriting
// somebody else's observation under their name is a different thing from
// removing it.
func (s *Service) UpdateJournalEntry(ctx context.Context, actorID, entryID int64, patch JournalPatch, version int64) (*domain.JournalEntry, error) {
e, err := s.journalEntryForWrite(ctx, actorID, entryID, false)
if err != nil {
return nil, err
}
if patch.Body != nil {
e.Body = strings.TrimSpace(*patch.Body)
}
if patch.ObservedAt != nil {
e.ObservedAt = *patch.ObservedAt
}
if err := finalizeJournalEntry(e); err != nil {
return nil, err
}
e.Version = version
return s.store.UpdateJournalEntry(ctx, e)
}
// DeleteJournalEntry removes an entry. The author may delete their own; the
// garden's owner may delete any entry in their garden.
func (s *Service) DeleteJournalEntry(ctx context.Context, actorID, entryID int64) error {
e, err := s.journalEntryForWrite(ctx, actorID, entryID, true)
if err != nil {
return err
}
return s.store.DeleteJournalEntry(ctx, e.ID)
}
// finalizeJournalEntry validates a built/merged entry. Shared by create and
// update so both enforce the same invariants.
func finalizeJournalEntry(e *domain.JournalEntry) error {
if e.Body == "" || len(e.Body) > maxJournalBodyLen {
return domain.ErrInvalidInput
}
if !validDatePtr(&e.ObservedAt) {
return domain.ErrInvalidInput
}
return nil
}
+325
View File
@@ -0,0 +1,325 @@
package service
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
func writeEntry(t *testing.T, s *Service, actor, gardenID int64, in JournalInput) *domain.JournalEntry {
t.Helper()
e, err := s.CreateJournalEntry(context.Background(), actor, gardenID, in)
if err != nil {
t.Fatalf("CreateJournalEntry: %v", err)
}
return e
}
func listJournal(t *testing.T, s *Service, actor, gardenID int64, q JournalQuery) []domain.JournalEntry {
t.Helper()
entries, _, err := s.ListJournal(context.Background(), actor, gardenID, q)
if err != nil {
t.Fatalf("ListJournal: %v", err)
}
return entries
}
// TestEntriesAttachToGardenBedOrPlanting — the three targets, each listing under
// itself and under the garden that anchors it.
func TestEntriesAttachToGardenBedOrPlanting(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()
plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20,
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
gardenEntry := writeEntry(t, s, owner, g.ID, JournalInput{Body: "First frost tonight"})
bedEntry := writeEntry(t, s, owner, g.ID, JournalInput{ObjectID: &bed.ID, Body: "Powdery mildew on the west bed"})
plopEntry := writeEntry(t, s, owner, g.ID, JournalInput{
ObjectID: &bed.ID, PlantingID: &plop.ID, Body: "Scapes forming",
})
// The garden sees all three: it anchors every entry.
if all := listJournal(t, s, owner, g.ID, JournalQuery{}); len(all) != 3 {
t.Errorf("garden journal has %d entries, want 3", len(all))
}
byBed := listJournal(t, s, owner, g.ID, JournalQuery{ObjectID: &bed.ID})
if len(byBed) != 2 {
t.Errorf("bed journal has %d entries, want 2 (the bed's and the plop's)", len(byBed))
}
byPlop := listJournal(t, s, owner, g.ID, JournalQuery{PlantingID: &plop.ID})
if len(byPlop) != 1 || byPlop[0].ID != plopEntry.ID {
t.Errorf("plop journal = %+v, want just the plop entry", byPlop)
}
if gardenEntry.ObjectID != nil || bedEntry.PlantingID != nil {
t.Error("targets leaked between entries")
}
if gardenEntry.AuthorID != owner {
t.Error("author not recorded")
}
// The author's name is resolved for display without a lookup per row.
if all := listJournal(t, s, owner, g.ID, JournalQuery{}); all[0].AuthorName == "" {
t.Error("author name not resolved on the list read")
}
}
// TestDeletingABedRemovesItsEntriesOnly — the cascade is scoped: losing a bed
// must not take the garden's own season notes with it.
func TestDeletingABedRemovesItsEntriesOnly(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)
ctx := context.Background()
writeEntry(t, s, owner, g.ID, JournalInput{Body: "Garden-level note"})
writeEntry(t, s, owner, g.ID, JournalInput{ObjectID: &bed.ID, Body: "Bed-level note"})
if err := s.DeleteObject(ctx, owner, bed.ID); err != nil {
t.Fatalf("DeleteObject: %v", err)
}
left := listJournal(t, s, owner, g.ID, JournalQuery{})
if len(left) != 1 || left[0].Body != "Garden-level note" {
t.Errorf("after deleting the bed the journal is %+v, want just the garden note", left)
}
}
// TestBackdatedEntriesOrderByObservationNotWriting — you write up Saturday's
// observations on Sunday, and Saturday is the date that matters.
func TestBackdatedEntriesOrderByObservationNotWriting(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
// Written second, observed first.
writeEntry(t, s, owner, g.ID, JournalInput{Body: "Sunday", ObservedAt: strPtr("2026-06-14")})
writeEntry(t, s, owner, g.ID, JournalInput{Body: "Saturday", ObservedAt: strPtr("2026-06-13")})
writeEntry(t, s, owner, g.ID, JournalInput{Body: "Monday", ObservedAt: strPtr("2026-06-15")})
got := listJournal(t, s, owner, g.ID, JournalQuery{})
want := []string{"Monday", "Sunday", "Saturday"}
for i, w := range want {
if got[i].Body != w {
t.Errorf("position %d = %q, want %q (ordered by observation, newest first)", i, got[i].Body, w)
}
}
// A date range filters on observation too.
from, to := "2026-06-14", "2026-06-14"
only := listJournal(t, s, owner, g.ID, JournalQuery{From: &from, To: &to})
if len(only) != 1 || only[0].Body != "Sunday" {
t.Errorf("date range returned %+v, want just Sunday", only)
}
}
// TestObservedAtDefaultsToToday
func TestObservedAtDefaultsToToday(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
e := writeEntry(t, s, owner, g.ID, JournalInput{Body: "No date given"})
if e.ObservedAt != s.now().UTC().Format(dateLayout) {
t.Errorf("observedAt = %q, want today", e.ObservedAt)
}
if _, err := s.CreateJournalEntry(context.Background(), owner, g.ID, JournalInput{
Body: "Bad date", ObservedAt: strPtr("14/06/2026"),
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("malformed observedAt accepted: %v", err)
}
if _, err := s.CreateJournalEntry(context.Background(), owner, g.ID, JournalInput{Body: " "}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("empty body accepted: %v", err)
}
}
// TestJournalPermissions — viewer reads and cannot write; stranger sees nothing;
// an author owns their own text, and the garden owner can remove anything.
func TestJournalPermissions(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
editor := seedUser(t, s, "[email protected]")
viewer := seedUser(t, s, "[email protected]")
stranger := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleEditor); err != nil {
t.Fatalf("AddShare editor: %v", err)
}
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
t.Fatalf("AddShare viewer: %v", err)
}
byEditor := writeEntry(t, s, editor, g.ID, JournalInput{Body: "Editor's observation"})
// Viewer: reads, cannot write.
if entries := listJournal(t, s, viewer, g.ID, JournalQuery{}); len(entries) != 1 {
t.Errorf("viewer sees %d entries, want 1", len(entries))
}
if _, err := s.CreateJournalEntry(ctx, viewer, g.ID, JournalInput{Body: "nope"}); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("viewer write err = %v, want ErrForbidden", err)
}
// Stranger: existence stays masked.
if _, _, err := s.ListJournal(ctx, stranger, g.ID, JournalQuery{}); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("stranger read err = %v, want ErrNotFound", err)
}
if err := s.DeleteJournalEntry(ctx, stranger, byEditor.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("stranger delete err = %v, want ErrNotFound", err)
}
// The author edits their own.
updated, err := s.UpdateJournalEntry(ctx, editor, byEditor.ID, JournalPatch{Body: strPtr("Revised")}, byEditor.Version)
if err != nil {
t.Fatalf("author edit: %v", err)
}
if updated.Body != "Revised" {
t.Errorf("body = %q", updated.Body)
}
// The garden OWNER may not rewrite somebody else's observation under their
// name — that's a different act from removing it.
if _, err := s.UpdateJournalEntry(ctx, owner, byEditor.ID, JournalPatch{Body: strPtr("Owner rewrote this")}, updated.Version); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("owner edit of another's entry err = %v, want ErrForbidden", err)
}
// But may delete it: the owner is the backstop for content in their garden.
if err := s.DeleteJournalEntry(ctx, owner, byEditor.ID); err != nil {
t.Errorf("owner delete of another's entry: %v", err)
}
}
// TestEntryTargetMustBelongToTheGarden — the garden anchors permission, so an
// object id from somebody else's garden must not be storable against this one.
func TestEntryTargetMustBelongToTheGarden(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
other := seedUser(t, s, "[email protected]")
mine := seedGarden(t, s, owner)
theirs := seedGarden(t, s, other)
theirBed := seedBed(t, s, other, theirs.ID)
myBed := seedBed(t, s, owner, mine.ID)
ctx := context.Background()
if _, err := s.CreateJournalEntry(ctx, owner, mine.ID, JournalInput{
ObjectID: &theirBed.ID, Body: "about someone else's bed",
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("foreign object accepted: %v", err)
}
// A plop-level entry naming the wrong object is refused too, or the object
// and planting filters would disagree about what an entry is about.
plant := seedOwnPlant(t, s, owner, 15)
otherBed, err := s.CreateObject(ctx, owner, mine.ID, ObjectInput{
Kind: domain.KindBed, Name: "Other", XCM: 200, YCM: 200, WidthCM: 100, HeightCM: 100,
})
if err != nil {
t.Fatalf("CreateObject: %v", err)
}
plop, err := s.CreatePlanting(ctx, owner, myBed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20,
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if _, err := s.CreateJournalEntry(ctx, owner, mine.ID, JournalInput{
ObjectID: &otherBed.ID, PlantingID: &plop.ID, Body: "mismatched",
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("mismatched object/planting pair accepted: %v", err)
}
}
// TestJournalVersionGuardAndPaging
func TestJournalVersionGuardAndPaging(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
e := writeEntry(t, s, owner, g.ID, JournalInput{Body: "One"})
if _, err := s.UpdateJournalEntry(ctx, owner, e.ID, JournalPatch{Body: strPtr("Two")}, e.Version); err != nil {
t.Fatalf("first update: %v", err)
}
current, err := s.UpdateJournalEntry(ctx, owner, e.ID, JournalPatch{Body: strPtr("Three")}, e.Version)
if !errors.Is(err, domain.ErrVersionConflict) {
t.Fatalf("stale update err = %v, want ErrVersionConflict", err)
}
if current == nil || current.Body != "Two" {
t.Errorf("conflict should carry the current row, got %+v", current)
}
for i := 0; i < 5; i++ {
writeEntry(t, s, owner, g.ID, JournalInput{Body: "filler"})
}
page, hasMore, err := s.ListJournal(ctx, owner, g.ID, JournalQuery{Limit: 2})
if err != nil {
t.Fatalf("ListJournal: %v", err)
}
if len(page) != 2 || !hasMore {
t.Errorf("page = %d entries, hasMore = %v; want 2 and true", len(page), hasMore)
}
last, hasMore, err := s.ListJournal(ctx, owner, g.ID, JournalQuery{Limit: 50, Offset: 0})
if err != nil {
t.Fatalf("ListJournal: %v", err)
}
if len(last) != 6 || hasMore {
t.Errorf("full page = %d entries, hasMore = %v; want 6 and false", len(last), hasMore)
}
}
// TestJournalStaysOutOfRevisionHistory — decided deliberately (#52): entries are
// append-shaped and individually versioned, and undoing a note is deleting it.
func TestJournalStaysOutOfRevisionHistory(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
before := len(history(t, s, owner, g.ID))
e := writeEntry(t, s, owner, g.ID, JournalInput{Body: "An observation"})
if _, err := s.UpdateJournalEntry(context.Background(), owner, e.ID, JournalPatch{Body: strPtr("Revised")}, e.Version); err != nil {
t.Fatalf("update: %v", err)
}
if got := len(history(t, s, owner, g.ID)); got != before {
t.Errorf("journal writes produced %d change sets; they should produce none", got-before)
}
}
// TestPlopEntryIsVisibleUnderItsBed — an entry about a plant IN a bed is an entry
// about that bed. Without deriving the parent object, "notes about this bed"
// would silently exclude every note written about something growing in it.
func TestPlopEntryIsVisibleUnderItsBed(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()
plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20,
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
// Only the plop is named — no objectId.
e := writeEntry(t, s, owner, g.ID, JournalInput{PlantingID: &plop.ID, Body: "Scapes forming"})
if e.ObjectID == nil || *e.ObjectID != bed.ID {
t.Fatalf("objectId = %v, want the plop's parent bed %d", e.ObjectID, bed.ID)
}
if byBed := listJournal(t, s, owner, g.ID, JournalQuery{ObjectID: &bed.ID}); len(byBed) != 1 {
t.Errorf("the bed filter found %d entries, want the plop's", len(byBed))
}
if byPlop := listJournal(t, s, owner, g.ID, JournalQuery{PlantingID: &plop.ID}); len(byPlop) != 1 {
t.Errorf("the plop filter found %d entries, want 1", len(byPlop))
}
}
+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.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.
+37 -1
View File
@@ -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
}
+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
{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
}
+24
View File
@@ -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
+347
View File
@@ -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 != ""
}
+519
View File
@@ -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, "[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 = 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, "[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)
}
}
// TestDeletingAPlantWithLotsIsRefused — seed_lots.plant_id is ON DELETE CASCADE,
// so without a guard, deleting a plant would silently destroy the purchase
// records attached to it: vendor, cost, germination rate, gone with no warning.
func TestDeletingAPlantWithLotsIsRefused(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 100, func(in *SeedLotInput) { in.CostCents = 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, "[email protected]")
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, "[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
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, "[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, 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, "[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
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, "[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.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)
}
}
+5
View File
@@ -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)
}
+168
View File
@@ -0,0 +1,168 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// journalColumns lists journal_entries columns in the order scanJournalEntry
// expects. Used unqualified for direct selects; the list read qualifies with j.
const journalColumns = `id, garden_id, object_id, planting_id, author_id, body,
observed_at, version, created_at, updated_at`
// journalScanTargets returns the scan destinations for journalColumns, in order.
// The list read appends one more for the joined author name, so the two readers
// share a single definition of the column order rather than each repeating it.
func journalScanTargets(e *domain.JournalEntry) []any {
return []any{
&e.ID, &e.GardenID, &e.ObjectID, &e.PlantingID, &e.AuthorID, &e.Body,
&e.ObservedAt, &e.Version, &e.CreatedAt, &e.UpdatedAt,
}
}
func scanJournalEntry(s scanner) (*domain.JournalEntry, error) {
var e domain.JournalEntry
if err := s.Scan(journalScanTargets(&e)...); err != nil {
return nil, err
}
return &e, nil
}
// JournalFilter narrows a garden's journal. A nil field means "don't filter on
// this". ObjectID/PlantingID select entries about one bed or one plop; the date
// range is inclusive and matches observed_at, not created_at.
type JournalFilter struct {
ObjectID *int64
PlantingID *int64
From *string
To *string
Limit int
Offset int
}
// CreateJournalEntry inserts an entry (fields already validated by the service).
func (d *DB) CreateJournalEntry(ctx context.Context, e *domain.JournalEntry) (*domain.JournalEntry, error) {
created, err := scanJournalEntry(d.sql.QueryRowContext(ctx,
`INSERT INTO journal_entries (garden_id, object_id, planting_id, author_id, body, observed_at)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING `+journalColumns,
e.GardenID, e.ObjectID, e.PlantingID, e.AuthorID, e.Body, e.ObservedAt,
))
if err != nil {
return nil, fmt.Errorf("store: insert journal entry: %w", err)
}
return created, nil
}
// GetJournalEntry returns an entry by id, or domain.ErrNotFound. Permission is
// the service's business, via the entry's garden.
func (d *DB) GetJournalEntry(ctx context.Context, id int64) (*domain.JournalEntry, error) {
e, err := scanJournalEntry(d.sql.QueryRowContext(ctx,
`SELECT `+journalColumns+` FROM journal_entries WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: get journal entry: %w", err)
}
return e, nil
}
// ListJournalEntries returns a garden's entries, most recently OBSERVED first,
// narrowed by the filter. Each carries its author's display name, so the list
// renders without a lookup per row. Always a non-nil slice.
func (d *DB) ListJournalEntries(ctx context.Context, gardenID int64, f JournalFilter) ([]domain.JournalEntry, error) {
query := `SELECT ` + qualifyColumns("j", journalColumns) + `, u.display_name
FROM journal_entries j
JOIN users u ON u.id = j.author_id
WHERE j.garden_id = ?`
args := []any{gardenID}
if f.ObjectID != nil {
query += ` AND j.object_id = ?`
args = append(args, *f.ObjectID)
}
if f.PlantingID != nil {
query += ` AND j.planting_id = ?`
args = append(args, *f.PlantingID)
}
if f.From != nil {
query += ` AND j.observed_at >= ?`
args = append(args, *f.From)
}
if f.To != nil {
query += ` AND j.observed_at <= ?`
args = append(args, *f.To)
}
// id DESC breaks ties within a day, so several entries observed on the same
// date still read newest-first rather than in arbitrary order.
query += ` ORDER BY j.observed_at DESC, j.id DESC LIMIT ? OFFSET ?`
args = append(args, f.Limit, f.Offset)
rows, err := d.sql.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("store: list journal entries: %w", err)
}
defer rows.Close()
entries := []domain.JournalEntry{}
for rows.Next() {
var e domain.JournalEntry
if err := rows.Scan(append(journalScanTargets(&e), &e.AuthorName)...); err != nil {
return nil, fmt.Errorf("store: scan journal entry: %w", err)
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate journal entries: %w", err)
}
return entries, nil
}
// UpdateJournalEntry applies a version-guarded update of the mutable columns.
// Same contract as every other mutable resource. The target (garden/object/
// planting) and the author are immutable: an entry is a record of an observation,
// so re-pointing it at a different bed would be rewriting the observation rather
// than correcting the text.
func (d *DB) UpdateJournalEntry(ctx context.Context, e *domain.JournalEntry) (*domain.JournalEntry, error) {
updated, err := scanJournalEntry(d.sql.QueryRowContext(ctx,
`UPDATE journal_entries
SET body = ?, observed_at = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+journalColumns,
e.Body, e.ObservedAt, e.ID, e.Version,
))
if errors.Is(err, sql.ErrNoRows) {
current, gerr := d.GetJournalEntry(ctx, e.ID)
if gerr != nil {
return nil, gerr
}
return current, domain.ErrVersionConflict
}
if err != nil {
return nil, fmt.Errorf("store: update journal entry: %w", err)
}
return updated, nil
}
// DeleteJournalEntry removes an entry. Returns domain.ErrNotFound if none was.
func (d *DB) DeleteJournalEntry(ctx context.Context, id int64) error {
res, err := d.sql.ExecContext(ctx, `DELETE FROM journal_entries WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete journal entry: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("store: journal delete rows: %w", err)
}
if n == 0 {
return domain.ErrNotFound
}
return nil
}
@@ -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;
@@ -0,0 +1,33 @@
-- Grow journal (#52): time-stamped notes on gardens, beds and plantings.
--
-- There is already free-text `notes` on gardens, objects and plants, but it is a
-- single mutable field: writing "powdery mildew on the west bed" overwrites what
-- you wrote in June. The distinction worth keeping is that `notes` is WHAT THIS
-- THING IS, while a journal entry is WHAT HAPPENED, AND WHEN. Both stay.
--
-- garden_id is NOT NULL even when the entry is about a bed or a single plop, and
-- that is the whole trick: permission checks reuse requireGardenRole unchanged
-- and no new ACL path is invented. object_id/planting_id narrow the target; the
-- garden always anchors it.
--
-- observed_at is deliberately distinct from created_at — you write up Saturday's
-- observations on Sunday, and the date that matters is Saturday's.
CREATE TABLE journal_entries (
id INTEGER PRIMARY KEY,
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
object_id INTEGER REFERENCES garden_objects (id) ON DELETE CASCADE,
planting_id INTEGER REFERENCES plantings (id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
body TEXT NOT NULL,
observed_at TEXT NOT NULL, -- 'YYYY-MM-DD'
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'))
);
-- The journal's only list query: one garden, most recently observed first.
CREATE INDEX idx_journal_garden ON journal_entries (garden_id, observed_at DESC);
-- Narrowing to one bed or one plop.
CREATE INDEX idx_journal_object ON journal_entries (object_id) WHERE object_id IS NOT NULL;
CREATE INDEX idx_journal_planting ON journal_entries (planting_id) WHERE planting_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) {
+217
View File
@@ -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
}