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:
@@ -22,6 +22,7 @@ SQLite, centimeters everywhere (display-side imperial conversion only), `version
|
||||
- **garden_shares** — (garden_id, user_id) unique, role `viewer`|`editor`, created_by. Owner is implicit via `gardens.owner_id`, never a share row.
|
||||
- **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.
|
||||
- **plants** — catalog. `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).
|
||||
- **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/history", h.getGardenHistory) // change sets, newest first
|
||||
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).
|
||||
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
|
||||
}
|
||||
@@ -157,6 +157,32 @@ type Revision struct {
|
||||
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
|
||||
// reverting it would have discarded a change made after the change set being
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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