Build image / build-and-push (push) Successful in 7s
attachSeedRemaining summed Remaining across lots in different units and then dropped only the unit LABEL, leaving a bare number that reads as a quantity and isn't one — 50 seeds plus 50 grams reported as "100". Worse for a model than for a person, since it has nothing else to go on. The total is now omitted with the label, and SeedLots reports how many lots there are, so "several lots, no single total" stays distinguishable from "no seed at all". Also folded TestToolboxScenario's inline setup into the newAgentTestService helper the new tests use, rather than leaving two copies of the same six lines. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
344 lines
11 KiB
Go
344 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// Plant field bounds. Spacing is mature in-row spacing in cm; the ceiling is a
|
|
// generous sanity limit (also rejecting NaN/Inf). Name/notes are length-capped so
|
|
// untrusted input can't balloon storage; icon holds one emoji (possibly a
|
|
// multi-codepoint ZWJ sequence, hence a byte cap rather than length 1).
|
|
const (
|
|
maxPlantNameLen = 200
|
|
maxPlantNotesLen = 10_000
|
|
maxPlantIconLen = 32
|
|
minPlantSpacingCM = 0.1
|
|
maxPlantSpacingCM = 10_000 // 100 m — headroom for large trees/shrubs
|
|
maxDaysToMaturity = 3650 // ~10 years
|
|
maxPlantVendorLen = 200
|
|
)
|
|
|
|
// plantCategories is the set of valid category enum values (mirrors the schema
|
|
// CHECK constraint in 0001_init.sql).
|
|
var plantCategories = map[string]struct{}{
|
|
domain.CategoryVegetable: {},
|
|
domain.CategoryHerb: {},
|
|
domain.CategoryFlower: {},
|
|
domain.CategoryFruit: {},
|
|
domain.CategoryTreeShrub: {},
|
|
domain.CategoryCover: {},
|
|
}
|
|
|
|
// PlantInput is the mutable field set for creating a plant. DaysToMaturity is
|
|
// optional (nil = unknown).
|
|
type PlantInput struct {
|
|
Name string
|
|
Category string
|
|
SpacingCM float64
|
|
Color string
|
|
Icon string
|
|
DaysToMaturity *int
|
|
// Where this variety came from — the page you'd go back to to buy it again.
|
|
// What you actually bought, and how much is left, is a SeedLot instead.
|
|
SourceURL string
|
|
Vendor string
|
|
Notes string
|
|
}
|
|
|
|
// PlantPatch is a partial update: nil fields are left unchanged. DaysToMaturity
|
|
// is itself nullable, so SetDays distinguishes "clear to NULL" (SetDays=true,
|
|
// value nil) from "leave unchanged" (SetDays=false) — a plain nil can't.
|
|
type PlantPatch struct {
|
|
Name *string
|
|
Category *string
|
|
SpacingCM *float64
|
|
Color *string
|
|
Icon *string
|
|
SetDays bool
|
|
DaysToMaturity *int
|
|
SourceURL *string
|
|
Vendor *string
|
|
Notes *string
|
|
}
|
|
|
|
// ListPlants returns the plants the actor can see: built-ins plus their own.
|
|
func (s *Service) ListPlants(ctx context.Context, actorID int64) ([]domain.Plant, error) {
|
|
return s.store.ListPlantsForActor(ctx, actorID)
|
|
}
|
|
|
|
// PlantMatch is a candidate from FindPlants: the plant, plus what seed the actor
|
|
// has left of it, so an agent can say "you only have enough for half that bed"
|
|
// instead of confidently planting seed that doesn't exist.
|
|
type PlantMatch struct {
|
|
domain.Plant
|
|
// SeedRemaining is the total left across the actor's lots of this plant, and
|
|
// SeedUnit the unit they're counted in. Both are omitted when there are no
|
|
// lots — and also when the lots disagree about the unit, because adding
|
|
// grams to packets produces a number that means nothing. SeedLots still
|
|
// reports how many there are, so "several lots, no single total" is
|
|
// distinguishable from "no seed at all".
|
|
SeedRemaining *float64 `json:"seedRemaining,omitempty"`
|
|
SeedUnit string `json:"seedUnit,omitempty"`
|
|
SeedLots int `json:"seedLots,omitempty"`
|
|
}
|
|
|
|
// maxPlantMatches caps FindPlants. A model given fifty candidates is not being
|
|
// helped; if the query is that broad the answer is to ask a better one.
|
|
const maxPlantMatches = 10
|
|
|
|
// FindPlants returns the plants in the actor's visible catalog (built-ins plus
|
|
// their own) whose name or category matches the query, best match first.
|
|
//
|
|
// It deliberately returns SEVERAL candidates rather than one guess. "garlic"
|
|
// against a catalog holding both "Garlic" and "German Red Garlic" is genuinely
|
|
// ambiguous, and a caller with the surrounding conversation is far better placed
|
|
// to disambiguate than a fuzzy-match heuristic here. An empty query returns the
|
|
// catalog head rather than nothing, so a caller can browse.
|
|
func (s *Service) FindPlants(ctx context.Context, actorID int64, query string) ([]PlantMatch, error) {
|
|
plants, err := s.store.ListPlantsForActor(ctx, actorID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
q := strings.ToLower(strings.TrimSpace(query))
|
|
|
|
type scored struct {
|
|
plant domain.Plant
|
|
rank int
|
|
}
|
|
ranked := make([]scored, 0, len(plants))
|
|
for _, p := range plants {
|
|
name := strings.ToLower(p.Name)
|
|
switch {
|
|
case q == "":
|
|
ranked = append(ranked, scored{p, 3})
|
|
case name == q:
|
|
ranked = append(ranked, scored{p, 0})
|
|
case strings.HasPrefix(name, q):
|
|
ranked = append(ranked, scored{p, 1})
|
|
case strings.Contains(name, q):
|
|
ranked = append(ranked, scored{p, 2})
|
|
case strings.Contains(strings.ToLower(p.Category), q):
|
|
ranked = append(ranked, scored{p, 3})
|
|
}
|
|
}
|
|
// Stable by rank then name, so the same query always answers the same way —
|
|
// a tool whose results reshuffle between calls is one a model can't reason
|
|
// about across turns.
|
|
sort.SliceStable(ranked, func(i, j int) bool {
|
|
if ranked[i].rank != ranked[j].rank {
|
|
return ranked[i].rank < ranked[j].rank
|
|
}
|
|
return ranked[i].plant.Name < ranked[j].plant.Name
|
|
})
|
|
if len(ranked) > maxPlantMatches {
|
|
ranked = ranked[:maxPlantMatches]
|
|
}
|
|
|
|
matches := make([]PlantMatch, 0, len(ranked))
|
|
for _, r := range ranked {
|
|
matches = append(matches, PlantMatch{Plant: r.plant})
|
|
}
|
|
if err := s.attachSeedRemaining(ctx, actorID, matches); err != nil {
|
|
return nil, err
|
|
}
|
|
return matches, nil
|
|
}
|
|
|
|
// attachSeedRemaining fills SeedRemaining/SeedUnit from the actor's lots.
|
|
func (s *Service) attachSeedRemaining(ctx context.Context, actorID int64, matches []PlantMatch) error {
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
lots, err := s.ListSeedLots(ctx, actorID, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
byPlant := map[int64][]domain.SeedLot{}
|
|
for _, l := range lots {
|
|
byPlant[l.PlantID] = append(byPlant[l.PlantID], l)
|
|
}
|
|
for i := range matches {
|
|
ls := byPlant[matches[i].ID]
|
|
if len(ls) == 0 {
|
|
continue
|
|
}
|
|
matches[i].SeedLots = len(ls)
|
|
unit := ls[0].Unit
|
|
mixed := false
|
|
total := 0.0
|
|
for _, l := range ls {
|
|
total += l.Remaining
|
|
if l.Unit != unit {
|
|
mixed = true
|
|
}
|
|
}
|
|
if mixed {
|
|
// Dropping only the LABEL would leave a number that reads as a
|
|
// quantity and isn't one. Drop the total with it; SeedLots still says
|
|
// there is seed here, just not one figure for it.
|
|
continue
|
|
}
|
|
matches[i].SeedRemaining = &total
|
|
matches[i].SeedUnit = unit
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreatePlant adds a plant owned by the actor. To "clone" a built-in the client
|
|
// simply POSTs a copy — there is no dedicated endpoint, and the copy is owned by
|
|
// (and editable by) the actor.
|
|
func (s *Service) CreatePlant(ctx context.Context, actorID int64, in PlantInput) (*domain.Plant, error) {
|
|
p := &domain.Plant{
|
|
Name: strings.TrimSpace(in.Name),
|
|
Category: in.Category,
|
|
SpacingCM: in.SpacingCM,
|
|
Color: in.Color,
|
|
Icon: strings.TrimSpace(in.Icon),
|
|
DaysToMaturity: in.DaysToMaturity,
|
|
SourceURL: strings.TrimSpace(in.SourceURL),
|
|
Vendor: strings.TrimSpace(in.Vendor),
|
|
Notes: strings.TrimSpace(in.Notes),
|
|
}
|
|
if err := finalizePlant(p); err != nil {
|
|
return nil, err
|
|
}
|
|
owner := actorID
|
|
p.OwnerID = &owner
|
|
return s.store.CreatePlant(ctx, p)
|
|
}
|
|
|
|
// writablePlant loads a plant and enforces that the actor may modify it. It is
|
|
// THE authorization point for plant mutation. Built-in rows (owner_id nil) are
|
|
// visible to everyone but read-only → ErrForbidden. A row owned by someone else
|
|
// is invisible → ErrNotFound (masking existence, as gardens/objects do). Only the
|
|
// actor's own rows are writable.
|
|
func (s *Service) writablePlant(ctx context.Context, actorID, plantID int64) (*domain.Plant, error) {
|
|
p, err := s.store.GetPlant(ctx, plantID)
|
|
if err != nil {
|
|
return nil, err // ErrNotFound
|
|
}
|
|
if p.OwnerID == nil {
|
|
return nil, domain.ErrForbidden // built-in: seeded and read-only
|
|
}
|
|
if *p.OwnerID != actorID {
|
|
return nil, domain.ErrNotFound // another user's plant: not visible
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// UpdatePlant applies a partial, version-guarded patch to the actor's own plant.
|
|
// On a version mismatch it returns (current, ErrVersionConflict).
|
|
func (s *Service) UpdatePlant(ctx context.Context, actorID, plantID int64, patch PlantPatch, version int64) (*domain.Plant, error) {
|
|
p, err := s.writablePlant(ctx, actorID, plantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyPlantPatch(p, patch)
|
|
if err := finalizePlant(p); err != nil {
|
|
return nil, err
|
|
}
|
|
p.Version = version
|
|
return s.store.UpdatePlant(ctx, p)
|
|
}
|
|
|
|
// DeletePlant removes the actor's own plant. A plant still referenced by any
|
|
// planting (even a removed one — the FK is RESTRICT) is refused with
|
|
// ErrPlantInUse rather than orphaning history; the client clones-and-edits or
|
|
// clears the plantings first.
|
|
//
|
|
// Seed lots are refused for a different reason: seed_lots.plant_id is ON DELETE
|
|
// CASCADE, so without this check deleting a plant would silently destroy the
|
|
// purchase records attached to it — vendor, cost, germination rate, all gone
|
|
// with no warning. Refusing lets the owner delete the lots deliberately if that
|
|
// is really what they meant.
|
|
func (s *Service) DeletePlant(ctx context.Context, actorID, plantID int64) error {
|
|
if _, err := s.writablePlant(ctx, actorID, plantID); err != nil {
|
|
return err
|
|
}
|
|
n, err := s.store.CountPlantingsForPlant(ctx, plantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
return domain.ErrPlantInUse
|
|
}
|
|
lots, err := s.store.CountSeedLotsForPlant(ctx, plantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if lots > 0 {
|
|
return domain.ErrPlantInUse
|
|
}
|
|
return s.store.DeletePlant(ctx, plantID)
|
|
}
|
|
|
|
// applyPlantPatch mutates p with each provided (non-nil) patch field.
|
|
func applyPlantPatch(p *domain.Plant, patch PlantPatch) {
|
|
if patch.Name != nil {
|
|
p.Name = strings.TrimSpace(*patch.Name)
|
|
}
|
|
if patch.Category != nil {
|
|
p.Category = *patch.Category
|
|
}
|
|
if patch.SpacingCM != nil {
|
|
p.SpacingCM = *patch.SpacingCM
|
|
}
|
|
if patch.Color != nil {
|
|
p.Color = *patch.Color
|
|
}
|
|
if patch.Icon != nil {
|
|
p.Icon = strings.TrimSpace(*patch.Icon)
|
|
}
|
|
if patch.SetDays {
|
|
p.DaysToMaturity = patch.DaysToMaturity
|
|
}
|
|
if patch.SourceURL != nil {
|
|
p.SourceURL = strings.TrimSpace(*patch.SourceURL)
|
|
}
|
|
if patch.Vendor != nil {
|
|
p.Vendor = strings.TrimSpace(*patch.Vendor)
|
|
}
|
|
if patch.Notes != nil {
|
|
p.Notes = strings.TrimSpace(*patch.Notes)
|
|
}
|
|
}
|
|
|
|
// finalizePlant validates a built/merged plant. Shared by create and update so
|
|
// both enforce the same invariants. isFinite/isHexColor live in objects.go.
|
|
func finalizePlant(p *domain.Plant) error {
|
|
if p.Name == "" || len(p.Name) > maxPlantNameLen {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if _, ok := plantCategories[p.Category]; !ok {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if !isFinite(p.SpacingCM) || p.SpacingCM < minPlantSpacingCM || p.SpacingCM > maxPlantSpacingCM {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if !isHexColor(p.Color) {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if p.Icon == "" || len(p.Icon) > maxPlantIconLen {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if p.DaysToMaturity != nil && (*p.DaysToMaturity < 1 || *p.DaysToMaturity > maxDaysToMaturity) {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if len(p.Notes) > maxPlantNotesLen {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if len(p.Vendor) > maxPlantVendorLen {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
// Rendered as a clickable link, so the scheme check is load-bearing (see
|
|
// validSourceURL) rather than tidiness.
|
|
if !validSourceURL(p.SourceURL) {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
return nil
|
|
}
|