package service import ( "context" "encoding/json" "math" "strings" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) const ( maxObjectNameLen = 200 maxObjectNotesLen = 10_000 maxObjectPropsLen = 20_000 ) // objectKinds maps each valid garden_objects.kind to its traits. plantable is // the default (beds/bags/containers/in-ground hold plants; trees/paths/ // structures don't) and is overridable per object. var objectKinds = map[string]struct{ plantable bool }{ domain.KindBed: {plantable: true}, domain.KindGrowBag: {plantable: true}, domain.KindContainer: {plantable: true}, domain.KindInGround: {plantable: true}, domain.KindTree: {plantable: false}, domain.KindPath: {plantable: false}, domain.KindStructure: {plantable: false}, } // ObjectInput is the payload for creating a garden object. Plantable nil means // "default for this kind". type ObjectInput struct { Kind string Name string Shape string XCM float64 YCM float64 WidthCM float64 HeightCM float64 RotationDeg float64 ZIndex int Plantable *bool Color *string Props *string Notes string } // ObjectPatch is a partial update: every field is optional (nil = leave as-is). // Kind and shape are intentionally immutable after creation. // // Color and Props are nullable in the DB, so they use an explicit "set" flag to // tell "clear to NULL" (Set*=true, value nil) apart from "leave unchanged" // (Set*=false) — a plain nil pointer can't express both. type ObjectPatch struct { Name *string XCM *float64 YCM *float64 WidthCM *float64 HeightCM *float64 RotationDeg *float64 ZIndex *int Plantable *bool SetColor bool Color *string SetProps bool Props *string Notes *string } // FullGarden is the one-shot editor payload: the garden plus everything drawn in // it — objects, active plantings (each with its DerivedCount filled in), and the // plants those plantings reference. type FullGarden struct { Garden *domain.Garden `json:"garden"` Objects []domain.GardenObject `json:"objects"` Plantings []domain.Planting `json:"plantings"` Plants []domain.Plant `json:"plants"` } // CreateObject adds an object to a garden the actor can edit. func (s *Service) CreateObject(ctx context.Context, actorID, gardenID int64, in ObjectInput) (*domain.GardenObject, error) { g, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor) if err != nil { return nil, err } // finalizeObject is the single validation point (it rejects an unknown kind), // so an unknown kind here just yields plantable=false before being rejected. shape := in.Shape if shape == "" { shape = domain.ShapeRect } plantable := objectKinds[in.Kind].plantable if in.Plantable != nil { plantable = *in.Plantable } o := &domain.GardenObject{ GardenID: gardenID, Kind: in.Kind, Name: strings.TrimSpace(in.Name), Shape: shape, XCM: in.XCM, YCM: in.YCM, WidthCM: in.WidthCM, HeightCM: in.HeightCM, RotationDeg: in.RotationDeg, ZIndex: in.ZIndex, Plantable: plantable, Color: in.Color, Props: in.Props, Notes: strings.TrimSpace(in.Notes), } if err := finalizeObject(o, g); err != nil { return nil, err } return s.store.CreateObject(ctx, o) } // objectForRole loads an object and enforces that the actor holds at least min // on its owning garden, returning both. A missing object — or one in a garden // the actor can't see — is ErrNotFound (existence masked, same as gardens). func (s *Service) objectForRole(ctx context.Context, actorID, objectID int64, min gardenRole) (*domain.GardenObject, *domain.Garden, error) { o, err := s.store.GetObject(ctx, objectID) if err != nil { return nil, nil, err // ErrNotFound } g, err := s.requireGardenRole(ctx, actorID, o.GardenID, min) if err != nil { return nil, nil, err } return o, g, nil } // UpdateObject applies a partial, version-guarded patch to an object in a garden // the actor can edit. On a version mismatch it returns (current, ErrVersionConflict). func (s *Service) UpdateObject(ctx context.Context, actorID, objectID int64, patch ObjectPatch, version int64) (*domain.GardenObject, error) { o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor) if err != nil { return nil, err } applyObjectPatch(o, patch) if err := finalizeObject(o, g); err != nil { return nil, err } o.Version = version return s.store.UpdateObject(ctx, o) } // DeleteObject removes an object (its plantings cascade) from a garden the actor // can edit. func (s *Service) DeleteObject(ctx context.Context, actorID, objectID int64) error { if _, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor); err != nil { return err } return s.store.DeleteObject(ctx, objectID) } // GardenFull returns the whole editor payload for a garden the actor can view. func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64) (*FullGarden, error) { g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer) if err != nil { return nil, err } return s.assembleFull(ctx, g) } // assembleFull builds the read-only /full payload for an already-authorized // garden: its objects, active plops (with DerivedCount filled in), and the // plants those plops reference. Shared by the authenticated GardenFull and the // unauthenticated PublicGarden read, so both return the identical shape. func (s *Service) assembleFull(ctx context.Context, g *domain.Garden) (*FullGarden, error) { objects, err := s.store.ListObjectsForGarden(ctx, g.ID) if err != nil { return nil, err } plantings, err := s.store.ListActivePlantingsForGarden(ctx, g.ID) if err != nil { return nil, err } plants, err := s.store.ListReferencedPlants(ctx, g.ID) if err != nil { return nil, err } // Fill each plop's DerivedCount from its plant's spacing (referenced plants // are already loaded, so this is a map lookup rather than N queries). spacing := make(map[int64]float64, len(plants)) for _, pl := range plants { spacing[pl.ID] = pl.SpacingCM } for i := range plantings { plantings[i].DerivedCount = derivedCount(plantings[i].RadiusCM, spacing[plantings[i].PlantID]) } return &FullGarden{Garden: g, Objects: objects, Plantings: plantings, Plants: plants}, nil } // applyObjectPatch mutates o with each provided (non-nil) patch field. func applyObjectPatch(o *domain.GardenObject, p ObjectPatch) { if p.Name != nil { o.Name = strings.TrimSpace(*p.Name) } if p.XCM != nil { o.XCM = *p.XCM } if p.YCM != nil { o.YCM = *p.YCM } if p.WidthCM != nil { o.WidthCM = *p.WidthCM } if p.HeightCM != nil { o.HeightCM = *p.HeightCM } if p.RotationDeg != nil { o.RotationDeg = *p.RotationDeg } if p.ZIndex != nil { o.ZIndex = *p.ZIndex } if p.Plantable != nil { o.Plantable = *p.Plantable } // Color/Props: Set* distinguishes "clear to NULL" (value nil) from unchanged. if p.SetColor { o.Color = p.Color } if p.SetProps { o.Props = p.Props } if p.Notes != nil { o.Notes = strings.TrimSpace(*p.Notes) } } // finalizeObject normalizes (rotation → [0,360)) and validates a built/merged // object against its garden. Shared by create and update so both enforce the // same invariants. func finalizeObject(o *domain.GardenObject, g *domain.Garden) error { if _, ok := objectKinds[o.Kind]; !ok { return domain.ErrInvalidInput } // rect/circle only; polygon is reserved in the schema but not supported yet. if o.Shape != domain.ShapeRect && o.Shape != domain.ShapeCircle { return domain.ErrInvalidInput } if len(o.Name) > maxObjectNameLen || len(o.Notes) > maxObjectNotesLen { return domain.ErrInvalidInput } if !validDimensionCM(o.WidthCM) || !validDimensionCM(o.HeightCM) { return domain.ErrInvalidInput } if !isFinite(o.XCM) || !isFinite(o.YCM) || !isFinite(o.RotationDeg) { return domain.ErrInvalidInput } o.RotationDeg = normalizeDegrees(o.RotationDeg) // Loose placement check: the (unrotated) bounding box must overlap the garden // at all — partial overhang is fine, fully off-field is not. if !bboxOverlapsGarden(o, g) { return domain.ErrInvalidInput } if o.Color != nil && !isHexColor(*o.Color) { return domain.ErrInvalidInput } if o.Props != nil { if len(*o.Props) > maxObjectPropsLen || !json.Valid([]byte(*o.Props)) { return domain.ErrInvalidInput } } return nil } func isFinite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) } // normalizeDegrees folds an angle into [0, 360). func normalizeDegrees(d float64) float64 { d = math.Mod(d, 360) if d < 0 { d += 360 } return d } // bboxOverlapsGarden reports whether the object's axis-aligned (unrotated) // bounding box intersects the garden rectangle [0,width]×[0,height]. func bboxOverlapsGarden(o *domain.GardenObject, g *domain.Garden) bool { halfW, halfH := o.WidthCM/2, o.HeightCM/2 left, right := o.XCM-halfW, o.XCM+halfW top, bottom := o.YCM-halfH, o.YCM+halfH return left < g.WidthCM && right > 0 && top < g.HeightCM && bottom > 0 } // isHexColor accepts #rgb or #rrggbb (case-insensitive). func isHexColor(s string) bool { if len(s) != 4 && len(s) != 7 { return false } if s[0] != '#' { return false } for _, c := range s[1:] { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } return true }