Add plantings backend: plop CRUD, derived counts, removed_at (#14)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 6m56s
Adversarial Review (Gadfly) / review (pull_request) Successful in 6m56s

- domain: Planting gains a computed (non-persisted) DerivedCount field.
- store/plantings.go: Get/Create/Update (version-guarded)/Delete alongside the
  existing /full read helper.
- service/plantings.go: place/move/resize/soft-remove a plop; editor role on the
  object's garden; object must be plantable; plant_id must be visible to the
  actor (built-in or own) else ErrInvalidInput; center must sit within the
  object's unrotated local bounds (radius may overhang); planted_at defaults to
  today. derivedCount = max(1, round(π·r²/spacing²)) — one unit-tested helper,
  reused by /full (via a spacing map, no N+1) and single responses.
- api: POST /objects/:id/plantings, PATCH/DELETE /plantings/:id; nullable
  count/label/plantedAt/removedAt use RawMessage so null (clear) is distinct
  from absent (unchanged). removedAt is the soft-remove / "clear bed" seam.
- /full now enriches each active plop with its derivedCount.

Service tests: formula edge cases (tiny radius → 1), defaults + derived, count
override, move/resize + clear override, non-plantable rejection, foreign/unknown
plant rejection, bounds, soft-remove leaves /full, version conflict, cross-user
masking, delete. Plus an API-level create/patch/full/delete flow.

GOWORK=off go build/vet/test ./internal/... green.

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 22:26:15 -04:00
co-authored by Claude Opus 4.8
parent e4505ed9a7
commit 2f3699f7fa
8 changed files with 895 additions and 0 deletions
+9
View File
@@ -176,6 +176,15 @@ func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64) (*Ful
if err != nil {
return nil, err
}
// Fill each plop's DerivedCount from its plant's spacing (referenced plants
// are already loaded, so this is a map lookup rather than N queries).
spacing := make(map[int64]float64, len(plants))
for _, pl := range plants {
spacing[pl.ID] = pl.SpacingCM
}
for i := range plantings {
plantings[i].DerivedCount = derivedCount(plantings[i].RadiusCM, spacing[plantings[i].PlantID])
}
return &FullGarden{Garden: g, Objects: objects, Plantings: plantings, Plants: plants}, nil
}