Build image / build-and-push (push) Successful in 16s
- Rename fillRequest → objectFillRequest to match the package's
<resource><Action>Request convention (objectCreateRequest, gardenCreateRequest…).
- Add the missing anonymous-clear permission case (401), mirroring anonymous fill.
- Reword the makeFillPlant comment to stop referencing external branch state,
which won't make sense once merged — just note the consolidation follow-up.
- clearObject: send no body (undefined) rather than an empty {}, and validate
the {cleared} response with a zod schema instead of a raw type cast.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
117 lines
4.3 KiB
Go
117 lines
4.3 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
|
)
|
|
|
|
// Bulk operations on a plantable object (#82): fill a region with one plant, and
|
|
// clear everything out of it.
|
|
//
|
|
// These were reachable only through the agent toolbox until now, which meant the
|
|
// most valuable bulk operation in a garden planner — and the one carrying the
|
|
// most carefully reasoned geometry in the codebase — did not exist at all on an
|
|
// instance with no model configured. They are thin adapters over the same
|
|
// service methods `internal/agent/tools.go` calls, so the permission checks and
|
|
// the one-change-set-per-operation guarantee come along unchanged.
|
|
|
|
// fillRect is an explicit rectangle in the object's local frame, the alternative
|
|
// to a compass name. A named type (not an inline anonymous struct) to match the
|
|
// rest of internal/api and so it can carry its own validity check.
|
|
type fillRect struct {
|
|
MinX float64 `json:"minXCm"`
|
|
MinY float64 `json:"minYCm"`
|
|
MaxX float64 `json:"maxXCm"`
|
|
MaxY float64 `json:"maxYCm"`
|
|
}
|
|
|
|
// degenerate reports whether the rect encloses no area. Such a rect (including
|
|
// the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through
|
|
// and plant a single plop at the object's centre — a surprising result for what
|
|
// is really malformed input.
|
|
func (r fillRect) degenerate() bool {
|
|
return r.MaxX <= r.MinX || r.MaxY <= r.MinY
|
|
}
|
|
|
|
// objectFillRequest is the body for POST /objects/:id/fill.
|
|
//
|
|
// A region is given EITHER by compass name ("ne", "south half", "all") or as an
|
|
// explicit rect in the object's local frame. The named form is what a person
|
|
// means and what the agent uses; the rect is for a future drag-a-box affordance.
|
|
// Exactly one must be supplied — accepting both and silently preferring one
|
|
// would make a client bug look like a geometry bug.
|
|
type objectFillRequest struct {
|
|
PlantID int64 `json:"plantId" binding:"required"`
|
|
Region string `json:"region"`
|
|
Rect *fillRect `json:"rect"`
|
|
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing
|
|
// without editing the catalog entry.
|
|
SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
|
|
}
|
|
|
|
func (h *handlers) fillObject(c *gin.Context) {
|
|
id, ok := parseIDParam(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req objectFillRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required")
|
|
return
|
|
}
|
|
named, hasRect := req.Region != "", req.Rect != nil
|
|
if named == hasRect {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT",
|
|
`supply exactly one of "region" (e.g. "all", "ne", "south half") or "rect"`)
|
|
return
|
|
}
|
|
|
|
actor := mustActor(c).ID
|
|
var (
|
|
created []domain.Planting
|
|
err error
|
|
)
|
|
if rect := req.Rect; rect != nil {
|
|
// Reject a zero-area rect here rather than let it plant one stray plop.
|
|
// (Binding the pointer to `rect` also keeps the deref visibly guarded,
|
|
// instead of reading req.Rect.MinX under an invariant from a line above.)
|
|
if rect.degenerate() {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area")
|
|
return
|
|
}
|
|
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
|
|
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM)
|
|
} else {
|
|
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM)
|
|
}
|
|
if err != nil {
|
|
writeServiceError(c, err)
|
|
return
|
|
}
|
|
// 200, not 201: a fill can legitimately create nothing (the region is already
|
|
// planted), and there is no single resource to point a Location at.
|
|
c.JSON(http.StatusOK, gin.H{"plantings": created, "created": len(created)})
|
|
}
|
|
|
|
// clearObject soft-removes every active plop in an object.
|
|
//
|
|
// Distinct from deleting the object, and — unlike the client-side loop this
|
|
// replaces — it lands as ONE change set, so undoing a cleared bed is one click
|
|
// rather than one per plop.
|
|
func (h *handlers) clearObject(c *gin.Context) {
|
|
id, ok := parseIDParam(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
n, err := h.svc.ClearObject(c.Request.Context(), mustActor(c).ID, id)
|
|
if err != nil {
|
|
writeServiceError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"cleared": n})
|
|
}
|