package service import ( "context" "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 ) // 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 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 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) } // 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, 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. 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 } 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.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 } return nil }