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
}
+251
View File
@@ -0,0 +1,251 @@
package service
import (
"context"
"errors"
"math"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// dateLayout is the 'YYYY-MM-DD' format plantings store planted_at/removed_at in.
const dateLayout = "2006-01-02"
const (
maxPlantingLabelLen = 200
maxRadiusCM = 10_000 // 100 m — a generous plop ceiling
maxExplicitCount = 1_000_000 // sanity cap on a manual count override
)
// derivedCount is the plant count implied by a plop's area and the plant's
// mature spacing: max(1, round(π·r² / spacing²)). It is the single source of the
// formula (also feeds #15's display and #19's FillRegion). A non-positive radius
// or spacing collapses to the floor of 1.
func derivedCount(radiusCM, spacingCM float64) int {
if radiusCM <= 0 || spacingCM <= 0 || math.IsNaN(radiusCM) || math.IsNaN(spacingCM) {
return 1
}
area := math.Pi * radiusCM * radiusCM
n := int(math.Round(area / (spacingCM * spacingCM)))
if n < 1 {
return 1
}
return n
}
// PlantingInput is the payload for placing a plop. Count nil = derived; PlantedAt
// nil defaults to today.
type PlantingInput struct {
PlantID int64
XCM float64
YCM float64
RadiusCM float64
Count *int
Label *string
PlantedAt *string
}
// PlantingPatch is a partial update. The nullable fields (Count/Label/PlantedAt/
// RemovedAt) use a Set* flag to tell "clear to NULL" from "leave unchanged".
// Setting RemovedAt is how "clear bed"/soft-remove works; clearing it un-removes.
type PlantingPatch struct {
PlantID *int64
XCM *float64
YCM *float64
RadiusCM *float64
SetCount bool
Count *int
SetLabel bool
Label *string
SetPlantedAt bool
PlantedAt *string
SetRemovedAt bool
RemovedAt *string
}
// CreatePlanting places a plop in a plantable object the actor can edit.
func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, in PlantingInput) (*domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil {
return nil, err
}
if !o.Plantable {
return nil, domain.ErrInvalidInput // trees/paths/structures can't hold plants
}
plant, err := s.visiblePlant(ctx, actorID, in.PlantID)
if err != nil {
return nil, err
}
p := &domain.Planting{
ObjectID: objectID,
PlantID: in.PlantID,
XCM: in.XCM,
YCM: in.YCM,
RadiusCM: in.RadiusCM,
Count: in.Count,
Label: trimStringPtr(in.Label),
PlantedAt: in.PlantedAt,
}
if p.PlantedAt == nil {
today := s.now().UTC().Format(dateLayout)
p.PlantedAt = &today
}
if err := finalizePlanting(p, o); err != nil {
return nil, err
}
created, err := s.store.CreatePlanting(ctx, p)
if err != nil {
return nil, err
}
created.DerivedCount = derivedCount(created.RadiusCM, plant.SpacingCM)
return created, nil
}
// UpdatePlanting applies a partial, version-guarded patch to a plop in an object
// the actor can edit. On a version mismatch it returns (current, ErrVersionConflict).
func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64, patch PlantingPatch, version int64) (*domain.Planting, error) {
pl, err := s.store.GetPlanting(ctx, plantingID)
if err != nil {
return nil, err // ErrNotFound
}
o, _, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
if err != nil {
return nil, err
}
applyPlantingPatch(pl, patch)
// The plant may have changed; the (possibly new) plant must be visible.
plant, err := s.visiblePlant(ctx, actorID, pl.PlantID)
if err != nil {
return nil, err
}
if err := finalizePlanting(pl, o); err != nil {
return nil, err
}
pl.Version = version
updated, err := s.store.UpdatePlanting(ctx, pl)
if err != nil {
if errors.Is(err, domain.ErrVersionConflict) && updated != nil {
// Enrich the current row with its own plant's derived count.
s.enrichDerived(ctx, updated)
}
return updated, err
}
updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM)
return updated, nil
}
// DeletePlanting hard-deletes a plop in an object the actor can edit. (Soft
// removal — "clear bed" / harvested — sets removed_at via UpdatePlanting.)
func (s *Service) DeletePlanting(ctx context.Context, actorID, plantingID int64) error {
pl, err := s.store.GetPlanting(ctx, plantingID)
if err != nil {
return err
}
if _, _, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor); err != nil {
return err
}
return s.store.DeletePlanting(ctx, plantingID)
}
// visiblePlant loads a plant the actor may reference in a planting: a built-in or
// one the actor owns. An unknown plant or another user's plant is an invalid
// reference (ErrInvalidInput) rather than leaking existence.
func (s *Service) visiblePlant(ctx context.Context, actorID, plantID int64) (*domain.Plant, error) {
p, err := s.store.GetPlant(ctx, plantID)
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.ErrInvalidInput
}
if err != nil {
return nil, err
}
if p.OwnerID != nil && *p.OwnerID != actorID {
return nil, domain.ErrInvalidInput
}
return p, nil
}
// enrichDerived best-effort sets p.DerivedCount from its plant's spacing (used
// when we don't already hold the plant, e.g. a conflict's current row).
func (s *Service) enrichDerived(ctx context.Context, p *domain.Planting) {
plant, err := s.store.GetPlant(ctx, p.PlantID)
if err == nil {
p.DerivedCount = derivedCount(p.RadiusCM, plant.SpacingCM)
}
}
// applyPlantingPatch mutates pl with each provided patch field.
func applyPlantingPatch(pl *domain.Planting, p PlantingPatch) {
if p.PlantID != nil {
pl.PlantID = *p.PlantID
}
if p.XCM != nil {
pl.XCM = *p.XCM
}
if p.YCM != nil {
pl.YCM = *p.YCM
}
if p.RadiusCM != nil {
pl.RadiusCM = *p.RadiusCM
}
if p.SetCount {
pl.Count = p.Count
}
if p.SetLabel {
pl.Label = trimStringPtr(p.Label)
}
if p.SetPlantedAt {
pl.PlantedAt = p.PlantedAt
}
if p.SetRemovedAt {
pl.RemovedAt = p.RemovedAt
}
}
// finalizePlanting validates a built/merged plop against its parent object.
// Shared by create and update so both enforce the same invariants.
func finalizePlanting(p *domain.Planting, o *domain.GardenObject) error {
if !isFinite(p.RadiusCM) || p.RadiusCM <= 0 || p.RadiusCM > maxRadiusCM {
return domain.ErrInvalidInput
}
if !isFinite(p.XCM) || !isFinite(p.YCM) {
return domain.ErrInvalidInput
}
// Loose bounds: the plop's CENTER must sit within the object's unrotated
// local bounds (origin at object center); the radius may overhang.
if math.Abs(p.XCM) > o.WidthCM/2 || math.Abs(p.YCM) > o.HeightCM/2 {
return domain.ErrInvalidInput
}
if p.Count != nil && (*p.Count < 1 || *p.Count > maxExplicitCount) {
return domain.ErrInvalidInput
}
if p.Label != nil && len(*p.Label) > maxPlantingLabelLen {
return domain.ErrInvalidInput
}
if !validDatePtr(p.PlantedAt) || !validDatePtr(p.RemovedAt) {
return domain.ErrInvalidInput
}
return nil
}
// validDatePtr reports whether a nil-or-'YYYY-MM-DD' date pointer is acceptable.
func validDatePtr(s *string) bool {
if s == nil {
return true
}
_, err := time.Parse(dateLayout, *s)
return err == nil
}
// trimStringPtr trims a *string, preserving nil (distinct from empty).
func trimStringPtr(s *string) *string {
if s == nil {
return nil
}
t := strings.TrimSpace(*s)
return &t
}
+314
View File
@@ -0,0 +1,314 @@
package service
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// seedBed creates a plantable 200×200 bed centered in a 1000×1000 garden.
func seedBed(t *testing.T, s *Service, owner, gardenID int64) *domain.GardenObject {
t.Helper()
o, err := s.CreateObject(context.Background(), owner, gardenID, ObjectInput{
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200,
})
if err != nil {
t.Fatalf("seed bed: %v", err)
}
return o
}
// seedOwnPlant creates a custom plant owned by owner with the given spacing.
func seedOwnPlant(t *testing.T, s *Service, owner int64, spacingCM float64) *domain.Plant {
t.Helper()
p, err := s.CreatePlant(context.Background(), owner, PlantInput{
Name: "Testroot", Category: domain.CategoryHerb, SpacingCM: spacingCM, Color: "#4a7c3f", Icon: "🌿",
})
if err != nil {
t.Fatalf("seed plant: %v", err)
}
return p
}
func TestDerivedCountFormula(t *testing.T) {
cases := []struct {
r, sp float64
want int
}{
{0.1, 10, 1}, // tiny radius floors to 1
{0, 10, 1}, // zero radius → 1
{10, 0, 1}, // zero spacing → 1
{10, 10, 3}, // π·100/100 = 3.14 → 3
{50, 10, 79}, // π·2500/100 = 78.5 → 79
{30, 15, 13}, // π·900/225 = 12.57 → 13
}
for i, c := range cases {
if got := derivedCount(c.r, c.sp); got != c.want {
t.Errorf("case %d: derivedCount(%v, %v) = %d, want %d", i, c.r, c.sp, got, c.want)
}
}
}
func TestCreatePlantingDefaultsAndDerivedCount(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10)
pl, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 10, YCM: -20, RadiusCM: 10,
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if pl.Count != nil {
t.Errorf("count = %v, want nil (derived)", *pl.Count)
}
if pl.DerivedCount != 3 {
t.Errorf("derivedCount = %d, want 3", pl.DerivedCount)
}
if pl.PlantedAt == nil {
t.Error("plantedAt should default to today, got nil")
}
if pl.Version != 1 || pl.ObjectID != bed.ID {
t.Errorf("unexpected planting: %+v", pl)
}
}
func TestPlantingCountOverrideWins(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10)
cnt := 12
pl, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 50, Count: &cnt,
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if pl.Count == nil || *pl.Count != 12 {
t.Errorf("count override = %v, want 12", pl.Count)
}
// derivedCount is still computed alongside the override (79 for r=50, sp=10).
if pl.DerivedCount != 79 {
t.Errorf("derivedCount = %d, want 79", pl.DerivedCount)
}
}
func TestUpdatePlantingMoveResizeAndClearCount(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10)
cnt := 5
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, Count: &cnt,
})
// Move + grow radius, and clear the count override back to derived.
nx, ny, nr := 30.0, -40.0, 50.0
updated, err := s.UpdatePlanting(context.Background(), owner, pl.ID,
PlantingPatch{XCM: &nx, YCM: &ny, RadiusCM: &nr, SetCount: true, Count: nil}, pl.Version)
if err != nil {
t.Fatalf("UpdatePlanting: %v", err)
}
if updated.XCM != 30 || updated.YCM != -40 || updated.RadiusCM != 50 {
t.Errorf("move/resize didn't apply: %+v", updated)
}
if updated.Count != nil {
t.Errorf("count should be cleared, got %v", *updated.Count)
}
if updated.DerivedCount != 79 { // r=50, sp=10
t.Errorf("derivedCount after grow = %d, want 79", updated.DerivedCount)
}
if updated.Version != pl.Version+1 {
t.Errorf("version = %d, want %d", updated.Version, pl.Version+1)
}
}
func TestPlantingNonPlantableObjectRejected(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
plant := seedOwnPlant(t, s, owner, 10)
tree, _ := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
Kind: domain.KindTree, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100,
})
if _, err := s.CreatePlanting(context.Background(), owner, tree.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("planting into a tree err = %v, want ErrInvalidInput", err)
}
}
func TestPlantingForeignPlantRejectedBuiltinAllowed(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
bob := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
// Bob's custom plant is invisible to owner → invalid reference.
bobPlant := seedOwnPlant(t, s, bob, 10)
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: bobPlant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("foreign plant err = %v, want ErrInvalidInput", err)
}
// An unknown plant id is likewise invalid.
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: 999999, XCM: 0, YCM: 0, RadiusCM: 10,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("unknown plant err = %v, want ErrInvalidInput", err)
}
// A built-in plant is plantable by anyone.
builtins, _ := s.ListPlants(context.Background(), owner)
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: builtins[0].ID, XCM: 0, YCM: 0, RadiusCM: 10,
}); err != nil {
t.Errorf("planting a built-in should work: %v", err)
}
}
func TestPlantingBoundsCheck(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID) // 200×200 → local bounds ±100
plant := seedOwnPlant(t, s, owner, 10)
// Center outside the object's local bounds → rejected (radius may overhang,
// but the center may not).
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 150, YCM: 0, RadiusCM: 10,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("out-of-bounds center err = %v, want ErrInvalidInput", err)
}
// On the edge is allowed.
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 100, YCM: -100, RadiusCM: 30,
}); err != nil {
t.Errorf("edge-of-bounds center should be allowed: %v", err)
}
// Non-positive radius rejected.
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 0,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("zero radius err = %v, want ErrInvalidInput", err)
}
}
func TestPlantingSoftRemoveLeavesFull(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10)
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
})
// It shows in /full, with a derived count.
full, _ := s.GardenFull(context.Background(), owner, g.ID)
if len(full.Plantings) != 1 {
t.Fatalf("full.Plantings = %d, want 1", len(full.Plantings))
}
if full.Plantings[0].DerivedCount != 3 {
t.Errorf("full derivedCount = %d, want 3", full.Plantings[0].DerivedCount)
}
if len(full.Plants) != 1 {
t.Errorf("full.Plants = %d, want 1 referenced plant", len(full.Plants))
}
// Soft-remove → it leaves /full.
rm := "2026-07-01"
if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID,
PlantingPatch{SetRemovedAt: true, RemovedAt: &rm}, pl.Version); err != nil {
t.Fatalf("soft-remove: %v", err)
}
full, _ = s.GardenFull(context.Background(), owner, g.ID)
if len(full.Plantings) != 0 {
t.Errorf("removed plop still in /full: %d", len(full.Plantings))
}
}
func TestPlantingVersionConflict(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10)
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
})
nr := 20.0
if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version); err != nil {
t.Fatalf("first update: %v", err)
}
current, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version)
if !errors.Is(err, domain.ErrVersionConflict) {
t.Fatalf("stale update err = %v, want ErrVersionConflict", err)
}
if current == nil || current.Version != 2 {
t.Fatalf("conflict didn't return current row: %+v", current)
}
if current.DerivedCount < 1 {
t.Errorf("conflict current row should carry a derived count, got %d", current.DerivedCount)
}
}
func TestPlantingCrossUserIsNotFound(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
bob := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10)
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
})
nr := 5.0
if _, err := s.UpdatePlanting(context.Background(), bob, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob update err = %v, want ErrNotFound", err)
}
if err := s.DeletePlanting(context.Background(), bob, pl.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob delete err = %v, want ErrNotFound", err)
}
// Bob can't place into owner's bed either.
if _, err := s.CreatePlanting(context.Background(), bob, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
}); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob create err = %v, want ErrNotFound", err)
}
}
func TestDeletePlanting(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10)
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
})
if err := s.DeletePlanting(context.Background(), owner, pl.ID); err != nil {
t.Fatalf("DeletePlanting: %v", err)
}
full, _ := s.GardenFull(context.Background(), owner, g.ID)
if len(full.Plantings) != 0 {
t.Errorf("planting still present after delete: %d", len(full.Plantings))
}
}