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
+280
View File
@@ -0,0 +1,280 @@
package service
import (
"context"
"encoding/json"
"math"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
const (
maxObjectNameLen = 200
maxObjectNotesLen = 10_000
maxObjectPropsLen = 20_000
)
// objectKinds is the set of valid garden_objects.kind values.
var objectKinds = map[string]bool{
domain.KindBed: true, domain.KindGrowBag: true, domain.KindContainer: true,
domain.KindInGround: true, domain.KindTree: true, domain.KindPath: true, domain.KindStructure: true,
}
// plantableByDefault: beds/bags/containers/in-ground hold plants; trees, paths,
// and structures don't. Overridable per object.
var plantableByDefault = map[string]bool{
domain.KindBed: true, domain.KindGrowBag: true, domain.KindContainer: true, domain.KindInGround: true,
domain.KindTree: false, domain.KindPath: false, domain.KindStructure: false,
}
// ObjectInput is the payload for creating a garden object. Plantable nil means
// "default for this kind".
type ObjectInput struct {
Kind string
Name string
Shape string
XCM float64
YCM float64
WidthCM float64
HeightCM float64
RotationDeg float64
ZIndex int
Plantable *bool
Color *string
Props *string
Notes string
}
// ObjectPatch is a partial update: every field is optional (nil = leave as-is).
// Kind and shape are intentionally immutable after creation.
type ObjectPatch struct {
Name *string
XCM *float64
YCM *float64
WidthCM *float64
HeightCM *float64
RotationDeg *float64
ZIndex *int
Plantable *bool
Color *string
Props *string
Notes *string
}
// FullGarden is the one-shot editor payload: the garden plus everything drawn in
// it. Plantings/Plants stay empty until #14 populates plantings; the shape is
// fixed now so the frontend types against it once.
type FullGarden struct {
Garden *domain.Garden `json:"garden"`
Objects []domain.GardenObject `json:"objects"`
Plantings []domain.Planting `json:"plantings"`
Plants []domain.Plant `json:"plants"`
}
// CreateObject adds an object to a garden the actor can edit.
func (s *Service) CreateObject(ctx context.Context, actorID, gardenID int64, in ObjectInput) (*domain.GardenObject, error) {
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor)
if err != nil {
return nil, err
}
if !objectKinds[in.Kind] {
return nil, domain.ErrInvalidInput
}
shape := in.Shape
if shape == "" {
shape = domain.ShapeRect
}
plantable := plantableByDefault[in.Kind]
if in.Plantable != nil {
plantable = *in.Plantable
}
o := &domain.GardenObject{
GardenID: gardenID,
Kind: in.Kind,
Name: strings.TrimSpace(in.Name),
Shape: shape,
XCM: in.XCM,
YCM: in.YCM,
WidthCM: in.WidthCM,
HeightCM: in.HeightCM,
RotationDeg: in.RotationDeg,
ZIndex: in.ZIndex,
Plantable: plantable,
Color: in.Color,
Props: in.Props,
Notes: strings.TrimSpace(in.Notes),
}
if err := finalizeObject(o, g); err != nil {
return nil, err
}
return s.store.CreateObject(ctx, o)
}
// UpdateObject applies a partial, version-guarded patch to an object in a garden
// the actor can edit. On a version mismatch it returns (current, ErrVersionConflict).
func (s *Service) UpdateObject(ctx context.Context, actorID, objectID int64, patch ObjectPatch, version int64) (*domain.GardenObject, error) {
o, err := s.store.GetObject(ctx, objectID)
if err != nil {
return nil, err // ErrNotFound
}
g, err := s.requireGardenRole(ctx, actorID, o.GardenID, roleEditor)
if err != nil {
return nil, err
}
applyObjectPatch(o, patch)
if err := finalizeObject(o, g); err != nil {
return nil, err
}
o.Version = version
return s.store.UpdateObject(ctx, o)
}
// DeleteObject removes an object (its plantings cascade) from a garden the actor
// can edit.
func (s *Service) DeleteObject(ctx context.Context, actorID, objectID int64) error {
o, err := s.store.GetObject(ctx, objectID)
if err != nil {
return err
}
if _, err := s.requireGardenRole(ctx, actorID, o.GardenID, roleEditor); err != nil {
return err
}
return s.store.DeleteObject(ctx, objectID)
}
// GardenFull returns the whole editor payload for a garden the actor can view.
func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64) (*FullGarden, error) {
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
if err != nil {
return nil, err
}
objects, err := s.store.ListObjectsForGarden(ctx, gardenID)
if err != nil {
return nil, err
}
plantings, err := s.store.ListActivePlantingsForGarden(ctx, gardenID)
if err != nil {
return nil, err
}
plants, err := s.store.ListReferencedPlants(ctx, gardenID)
if err != nil {
return nil, err
}
return &FullGarden{Garden: g, Objects: objects, Plantings: plantings, Plants: plants}, nil
}
// applyObjectPatch mutates o with each provided (non-nil) patch field.
func applyObjectPatch(o *domain.GardenObject, p ObjectPatch) {
if p.Name != nil {
o.Name = strings.TrimSpace(*p.Name)
}
if p.XCM != nil {
o.XCM = *p.XCM
}
if p.YCM != nil {
o.YCM = *p.YCM
}
if p.WidthCM != nil {
o.WidthCM = *p.WidthCM
}
if p.HeightCM != nil {
o.HeightCM = *p.HeightCM
}
if p.RotationDeg != nil {
o.RotationDeg = *p.RotationDeg
}
if p.ZIndex != nil {
o.ZIndex = *p.ZIndex
}
if p.Plantable != nil {
o.Plantable = *p.Plantable
}
if p.Color != nil {
o.Color = p.Color
}
if p.Props != nil {
o.Props = p.Props
}
if p.Notes != nil {
o.Notes = strings.TrimSpace(*p.Notes)
}
}
// finalizeObject normalizes (rotation → [0,360)) and validates a built/merged
// object against its garden. Shared by create and update so both enforce the
// same invariants.
func finalizeObject(o *domain.GardenObject, g *domain.Garden) error {
if !objectKinds[o.Kind] {
return domain.ErrInvalidInput
}
// rect/circle only; polygon is reserved in the schema but not supported yet.
if o.Shape != domain.ShapeRect && o.Shape != domain.ShapeCircle {
return domain.ErrInvalidInput
}
if len(o.Name) > maxObjectNameLen || len(o.Notes) > maxObjectNotesLen {
return domain.ErrInvalidInput
}
if !validDimensionCM(o.WidthCM) || !validDimensionCM(o.HeightCM) {
return domain.ErrInvalidInput
}
if !isFinite(o.XCM) || !isFinite(o.YCM) || !isFinite(o.RotationDeg) {
return domain.ErrInvalidInput
}
o.RotationDeg = normalizeDegrees(o.RotationDeg)
// Loose placement check: the (unrotated) bounding box must overlap the garden
// at all — partial overhang is fine, fully off-field is not.
if !bboxOverlapsGarden(o, g) {
return domain.ErrInvalidInput
}
if o.Color != nil && !isHexColor(*o.Color) {
return domain.ErrInvalidInput
}
if o.Props != nil {
if len(*o.Props) > maxObjectPropsLen || !json.Valid([]byte(*o.Props)) {
return domain.ErrInvalidInput
}
}
return nil
}
func isFinite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
// normalizeDegrees folds an angle into [0, 360).
func normalizeDegrees(d float64) float64 {
d = math.Mod(d, 360)
if d < 0 {
d += 360
}
return d
}
// bboxOverlapsGarden reports whether the object's axis-aligned (unrotated)
// bounding box intersects the garden rectangle [0,width]×[0,height].
func bboxOverlapsGarden(o *domain.GardenObject, g *domain.Garden) bool {
halfW, halfH := o.WidthCM/2, o.HeightCM/2
left, right := o.XCM-halfW, o.XCM+halfW
top, bottom := o.YCM-halfH, o.YCM+halfH
return left < g.WidthCM && right > 0 && top < g.HeightCM && bottom > 0
}
// isHexColor accepts #rgb or #rrggbb (case-insensitive).
func isHexColor(s string) bool {
if len(s) != 4 && len(s) != 7 {
return false
}
if s[0] != '#' {
return false
}
for _, c := range s[1:] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}