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 }