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 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 } 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, 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. It stamps removed_at // from the service clock (s.now()), same as ClearObject and the fill path, so the // removal date can't diverge by which caller set it; then delegates to // UpdatePlanting for the editor-role check, version guard and history record. func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) { today := s.now().UTC().Format(dateLayout) return s.UpdatePlanting(ctx, actorID, plantingID, PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version) } // 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 }