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
This commit is contained in:
2026-07-21 01:27:30 -04:00
co-authored by Claude Opus 4.8
parent 2a8fe24766
commit 635d5e3770
9 changed files with 881 additions and 6 deletions
+211
View File
@@ -0,0 +1,211 @@
package service
import (
"context"
"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
}
if err := s.checkJournalTarget(ctx, gardenID, in.ObjectID, in.PlantingID); err != nil {
return nil, err
}
e := &domain.JournalEntry{
GardenID: gardenID,
ObjectID: in.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)
}
// checkJournalTarget verifies that an object/planting an entry points at really
// lives in this garden.
func (s *Service) checkJournalTarget(ctx context.Context, gardenID int64, objectID, plantingID *int64) error {
if objectID != nil {
o, err := s.store.GetObject(ctx, *objectID)
if err != nil || o.GardenID != gardenID {
return domain.ErrInvalidInput
}
}
if plantingID != nil {
pl, err := s.store.GetPlanting(ctx, *plantingID)
if err != nil {
return domain.ErrInvalidInput
}
o, err := s.store.GetObject(ctx, pl.ObjectID)
if err != nil || o.GardenID != gardenID {
return domain.ErrInvalidInput
}
// A plop-level entry that also names an object must name the RIGHT one,
// or the two filters would disagree about which entries are about what.
if objectID != nil && *objectID != pl.ObjectID {
return domain.ErrInvalidInput
}
}
return 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
}
+294
View File
@@ -0,0 +1,294 @@
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)
}
}
+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
}