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 — omitted when there are no lots, or // when lots disagree about the unit and summing them would be a lie. SeedRemaining *float64 `json:"seedRemaining,omitempty"` SeedUnit string `json:"seedUnit,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 } total := 0.0 unit := ls[0].Unit for _, l := range ls { total += l.Remaining if l.Unit != unit { unit = "" // mixed units can't be summed honestly } } 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 }