Add plant catalog backend: CRUD + seeded built-ins (#12)
Extends the plants store (which had only the /full read side) with full catalog CRUD, adds a service layer with visibility/immutability rules, REST endpoints, and a seed migration of ~32 built-ins. - 0003_seed_plants.sql: 32 built-in plants (owner_id NULL, read-only), seeded once by the version-tracked migration runner. - store: ListPlantsForActor (built-ins + own), Get/Create/Update/Delete + CountPlantingsForPlant; isForeignKeyViolation backstop for the RESTRICT FK. - service: built-ins are read-only (ErrForbidden); another user's plants are invisible (ErrNotFound); delete of a referenced plant → ErrPlantInUse (409); validation mirrors the schema (category enum, spacing>0, hex color, icon). - api: GET,POST /plants and PATCH,DELETE /plants/:id with the version guard. - Made the migration-count store tests derive their expectation from the files so future migrations don't break them. Service + API tests cover visibility, built-in immutability, validation, version conflict, and delete-in-use. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -2,6 +2,8 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
@@ -53,3 +55,132 @@ func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64) ([]domain
|
||||
}
|
||||
return plants, nil
|
||||
}
|
||||
|
||||
// maxPlantsListed caps ListPlantsForActor as a defensive backstop. The seeded
|
||||
// built-ins (~30) plus a user's own catalog stay tiny at household scale, so this
|
||||
// is never hit; pagination is post-v1 if ever needed.
|
||||
const maxPlantsListed = 5000
|
||||
|
||||
// ListPlantsForActor returns the plants a user can see: every built-in
|
||||
// (owner_id NULL) plus the user's own rows, built-ins first then by name. Always
|
||||
// a non-nil slice. This is the catalog list; ListReferencedPlants above is the
|
||||
// narrower /full read.
|
||||
func (d *DB) ListPlantsForActor(ctx context.Context, actorID int64) ([]domain.Plant, error) {
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT `+plantColumns+` FROM plants
|
||||
WHERE owner_id IS NULL OR owner_id = ?
|
||||
ORDER BY owner_id IS NOT NULL, name COLLATE NOCASE, id
|
||||
LIMIT ?`,
|
||||
actorID, maxPlantsListed,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list plants: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
plants := []domain.Plant{}
|
||||
for rows.Next() {
|
||||
p, err := scanPlant(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan plant: %w", err)
|
||||
}
|
||||
plants = append(plants, *p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate plants: %w", err)
|
||||
}
|
||||
return plants, nil
|
||||
}
|
||||
|
||||
// GetPlant returns the plant with the given id, or domain.ErrNotFound. Visibility
|
||||
// (built-in vs owned) is the service layer's call, not this raw read's.
|
||||
func (d *DB) GetPlant(ctx context.Context, id int64) (*domain.Plant, error) {
|
||||
p, err := scanPlant(d.sql.QueryRowContext(ctx,
|
||||
`SELECT `+plantColumns+` FROM plants WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: get plant: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// CreatePlant inserts a plant (fields already validated by the service) and
|
||||
// returns the stored row. OwnerID is the creating user — built-ins are seeded by
|
||||
// migration only, never through this path.
|
||||
func (d *DB) CreatePlant(ctx context.Context, p *domain.Plant) (*domain.Plant, error) {
|
||||
created, err := scanPlant(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO plants (owner_id, name, category, spacing_cm, color, icon, days_to_maturity, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING `+plantColumns,
|
||||
p.OwnerID, p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity, p.Notes,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: insert plant: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// UpdatePlant applies a version-guarded update of all mutable columns (the
|
||||
// service merges partial patches first). Returns the updated row, or
|
||||
// (current row, ErrVersionConflict) / ErrNotFound — the same contract as
|
||||
// UpdateGarden/UpdateObject.
|
||||
func (d *DB) UpdatePlant(ctx context.Context, p *domain.Plant) (*domain.Plant, error) {
|
||||
updated, err := scanPlant(d.sql.QueryRowContext(ctx,
|
||||
`UPDATE plants
|
||||
SET name = ?, category = ?, spacing_cm = ?, color = ?, icon = ?,
|
||||
days_to_maturity = ?, notes = ?,
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ? AND version = ?
|
||||
RETURNING `+plantColumns,
|
||||
p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity, p.Notes,
|
||||
p.ID, p.Version,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
current, gerr := d.GetPlant(ctx, p.ID)
|
||||
if gerr != nil {
|
||||
return nil, gerr
|
||||
}
|
||||
return current, domain.ErrVersionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: update plant: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// CountPlantingsForPlant returns how many plantings reference a plant, INCLUDING
|
||||
// removed ones — a removed_at row still holds the FK (ON DELETE RESTRICT), so any
|
||||
// row blocks deletion. The service checks this to refuse a delete with a clear
|
||||
// error before the constraint fires.
|
||||
func (d *DB) CountPlantingsForPlant(ctx context.Context, plantID int64) (int, error) {
|
||||
var n int
|
||||
if err := d.sql.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM plantings WHERE plant_id = ?`, plantID).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("store: count plantings for plant: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DeletePlant removes a plant. Returns domain.ErrNotFound if no row was deleted,
|
||||
// or domain.ErrPlantInUse if the ON DELETE RESTRICT foreign key from plantings
|
||||
// fires (a backstop for the service's own count check under a concurrent insert).
|
||||
func (d *DB) DeletePlant(ctx context.Context, id int64) error {
|
||||
res, err := d.sql.ExecContext(ctx, `DELETE FROM plants WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
if isForeignKeyViolation(err) {
|
||||
return domain.ErrPlantInUse
|
||||
}
|
||||
return fmt.Errorf("store: delete plant: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: plant delete rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user