Add plantings backend: plop CRUD, derived counts, removed_at (#14)
- 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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user