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 is the set of valid garden_objects.kind values. var objectKinds = map[string]bool{ domain.KindBed: true, domain.KindGrowBag: true, domain.KindContainer: true, domain.KindInGround: true, domain.KindTree: true, domain.KindPath: true, domain.KindStructure: true, } // plantableByDefault: beds/bags/containers/in-ground hold plants; trees, paths, // and structures don't. Overridable per object. var plantableByDefault = map[string]bool{ domain.KindBed: true, domain.KindGrowBag: true, domain.KindContainer: true, domain.KindInGround: true, domain.KindTree: false, domain.KindPath: false, domain.KindStructure: 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. type ObjectPatch struct { Name *string XCM *float64 YCM *float64 WidthCM *float64 HeightCM *float64 RotationDeg *float64 ZIndex *int Plantable *bool Color *string Props *string Notes *string } // FullGarden is the one-shot editor payload: the garden plus everything drawn in // it. Plantings/Plants stay empty until #14 populates plantings; the shape is // fixed now so the frontend types against it once. 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 } if !objectKinds[in.Kind] { return nil, domain.ErrInvalidInput } shape := in.Shape if shape == "" { shape = domain.ShapeRect } plantable := plantableByDefault[in.Kind] 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) } // 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, err := s.store.GetObject(ctx, objectID) if err != nil { return nil, err // ErrNotFound } g, err := s.requireGardenRole(ctx, actorID, o.GardenID, 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 { o, err := s.store.GetObject(ctx, objectID) if err != nil { return err } if _, err := s.requireGardenRole(ctx, actorID, o.GardenID, 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 } objects, err := s.store.ListObjectsForGarden(ctx, gardenID) if err != nil { return nil, err } plantings, err := s.store.ListActivePlantingsForGarden(ctx, gardenID) if err != nil { return nil, err } plants, err := s.store.ListReferencedPlants(ctx, gardenID) if err != nil { return nil, err } 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 } if p.Color != nil { o.Color = p.Color } if p.Props != nil { 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 !objectKinds[o.Kind] { 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 }