"German Red Garlic" now links back to the Johnny's page it came from, and pansy knows how much is left. Two modelling calls, both of which look like omissions unless stated. The plant catalog stays FLAT — no species/variety hierarchy. A row in your catalog just is the thing you plant; the built-in "Garlic" is a generic starting point you clone. A hierarchy buys taxonomy nobody asked for and complicates every join. Inventory belongs to a PURCHASE, not to a variety. You might buy the same garlic from two vendors in two years at two prices and two germination rates; quantity columns on plants would collapse all of that into one number and lose it. So lots get their own table, and owner_id sits on the lot rather than being inherited from the plant — you can buy generic built-in Garlic from Johnny's and the record is still yours alone. Lots are never shared along with a garden. `remaining` is derived, never stored: quantity minus the summed effective count of the active plantings attributed to the lot. The alternative — decrementing a column when you plant — drifts the moment anything edits or removes a planting behind its back, and once it has drifted there's no way to tell what the true number was. Removing a plop gives the seed back with nothing having to remember to. The usage query returns raw radius/count/spacing rather than a SUM, because the effective-count formula lives in the service and reproducing it in SQL would guarantee the two drift apart. source_url is validated as http(s) with a host, on both plants and lots. It renders as a clickable link, so that check is load-bearing rather than tidiness: without it a stored javascript: URL becomes script execution the moment someone clicks it. Schemeless and relative URLs are refused too — they'd resolve against pansy's own origin, which is never what a vendor link means. A planting's lot must be the actor's AND a lot of the plant being planted. Attributing a garlic planting to a tomato lot would silently corrupt every remaining count downstream, so it's refused rather than stored, and re-checked on update in case a plant swap invalidated a previously-valid attribution. Deleting a lot leaves its plantings alone with a null link: the plants really were in the ground, whatever happened to the record of the packet. Closes #50 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
325 lines
10 KiB
Go
325 lines
10 KiB
Go
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.seedLotForPlanting(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.seedLotForPlanting(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
|
|
}
|
|
|
|
// 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
|
|
}
|