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 }