Compare commits
3
Commits
fea30c267d
...
6638696b00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6638696b00 | ||
|
|
baf793a9f5 | ||
|
|
78a04672b5 |
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user