Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
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
|
|
}
|