From b96ca1ec0a2e42e00acbe583964e4afc773762e6 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Tue, 21 Jul 2026 05:40:17 +0000 Subject: [PATCH] Grow journal: time-stamped notes on gardens, beds and plantings (#52) (#65) Co-authored-by: Steve Dudenhoeffer --- DESIGN.md | 1 + internal/api/api.go | 10 + internal/api/journal.go | 142 +++++++++ internal/api/journal_test.go | 157 ++++++++++ internal/domain/domain.go | 26 ++ internal/service/journal.go | 239 +++++++++++++++ internal/service/journal_test.go | 325 +++++++++++++++++++++ internal/store/journal.go | 168 +++++++++++ internal/store/migrations/0008_journal.sql | 33 +++ 9 files changed, 1101 insertions(+) create mode 100644 internal/api/journal.go create mode 100644 internal/api/journal_test.go create mode 100644 internal/service/journal.go create mode 100644 internal/service/journal_test.go create mode 100644 internal/store/journal.go create mode 100644 internal/store/migrations/0008_journal.sql diff --git a/DESIGN.md b/DESIGN.md index 1970ba4..d4bd5f5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -23,6 +23,7 @@ SQLite, centimeters everywhere (display-side imperial conversion only), `version - **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. - **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. diff --git a/internal/api/api.go b/internal/api/api.go index dbf138d..3e3c6f8 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/api/journal.go b/internal/api/journal.go new file mode 100644 index 0000000..60dbb85 --- /dev/null +++ b/internal/api/journal.go @@ -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 +} diff --git a/internal/api/journal_test.go b/internal/api/journal_test.go new file mode 100644 index 0000000..837b6f6 --- /dev/null +++ b/internal/api/journal_test.go @@ -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, "journal@example.com") + 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, "filters@example.com") + 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, "jowner@example.com") + gid := createGardenAPI(t, r, owner, "G") + stranger := registerAndCookie(t, r, "jstranger@example.com") + + 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) + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 81c98a9..0a6a9f3 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -165,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. diff --git a/internal/service/journal.go b/internal/service/journal.go new file mode 100644 index 0000000..99085b3 --- /dev/null +++ b/internal/service/journal.go @@ -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 +} diff --git a/internal/service/journal_test.go b/internal/service/journal_test.go new file mode 100644 index 0000000..b464b01 --- /dev/null +++ b/internal/service/journal_test.go @@ -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, "a@example.com") + 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, "a@example.com") + 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, "a@example.com") + 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, "a@example.com") + 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, "a@example.com") + editor := seedUser(t, s, "e@example.com") + viewer := seedUser(t, s, "v@example.com") + stranger := seedUser(t, s, "s@example.com") + g := seedGarden(t, s, owner) + ctx := context.Background() + + if _, err := s.AddShare(ctx, owner, g.ID, "e@example.com", domain.RoleEditor); err != nil { + t.Fatalf("AddShare editor: %v", err) + } + if _, err := s.AddShare(ctx, owner, g.ID, "v@example.com", 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, "a@example.com") + other := seedUser(t, s, "b@example.com") + 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, "a@example.com") + 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, "a@example.com") + 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, "a@example.com") + 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)) + } +} diff --git a/internal/store/journal.go b/internal/store/journal.go new file mode 100644 index 0000000..2ac598d --- /dev/null +++ b/internal/store/journal.go @@ -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 +} diff --git a/internal/store/migrations/0008_journal.sql b/internal/store/migrations/0008_journal.sql new file mode 100644 index 0000000..5ccd456 --- /dev/null +++ b/internal/store/migrations/0008_journal.sql @@ -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;