happens. Notes get written standing in the garden holding a phone, usually one-handed — if it takes more than a couple of taps from looking at a bed to typing a sentence, the log stays empty. So the journal is a tab in the rail #49 settled, and selecting a bed puts a "📝 Add note" button in the inspector that scopes the journal to that bed and switches to it. The composer is already open in there rather than behind an "add" button, which makes it two taps from a selected bed to typing. The observed date defaults to today in the VIEWER's timezone, not UTC — "today" means the day you are standing in the garden — and stays editable, because you write up Saturday's observations on Sunday. Discoverability is the other half. A log you have to remember exists is a log nobody reads, so the inspector button carries the note count for that bed and the Journal tab carries the garden's total: a garden with a season's notes in it no longer looks identical to an empty one. Those counts come from their own endpoint, deliberately — the indicator has to work while the journal panel is closed, which is exactly when the entry list hasn't loaded. Permissions match the service: anyone who can edit the garden can write, the author can edit their own text, and the author or the garden owner can delete. A viewer sees the entries and the count but no composer. The empty state says what the journal is for rather than just "no entries", since it starts empty for a whole season's worth of gardens and the reason to start is the thing worth saying. Closes #53 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
249 lines
8.0 KiB
Go
249 lines
8.0 KiB
Go
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
|
|
}
|
|
|
|
// JournalCounts returns how many entries a garden holds per object (key 0 for
|
|
// entries about the garden itself), for an actor who can at least view it.
|
|
func (s *Service) JournalCounts(ctx context.Context, actorID, gardenID int64) (map[int64]int, error) {
|
|
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.store.JournalCounts(ctx, gardenID)
|
|
}
|
|
|
|
// 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
|
|
}
|