Build image / build-and-push (push) Successful in 17s
- The plan-name line of the system prompt interpolates the garden's name with %q like the rest of the prompt: any editor can rename a garden, and a name with a newline in it must not read as an instruction. - fill_region refuses an inverted rectangle with its corners named, and a rectangle that misses the bed (or only touches its edge) is an error from the service rather than a successful fill of nothing. - remove_plantings requires plantId; omitted it would remove plant 0 and report success. - historyEntry.Undo → UndoOf (it holds the reverted change set's id). - remove_planting's description names list_plantings as an id source. - RemovePlanting takes the removal date itself; the dateless wrapper had no callers left. Co-Authored-By: Claude Fable 5 <[email protected]>
412 lines
14 KiB
Go
412 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"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 or
|
||
// non-finite radius/spacing collapses to the floor of 1; the result is capped at
|
||
// maxExplicitCount so a huge radius / tiny spacing can't yield an absurd (or, on
|
||
// a 32-bit int, overflowing) value — the same ceiling a manual override honors.
|
||
func derivedCount(radiusCM, spacingCM float64) int {
|
||
if radiusCM <= 0 || spacingCM <= 0 || !isFinite(radiusCM) || !isFinite(spacingCM) {
|
||
return 1
|
||
}
|
||
area := math.Pi * radiusCM * radiusCM
|
||
n := int(math.Round(area / (spacingCM * spacingCM)))
|
||
if n < 1 {
|
||
return 1
|
||
}
|
||
if n > maxExplicitCount {
|
||
return maxExplicitCount
|
||
}
|
||
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
|
||
// SeedLotID attributes this planting to a purchase, so the lot can report
|
||
// what's left. Optional.
|
||
SeedLotID *int64
|
||
}
|
||
|
||
// 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
|
||
SetSeedLotID bool
|
||
SeedLotID *int64
|
||
}
|
||
|
||
// 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, g, 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
|
||
}
|
||
|
||
radius := in.RadiusCM
|
||
if radius == 0 {
|
||
// Unspecified means ONE plant: the editor's tap-to-place radius, half the
|
||
// spacing. A clump (1.5× spacing) is what a fill makes, not a placement.
|
||
radius = plant.SpacingCM / 2
|
||
}
|
||
p := &domain.Planting{
|
||
ObjectID: objectID,
|
||
PlantID: in.PlantID,
|
||
XCM: in.XCM,
|
||
YCM: in.YCM,
|
||
RadiusCM: radius,
|
||
Count: in.Count,
|
||
Label: trimStringPtr(in.Label),
|
||
PlantedAt: in.PlantedAt,
|
||
SeedLotID: in.SeedLotID,
|
||
}
|
||
if p.PlantedAt == nil {
|
||
today := s.now().UTC().Format(dateLayout)
|
||
p.PlantedAt = &today
|
||
}
|
||
if err := finalizePlanting(p, o, true); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.checkSeedLotForPlanting(ctx, actorID, p.SeedLotID, p.PlantID); err != nil {
|
||
return nil, err
|
||
}
|
||
created, err := s.store.CreatePlanting(ctx, p)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.record(ctx, g.ID, actorID, "Planted "+plant.Name+" in "+objectLabel(o),
|
||
changeCreate(domain.EntityPlanting, created.ID, created))
|
||
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, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
before := *pl // GetPlanting returns the current row, and the patch mutates it
|
||
originalPlantID := pl.PlantID
|
||
applyPlantingPatch(pl, patch)
|
||
// Fetch the plop's plant for the derived count. Only when the actor is
|
||
// actually CHANGING the plant (new id ≠ old) is the new plant gated on
|
||
// visibility — an existing plop may reference a plant the actor can't see
|
||
// (e.g. a shared editor in the owner's garden using the owner's private
|
||
// plant), and a no-op plantId resend must not break editing it.
|
||
var plant *domain.Plant
|
||
if pl.PlantID != originalPlantID {
|
||
plant, err = s.visiblePlant(ctx, actorID, pl.PlantID)
|
||
} else {
|
||
plant, err = s.store.GetPlant(ctx, pl.PlantID)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Only re-check the object bounds when the position is actually being moved.
|
||
// A plop left outside its object's bounds by a later object resize must still
|
||
// be editable (radius, dates, soft-remove) — otherwise it becomes a row you
|
||
// can neither fix nor remove.
|
||
checkBounds := patch.XCM != nil || patch.YCM != nil
|
||
if err := finalizePlanting(pl, o, checkBounds); err != nil {
|
||
return nil, err
|
||
}
|
||
// Re-checked on every update, not just when the lot changes: a plant swap can
|
||
// leave a previously-valid attribution pointing at a lot of the wrong variety.
|
||
if err := s.checkSeedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID); 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
|
||
}
|
||
s.record(ctx, g.ID, actorID, plantingEditSummary(&before, updated, plant, o),
|
||
changeUpdate(domain.EntityPlanting, updated.ID, &before, updated))
|
||
updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM)
|
||
return updated, nil
|
||
}
|
||
|
||
// RemovePlanting soft-removes a single plop — the one-plop counterpart to
|
||
// ClearObject, used by the agent's remove_planting tool. removedAt (YYYY-MM-DD)
|
||
// is the day the caller knows it happened — the gardener's local day; nil
|
||
// stamps the service clock's UTC today, the same default ClearObject and the
|
||
// fill path use. Delegates to UpdatePlanting for the editor-role check, version
|
||
// guard and history record.
|
||
func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64, removedAt *string) (*domain.Planting, error) {
|
||
if !validDatePtr(removedAt) {
|
||
return nil, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
||
}
|
||
on := s.now().UTC().Format(dateLayout)
|
||
if removedAt != nil {
|
||
on = *removedAt
|
||
}
|
||
return s.UpdatePlanting(ctx, actorID, plantingID,
|
||
PlantingPatch{SetRemovedAt: true, RemovedAt: &on}, version)
|
||
}
|
||
|
||
// MoveInput says where a plop goes: a position in the local frame of ToObjectID,
|
||
// or of the plop's current object when ToObjectID is nil.
|
||
type MoveInput struct {
|
||
ToObjectID *int64
|
||
XCM, YCM float64
|
||
}
|
||
|
||
// MovePlanting relocates one plop — within its object, or into another plantable
|
||
// object of the same garden — keeping its plant, size, count and planting date.
|
||
// Removing and re-placing is not the same thing: "move the tomatoes to the other
|
||
// bed" is not "pull them up and plant new ones today", and the live assistant
|
||
// did exactly that for want of this. Version-guarded like UpdatePlanting; a
|
||
// within-object move IS an UpdatePlanting of the position.
|
||
func (s *Service) MovePlanting(ctx context.Context, actorID, plantingID int64, in MoveInput, version int64) (*domain.Planting, error) {
|
||
pl, err := s.store.GetPlanting(ctx, plantingID)
|
||
if err != nil {
|
||
return nil, err // ErrNotFound
|
||
}
|
||
if in.ToObjectID == nil || *in.ToObjectID == pl.ObjectID {
|
||
return s.UpdatePlanting(ctx, actorID, plantingID, PlantingPatch{XCM: &in.XCM, YCM: &in.YCM}, version)
|
||
}
|
||
from, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
to, toGarden, err := s.objectForRole(ctx, actorID, *in.ToObjectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if toGarden.ID != g.ID {
|
||
return nil, fmt.Errorf("%w: a planting can only move within its own garden", domain.ErrInvalidInput)
|
||
}
|
||
if !to.Plantable {
|
||
return nil, fmt.Errorf("%w: %s can't hold plants", domain.ErrInvalidInput, objectLabel(to))
|
||
}
|
||
// By id, not through the actor's catalog: the plop may be of a variety the
|
||
// actor can't see (a shared editor, the owner's private plant), and moving it
|
||
// isn't choosing it.
|
||
plant, err := s.store.GetPlant(ctx, pl.PlantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
before := *pl
|
||
pl.ObjectID = to.ID
|
||
pl.XCM, pl.YCM = in.XCM, in.YCM
|
||
if err := finalizePlanting(pl, to, true); 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 {
|
||
s.enrichDerived(ctx, updated)
|
||
}
|
||
return updated, err
|
||
}
|
||
s.record(ctx, g.ID, actorID, "Moved "+plant.Name+" from "+objectLabel(from)+" to "+objectLabel(to),
|
||
changeUpdate(domain.EntityPlanting, updated.ID, &before, updated))
|
||
updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM)
|
||
return updated, nil
|
||
}
|
||
|
||
// plantingEditSummary describes a plop edit for the history list. Soft-removal
|
||
// ("clear bed", harvested) is the one edit worth naming specifically — it reads
|
||
// as a removal to the person who did it, not as an edit.
|
||
func plantingEditSummary(before, after *domain.Planting, plant *domain.Plant, o *domain.GardenObject) string {
|
||
switch {
|
||
case before.RemovedAt == nil && after.RemovedAt != nil:
|
||
return "Removed " + plant.Name + " from " + objectLabel(o)
|
||
case before.RemovedAt != nil && after.RemovedAt == nil:
|
||
return "Restored " + plant.Name + " in " + objectLabel(o)
|
||
default:
|
||
return "Edited " + plant.Name + " in " + objectLabel(o)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
o, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.store.DeletePlanting(ctx, plantingID); err != nil {
|
||
return err
|
||
}
|
||
s.record(ctx, g.ID, actorID, "Deleted a planting from "+objectLabel(o),
|
||
changeDelete(domain.EntityPlanting, pl.ID, pl))
|
||
return nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
if p.SetSeedLotID {
|
||
pl.SeedLotID = p.SeedLotID
|
||
}
|
||
}
|
||
|
||
// finalizePlanting validates a built/merged plop against its parent object.
|
||
// Shared by create and update so both enforce the same invariants. checkBounds
|
||
// gates the center-in-object-bounds test: create always checks it, update only
|
||
// when the position is being moved (so a resize that orphans a plop outside the
|
||
// shrunken object doesn't block editing/removing it).
|
||
func finalizePlanting(p *domain.Planting, o *domain.GardenObject, checkBounds bool) 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 checkBounds && (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
|
||
}
|
||
// A plop can't be removed before it was planted (nonsensical interval).
|
||
if p.PlantedAt != nil && p.RemovedAt != nil {
|
||
planted, _ := time.Parse(dateLayout, *p.PlantedAt)
|
||
removed, _ := time.Parse(dateLayout, *p.RemovedAt)
|
||
if removed.Before(planted) {
|
||
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
|
||
}
|