package service import ( "context" "math" "strings" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // Garden sizing and field bounds. A new garden with no dimensions defaults to a // 10 m square; dimensions must be finite and within [1 cm, 100 m] — a generous // sanity range that also rejects NaN/Inf and absurd values. Name/notes are // length-capped so untrusted input can't balloon storage. const ( defaultGardenCM = 1000 // 10 m minGardenCM = 1 // 1 cm maxGardenCM = 10_000 // 100 m defaultGardenGridCM = 100 // 1 m, matching the editor's previous fixed grid maxGardenNameLen = 200 maxGardenNotesLen = 10_000 ) // gardenRole ranks a user's access to a garden. Higher includes lower // (owner can do anything an editor can, etc.). Until sharing (#16), the only // role anyone holds is owner, on their own gardens. type gardenRole int const ( roleNone gardenRole = iota roleViewer roleEditor roleOwner ) // String renders a role for the API's Garden.MyRole ("owner"/"editor"/"viewer"). func (r gardenRole) String() string { switch r { case roleOwner: return domain.RoleOwner case roleEditor: return domain.RoleEditor case roleViewer: return domain.RoleViewer default: return "" } } // GardenInput is the mutable field set for creating or updating a garden. type GardenInput struct { Name string WidthCM float64 HeightCM float64 UnitPref string Notes string GridSizeCM float64 SnapToGrid bool } // requireGardenRole loads a garden and enforces that the actor holds at least // min. It is THE place authorization for a garden is decided — REST handlers and // future agent tools both funnel through here, so neither can skip a check. // // A user with no role at all gets ErrNotFound rather than ErrForbidden: we don't // reveal that a garden exists to someone with no access. A user who has some // access but not enough (e.g. a viewer trying to edit, once #16 lands) gets // ErrForbidden. #16 extends effectiveRole to consult garden_shares. func (s *Service) requireGardenRole(ctx context.Context, actorID, gardenID int64, min gardenRole) (*domain.Garden, error) { g, err := s.store.GetGarden(ctx, gardenID) if err != nil { return nil, err // ErrNotFound or a real error } role, err := s.effectiveGardenRole(ctx, actorID, g) if err != nil { return nil, err } if role == roleNone { return nil, domain.ErrNotFound } if role < min { return nil, domain.ErrForbidden } g.MyRole = role.String() // so every read-through carries the actor's role return g, nil } // effectiveGardenRole is the actor's role on a garden: owner (implicit via // gardens.owner_id), else a viewer/editor grant from garden_shares, else none. func (s *Service) effectiveGardenRole(ctx context.Context, actorID int64, g *domain.Garden) (gardenRole, error) { if g.OwnerID == actorID { return roleOwner, nil } role, found, err := s.store.GetShareRole(ctx, g.ID, actorID) if err != nil { return roleNone, err } if !found { return roleNone, nil } switch role { case domain.RoleEditor: return roleEditor, nil case domain.RoleViewer: return roleViewer, nil default: return roleNone, nil } } // CreateGarden creates a garden owned by the actor. Missing dimensions default // to a 10 m square; unit defaults to metric. func (s *Service) CreateGarden(ctx context.Context, actorID int64, in GardenInput) (*domain.Garden, error) { g, err := gardenFromInput(in, true) if err != nil { return nil, err } g.OwnerID = actorID created, err := s.store.CreateGarden(ctx, g) if err != nil { return nil, err } created.MyRole = roleOwner.String() // the creator owns it return created, nil } // GetGarden returns a garden the actor may at least view. func (s *Service) GetGarden(ctx context.Context, actorID, gardenID int64) (*domain.Garden, error) { return s.requireGardenRole(ctx, actorID, gardenID, roleViewer) } // ListGardens returns the gardens the actor can see: owned plus shared-with-them, // each row carrying the actor's my_role. func (s *Service) ListGardens(ctx context.Context, actorID int64) ([]domain.Garden, error) { return s.store.ListGardensForActor(ctx, actorID) } // UpdateGarden applies a version-guarded update to a garden's metadata; only the // OWNER may edit metadata (editors edit contents, not the garden itself). On a // version mismatch it returns (current garden, ErrVersionConflict) so the handler // can return the fresh row for the client to rebase. func (s *Service) UpdateGarden(ctx context.Context, actorID, gardenID int64, in GardenInput, version int64) (*domain.Garden, error) { before, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner) if err != nil { return nil, err } g, err := gardenFromInput(in, false) // no defaults: an update states every field if err != nil { return nil, err } g.ID = gardenID g.Version = version updated, err := s.store.UpdateGarden(ctx, g) if updated != nil { updated.MyRole = roleOwner.String() // only the owner reaches here } if err != nil { return updated, err } // MyRole is computed, not stored; blank it in the snapshot so a revert can't // write a role into a row that has no such column. snap := *before snap.MyRole = "" after := *updated after.MyRole = "" s.record(ctx, gardenID, actorID, "Edited garden settings", changeUpdate(domain.EntityGarden, gardenID, &snap, &after)) return updated, nil } // CopyGarden duplicates a garden into a new one owned by the actor, carrying // over its dimensions, grid settings, objects and currently-planted plops. A // blank name defaults to " (copy)". // // Owner-only, deliberately: a copy's plops keep pointing at the SOURCE's // plant_ids, and a custom plant is owned by one user. Letting a viewer copy // someone else's garden would hand them a garden referencing plants that aren't // in their catalog — and would block the original owner from ever deleting those // plants (plantings.plant_id is ON DELETE RESTRICT). Copying a shared garden // needs a plant-cloning policy first; until then the owner's own copy is the // case that's unambiguously correct. func (s *Service) CopyGarden(ctx context.Context, actorID, gardenID int64, name string) (*domain.Garden, error) { src, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner) if err != nil { return nil, err } name = strings.TrimSpace(name) if name == "" { name = copyName(src.Name) } if len(name) > maxGardenNameLen { return nil, domain.ErrInvalidInput } created, err := s.store.CopyGarden(ctx, gardenID, actorID, name) if err != nil { return nil, err } created.MyRole = roleOwner.String() // the copier owns the copy return created, nil } // copyName derives the default name for a copy. A name already at the length cap // is trimmed to make room for the suffix, on a rune boundary so the result stays // valid UTF-8. func copyName(src string) string { const suffix = " (copy)" if room := maxGardenNameLen - len(suffix); len(src) > room { src = strings.TrimSpace(strings.ToValidUTF8(src[:room], "")) } return src + suffix } // DeleteGarden removes a garden; only the owner may. func (s *Service) DeleteGarden(ctx context.Context, actorID, gardenID int64) error { if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil { return err } return s.store.DeleteGarden(ctx, gardenID) } // gardenFromInput validates and normalizes input into a domain.Garden (without // identity/ownership fields). With applyDefaults, absent dimensions/unit are // filled in (create); without it, dimensions and unit must be supplied (update). // The grid size is the exception: 0 always means "unset" and is defaulted on both // paths, so an older client that omits it can't wipe the column to an invalid 0. func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error) { name := strings.TrimSpace(in.Name) if name == "" || len(name) > maxGardenNameLen { return nil, domain.ErrInvalidInput } notes := strings.TrimSpace(in.Notes) if len(notes) > maxGardenNotesLen { return nil, domain.ErrInvalidInput } // 0 means "unset": defaulted on create, rejected on update. Anything else // must be a finite length in range — this also rejects negatives, NaN, Inf, // and subnormal-tiny values (which are > 0 but below 1 cm). width, height := in.WidthCM, in.HeightCM if applyDefaults { if width == 0 { width = defaultGardenCM } if height == 0 { height = defaultGardenCM } } if !validDimensionCM(width) || !validDimensionCM(height) { return nil, domain.ErrInvalidInput } unit := in.UnitPref if unit == "" && applyDefaults { unit = domain.UnitMetric } if unit != domain.UnitMetric && unit != domain.UnitImperial { return nil, domain.ErrInvalidInput } // Grid spacing shares the garden dimension range [1 cm, 100 m]. 0 means // "unset" — defaulted (not just on create: an older client that omits it must // not wipe the column to an invalid 0), so a saved garden always has a usable // grid even if snapping is off. grid := in.GridSizeCM if grid == 0 { grid = defaultGardenGridCM } if !validDimensionCM(grid) { return nil, domain.ErrInvalidInput } return &domain.Garden{ Name: name, WidthCM: width, HeightCM: height, UnitPref: unit, Notes: notes, GridSizeCM: grid, SnapToGrid: in.SnapToGrid, }, nil } // validDimensionCM reports whether v is a finite length within the allowed // garden range. Guards against NaN/Inf (which slip past naive < / > comparisons) // and subnormal-tiny positives. func validDimensionCM(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= minGardenCM && v <= maxGardenCM }