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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:36:15 -04:00
steveandClaude Opus 4.8 635d5e3770 Grow journal: time-stamped notes on gardens, beds and plantings (#52)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Canceled after 8m27s
Adversarial Review (Gadfly) / review (pull_request) Canceled after 8m27s
"Take notes through the season on each bed and plant." There is already
free-text notes on gardens, objects and plants, but it is a single mutable
field: writing "powdery mildew on the west bed" overwrites what you wrote in
June. The distinction worth keeping is that notes says what this thing IS, while
a journal entry says what HAPPENED, and when. Both stay.

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

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

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

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

Closes #52

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