Add garden objects backend: polymorphic CRUD + /full (#10)
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m9s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m9s

Garden objects (beds, bags, containers, in-ground, trees, paths,
structures) in one polymorphic table, following the #7 service
conventions.

- store/objects.go: scanObject + Create (RETURNING), Get, ListForGarden
  (z_index order), version-guarded Update (RETURNING, returns current row
  on conflict), Delete (plantings cascade via FK).
- store/plantings.go + plants.go: the read side /full needs now —
  ListActivePlantingsForGarden (removed_at IS NULL) and
  ListReferencedPlants (distinct plants used by active plantings). Both
  return empty until #14 adds plantings; #12/#14 extend these files.
- service/objects.go: Create/Update(partial patch)/Delete/GardenFull, all
  (ctx, actorID, ...) through requireGardenRole(editor for mutations,
  viewer for /full). finalizeObject validates kind + shape (rect/circle;
  polygon reserved), finite dims in [1cm,100m], NaN/Inf rejection, loose
  bbox-overlaps-garden placement (partial overhang OK), hex color, valid
  JSON props, name/notes caps; normalizes rotation to [0,360). Plantable
  defaults by kind, overridable.
- api/objects.go: POST /gardens/:id/objects, PATCH/DELETE /objects/:id,
  GET /gardens/:id/full ({garden,objects,plantings,plants}). props is any
  JSON value stored as text; PATCH is partial + required version, 409
  returns the current row.

Tests: service (defaults, plantable-by-kind, rotation normalize,
validation rejects incl. off-field/polygon/bad-color/bad-props, partial
patch + version conflict, cross-user ErrNotFound, delete, /full shape) and
api (CRUD + /full, 409 envelope, cross-user 404, polygon/no-kind 400,
props round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 19:30:30 -04:00
co-authored by Claude Opus 4.8
parent a1294b9c69
commit 3dd935fb19
8 changed files with 1080 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// objectColumns lists garden_objects columns in the order scanObject expects.
const objectColumns = `id, garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
rotation_deg, z_index, plantable, color, props, notes, version, created_at, updated_at`
func scanObject(s scanner) (*domain.GardenObject, error) {
var (
o domain.GardenObject
plantable int64
)
if err := s.Scan(
&o.ID, &o.GardenID, &o.Kind, &o.Name, &o.Shape, &o.Points,
&o.XCM, &o.YCM, &o.WidthCM, &o.HeightCM, &o.RotationDeg, &o.ZIndex,
&plantable, &o.Color, &o.Props, &o.Notes, &o.Version, &o.CreatedAt, &o.UpdatedAt,
); err != nil {
return nil, err
}
o.Plantable = plantable != 0
return &o, nil
}
// CreateObject inserts a garden object (fields already validated by the service)
// and returns the stored row.
func (d *DB) CreateObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) {
created, err := scanObject(d.sql.QueryRowContext(ctx,
`INSERT INTO garden_objects
(garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
rotation_deg, z_index, plantable, color, props, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+objectColumns,
o.GardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.Notes,
))
if err != nil {
return nil, fmt.Errorf("store: insert object: %w", err)
}
return created, nil
}
// GetObject returns the object with the given id, or domain.ErrNotFound.
func (d *DB) GetObject(ctx context.Context, id int64) (*domain.GardenObject, error) {
o, err := scanObject(d.sql.QueryRowContext(ctx,
`SELECT `+objectColumns+` FROM garden_objects WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: get object: %w", err)
}
return o, nil
}
// ListObjectsForGarden returns a garden's objects, bottom-to-top (z_index then
// id). Always a non-nil slice.
func (d *DB) ListObjectsForGarden(ctx context.Context, gardenID int64) ([]domain.GardenObject, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT `+objectColumns+` FROM garden_objects WHERE garden_id = ? ORDER BY z_index, id`,
gardenID,
)
if err != nil {
return nil, fmt.Errorf("store: list objects: %w", err)
}
defer rows.Close()
objects := []domain.GardenObject{}
for rows.Next() {
o, err := scanObject(rows)
if err != nil {
return nil, fmt.Errorf("store: scan object: %w", err)
}
objects = append(objects, *o)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate objects: %w", err)
}
return objects, nil
}
// UpdateObject applies a version-guarded update of all mutable columns (the
// service merges partial patches onto the current row first). Returns the
// updated row, or (current row, ErrVersionConflict) / ErrNotFound — the same
// contract as UpdateGarden.
func (d *DB) UpdateObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) {
updated, err := scanObject(d.sql.QueryRowContext(ctx,
`UPDATE garden_objects
SET kind = ?, name = ?, shape = ?, points = ?, x_cm = ?, y_cm = ?,
width_cm = ?, height_cm = ?, rotation_deg = ?, z_index = ?,
plantable = ?, color = ?, props = ?, notes = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+objectColumns,
o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.Notes,
o.ID, o.Version,
))
if errors.Is(err, sql.ErrNoRows) {
current, gerr := d.GetObject(ctx, o.ID)
if gerr != nil {
return nil, gerr
}
return current, domain.ErrVersionConflict
}
if err != nil {
return nil, fmt.Errorf("store: update object: %w", err)
}
return updated, nil
}
// DeleteObject removes an object; its plantings cascade via the FK. Returns
// domain.ErrNotFound if no row was deleted.
func (d *DB) DeleteObject(ctx context.Context, id int64) error {
res, err := d.sql.ExecContext(ctx, `DELETE FROM garden_objects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete object: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("store: object delete rows: %w", err)
}
if n == 0 {
return domain.ErrNotFound
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package store
import (
"context"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// scanPlanting reads a plantings row in table-declared column order (id,
// object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at,
// removed_at, version, created_at, updated_at) — the order SELECT pl.* yields.
func scanPlanting(s scanner) (*domain.Planting, error) {
var p domain.Planting
if err := s.Scan(
&p.ID, &p.ObjectID, &p.PlantID, &p.XCM, &p.YCM, &p.RadiusCM,
&p.Count, &p.Label, &p.PlantedAt, &p.RemovedAt,
&p.Version, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
return &p, nil
}
// ListActivePlantingsForGarden returns every currently-planted plop (removed_at
// IS NULL) across all objects in a garden — the editor's one-shot load. Always a
// non-nil slice (empty until plantings CRUD lands in #14). Plop CRUD itself is
// #14; this is only the /full read side.
func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) ([]domain.Planting, error) {
// pl.* returns the plantings columns in table-declared order, which matches
// plantingColumns / scanPlanting.
rows, err := d.sql.QueryContext(ctx,
`SELECT pl.* FROM plantings pl
JOIN garden_objects o ON o.id = pl.object_id
WHERE o.garden_id = ? AND pl.removed_at IS NULL
ORDER BY pl.id`,
gardenID,
)
if err != nil {
return nil, fmt.Errorf("store: list plantings: %w", err)
}
defer rows.Close()
plantings := []domain.Planting{}
for rows.Next() {
p, err := scanPlanting(rows)
if err != nil {
return nil, fmt.Errorf("store: scan planting: %w", err)
}
plantings = append(plantings, *p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate plantings: %w", err)
}
return plantings, nil
}
+55
View File
@@ -0,0 +1,55 @@
package store
import (
"context"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// scanPlant reads a plants row in table-declared column order (id, owner_id,
// name, category, spacing_cm, color, icon, days_to_maturity, notes, version,
// created_at, updated_at) — the order SELECT p.* yields.
func scanPlant(s scanner) (*domain.Plant, error) {
var p domain.Plant
if err := s.Scan(
&p.ID, &p.OwnerID, &p.Name, &p.Category, &p.SpacingCM, &p.Color, &p.Icon,
&p.DaysToMaturity, &p.Notes, &p.Version, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
return &p, nil
}
// ListReferencedPlants returns the distinct plants used by a garden's active
// plantings — the catalog subset the editor needs to render them. Always a
// non-nil slice (empty until plantings exist, #14). Full plant CRUD/seeding is
// #12; this is only the /full read side.
func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64) ([]domain.Plant, error) {
// p.* returns the plants columns in table-declared order, matching scanPlant.
rows, err := d.sql.QueryContext(ctx,
`SELECT DISTINCT p.* FROM plants p
JOIN plantings pl ON pl.plant_id = p.id
JOIN garden_objects o ON o.id = pl.object_id
WHERE o.garden_id = ? AND pl.removed_at IS NULL
ORDER BY p.id`,
gardenID,
)
if err != nil {
return nil, fmt.Errorf("store: list referenced 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
}