Files
pansy/internal/store/journal.go
T
steve 7f8b5254d0
Build image / build-and-push (push) Successful in 11s
Journal UI: write and read season notes where you're already looking (#53) (#67)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:55:45 +00:00

201 lines
6.7 KiB
Go

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`
// journalScanTargets returns the scan destinations for journalColumns, in order.
// The list read appends one more for the joined author name, so the two readers
// share a single definition of the column order rather than each repeating it.
func journalScanTargets(e *domain.JournalEntry) []any {
return []any{
&e.ID, &e.GardenID, &e.ObjectID, &e.PlantingID, &e.AuthorID, &e.Body,
&e.ObservedAt, &e.Version, &e.CreatedAt, &e.UpdatedAt,
}
}
func scanJournalEntry(s scanner) (*domain.JournalEntry, error) {
var e domain.JournalEntry
if err := s.Scan(journalScanTargets(&e)...); 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(append(journalScanTargets(&e), &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
}
// JournalCounts returns how many entries a garden holds, keyed by the object
// they are about — key 0 for entries about the garden itself.
//
// A count per object rather than a flag, and one query rather than one per bed:
// the indicator has to be available when the journal panel is closed, which is
// exactly when nothing else has loaded the entries.
func (d *DB) JournalCounts(ctx context.Context, gardenID int64) (map[int64]int, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT COALESCE(object_id, 0), COUNT(*)
FROM journal_entries WHERE garden_id = ?
GROUP BY COALESCE(object_id, 0)`,
gardenID)
if err != nil {
return nil, fmt.Errorf("store: journal counts: %w", err)
}
defer rows.Close()
counts := map[int64]int{}
for rows.Next() {
var objectID int64
var n int
if err := rows.Scan(&objectID, &n); err != nil {
return nil, fmt.Errorf("store: scan journal count: %w", err)
}
counts[objectID] = n
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate journal counts: %w", err)
}
return counts, 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
}