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:
@@ -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