Build image / build-and-push (push) Successful in 17s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
417 lines
13 KiB
Go
417 lines
13 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
|
||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||
)
|
||
|
||
const (
|
||
maxObjectNameLen = 200
|
||
maxObjectNotesLen = 10_000
|
||
maxObjectPropsLen = 20_000
|
||
defaultObjectGridCM = 30 // ~1 ft, a practical bed cell for plant snapping
|
||
)
|
||
|
||
// 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
|
||
GridSizeCM float64
|
||
SnapToGrid bool
|
||
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
|
||
GridSizeCM *float64
|
||
SnapToGrid *bool
|
||
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,
|
||
GridSizeCM: in.GridSizeCM,
|
||
SnapToGrid: in.SnapToGrid,
|
||
Notes: strings.TrimSpace(in.Notes),
|
||
}
|
||
if err := finalizeObject(o, g); err != nil {
|
||
return nil, err
|
||
}
|
||
created, err := s.store.CreateObject(ctx, o)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.record(ctx, gardenID, actorID, "Added "+objectLabel(created),
|
||
changeCreate(domain.EntityObject, created.ID, created))
|
||
return created, nil
|
||
}
|
||
|
||
// objectLabel names an object for a change-set summary: its own name when it has
|
||
// one, else its kind ("bed", "grow_bag"), so history reads as "Added bed" rather
|
||
// than "Added ".
|
||
func objectLabel(o *domain.GardenObject) string {
|
||
if o.Name != "" {
|
||
return o.Name
|
||
}
|
||
return o.Kind
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
before := *o // objectForRole returns the current row, and the patch mutates it
|
||
applyObjectPatch(o, patch)
|
||
if err := finalizeObject(o, g); err != nil {
|
||
return nil, err
|
||
}
|
||
o.Version = version
|
||
updated, err := s.store.UpdateObject(ctx, o)
|
||
if err != nil {
|
||
return updated, err // may carry the current row on a version conflict
|
||
}
|
||
s.record(ctx, g.ID, actorID, "Edited "+objectLabel(updated),
|
||
changeUpdate(domain.EntityObject, updated.ID, &before, updated))
|
||
return updated, nil
|
||
}
|
||
|
||
// 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, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// deleteObjectRecording snapshots the plantings the FK is about to cascade
|
||
// away, so the delete is actually revertible rather than merely listed.
|
||
changes, err := s.deleteObjectRecording(ctx, o)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
s.record(ctx, g.ID, actorID, "Deleted "+objectLabel(o), changes...)
|
||
return nil
|
||
}
|
||
|
||
// GardenFull returns the whole editor payload for a garden the actor can view.
|
||
// year nil means "what's planted now"; a year means the season view (#54).
|
||
func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64, year *int) (*FullGarden, error) {
|
||
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if year != nil && (*year < minSeasonYear || *year > maxSeasonYear) {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
return s.assembleFullFor(ctx, g, year)
|
||
}
|
||
|
||
// Seasons are bounded to keep a typo'd year from producing a confidently empty
|
||
// garden; the range is wide enough to cover any real record.
|
||
const (
|
||
minSeasonYear = 1900
|
||
maxSeasonYear = 2200
|
||
)
|
||
|
||
// GardenYears lists the calendar years a garden has planting data for, newest
|
||
// first, always including the current one.
|
||
//
|
||
// This exists so the year selector can offer only years that actually hold
|
||
// something. A free numeric field invites a typo and an empty view that looks
|
||
// like data loss rather than a mistake.
|
||
func (s *Service) GardenYears(ctx context.Context, actorID, gardenID int64) ([]int, error) {
|
||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
|
||
return nil, err
|
||
}
|
||
years, err := s.store.GardenPlantingYears(ctx, gardenID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
current := s.now().UTC().Year()
|
||
for _, y := range years {
|
||
if y == current {
|
||
return years, nil
|
||
}
|
||
}
|
||
// Newest first, and this year is newest unless the data runs into the future.
|
||
out := append([]int{current}, years...)
|
||
sort.Sort(sort.Reverse(sort.IntSlice(out)))
|
||
return out, nil
|
||
}
|
||
|
||
// assembleFull builds the read-only /full payload for an already-authorized
|
||
// garden as it stands NOW. Shared by the unauthenticated PublicGarden read,
|
||
// which never offers a season view.
|
||
func (s *Service) assembleFull(ctx context.Context, g *domain.Garden) (*FullGarden, error) {
|
||
return s.assembleFullFor(ctx, g, nil)
|
||
}
|
||
|
||
// assembleFullFor builds the /full payload: objects, plops (with DerivedCount
|
||
// filled in), and the plants those plops reference.
|
||
//
|
||
// year nil is today's behaviour — active plops only. A year widens it to every
|
||
// plop whose time in the ground overlapped that year, which necessarily includes
|
||
// plops since removed, so the referenced-plant lookup has to widen with it or
|
||
// those plops would render with no icon and no colour.
|
||
func (s *Service) assembleFullFor(ctx context.Context, g *domain.Garden, year *int) (*FullGarden, error) {
|
||
objects, err := s.store.ListObjectsForGarden(ctx, g.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var plantings []domain.Planting
|
||
if year != nil {
|
||
plantings, err = s.store.ListPlantingsForGardenYear(ctx, g.ID, *year)
|
||
} else {
|
||
plantings, err = s.store.ListActivePlantingsForGarden(ctx, g.ID)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
plants, err := s.store.ListReferencedPlants(ctx, g.ID, year)
|
||
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.GridSizeCM != nil {
|
||
o.GridSizeCM = *p.GridSizeCM
|
||
}
|
||
if p.SnapToGrid != nil {
|
||
o.SnapToGrid = *p.SnapToGrid
|
||
}
|
||
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
|
||
}
|
||
// Grid spacing shares the dimension range [1 cm, 100 m]; 0 means "unset" and
|
||
// is defaulted so a create (or an older client omitting it) still lands a
|
||
// usable bed grid.
|
||
if o.GridSizeCM == 0 {
|
||
o.GridSizeCM = defaultObjectGridCM
|
||
}
|
||
if !validDimensionCM(o.GridSizeCM) {
|
||
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
|
||
}
|