Grow journal: time-stamped notes on gardens, beds and plantings (#52)
"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:
@@ -23,6 +23,7 @@ SQLite, centimeters everywhere (display-side imperial conversion only), `version
|
|||||||
- **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.
|
- **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.
|
||||||
- **seed_lots** — one purchase: vendor, source URL, sku, lot code, purchase date, packed-for year, quantity + unit, cost, germination %. `owner_id` is on the LOT, not inherited from the plant, so you can record buying generic built-in Garlic from Johnny's and keep it private. `plantings.seed_lot_id` (ON DELETE SET NULL) attributes a plop to a purchase; **`remaining` is derived** (quantity − summed effective count of active plantings), never stored — a decremented column drifts the moment a planting is edited behind its back and can't be audited afterwards. Lots are never shared along with a garden.
|
- **seed_lots** — one purchase: vendor, source URL, sku, lot code, purchase date, packed-for year, quantity + unit, cost, germination %. `owner_id` is on the LOT, not inherited from the plant, so you can record buying generic built-in Garlic from Johnny's and keep it private. `plantings.seed_lot_id` (ON DELETE SET NULL) attributes a plop to a purchase; **`remaining` is derived** (quantity − summed effective count of active plantings), never stored — a decremented column drifts the moment a planting is edited behind its back and can't be audited afterwards. Lots are never shared along with a garden.
|
||||||
- **plants** — catalog, plus `source_url`/`vendor` provenance for the variety itself. `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.
|
- **plants** — catalog, plus `source_url`/`vendor` provenance for the variety itself. `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).
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -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/full", h.getGardenFull) // one-shot editor load
|
||||||
gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first
|
gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first
|
||||||
gardens.POST("/:id/objects", h.createObject)
|
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).
|
// Sharing (owner-managed; a recipient may remove their own share).
|
||||||
gardens.GET("/:id/shares", h.listShares)
|
gardens.GET("/:id/shares", h.listShares)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -165,6 +165,32 @@ type Revision struct {
|
|||||||
After *string `json:"after,omitempty"`
|
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
|
// RevertConflict reports one entity a revert deliberately left alone, because
|
||||||
// reverting it would have discarded a change made after the change set being
|
// reverting it would have discarded a change made after the change set being
|
||||||
// reverted. The rest of the change set still reverts.
|
// reverted. The rest of the change set still reverts.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
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`
|
||||||
|
|
||||||
|
func scanJournalEntry(s scanner) (*domain.JournalEntry, error) {
|
||||||
|
var e domain.JournalEntry
|
||||||
|
if err := s.Scan(
|
||||||
|
&e.ID, &e.GardenID, &e.ObjectID, &e.PlantingID, &e.AuthorID, &e.Body,
|
||||||
|
&e.ObservedAt, &e.Version, &e.CreatedAt, &e.UpdatedAt,
|
||||||
|
); 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(
|
||||||
|
&e.ID, &e.GardenID, &e.ObjectID, &e.PlantingID, &e.AuthorID, &e.Body,
|
||||||
|
&e.ObservedAt, &e.Version, &e.CreatedAt, &e.UpdatedAt, &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;
|
||||||
Reference in New Issue
Block a user