package service import ( "context" "strings" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // Garden sizing bounds. A new garden with no dimensions defaults to a 10 m // square; the max is a generous sanity cap (100 m) that also guards against // absurd or overflow-y values. const ( defaultGardenCM = 1000 maxGardenCM = 100_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 ) // 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 } // 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 := effectiveGardenRole(actorID, g) if role == roleNone { return nil, domain.ErrNotFound } if role < min { return nil, domain.ErrForbidden } return g, nil } // effectiveGardenRole is the actor's role on a garden. Owner is implicit via // gardens.owner_id; share-based viewer/editor roles are added in #16. func effectiveGardenRole(actorID int64, g *domain.Garden) gardenRole { if g.OwnerID == actorID { return roleOwner } return roleNone } // 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 return s.store.CreateGarden(ctx, g) } // 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-only until #16. func (s *Service) ListGardens(ctx context.Context, actorID int64) ([]domain.Garden, error) { return s.store.ListGardensForOwner(ctx, actorID) } // UpdateGarden applies a version-guarded update; the actor must be at least an // editor. 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) { if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); 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 return s.store.UpdateGarden(ctx, g) } // 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, every field must be supplied (update). func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error) { name := strings.TrimSpace(in.Name) if name == "" { return nil, domain.ErrInvalidInput } // 0 means "unset": defaulted on create, rejected on update. A negative value // is always an explicit error (never silently corrected to the default). width, height := in.WidthCM, in.HeightCM if applyDefaults { if width == 0 { width = defaultGardenCM } if height == 0 { height = defaultGardenCM } } if width <= 0 || height <= 0 || width > maxGardenCM || height > maxGardenCM { return nil, domain.ErrInvalidInput } unit := in.UnitPref if unit == "" && applyDefaults { unit = domain.UnitMetric } if unit != domain.UnitMetric && unit != domain.UnitImperial { return nil, domain.ErrInvalidInput } return &domain.Garden{ Name: name, WidthCM: width, HeightCM: height, UnitPref: unit, Notes: strings.TrimSpace(in.Notes), }, nil }