REST fill/clear on objects; clear-bed becomes one change set (#82)
FillRegion and ClearObject were reachable only through the agent toolbox, so
on an instance with no model configured 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. internal/service/ops.go said these lived
on *Service so "any future REST surface" would inherit the ACL checks; this is
that surface.
POST /objects/:id/fill takes a region EITHER by compass name ("all", "ne",
"south half") or as an explicit rect in the object's local frame, and refuses
both-or-neither: silently preferring one would make a client bug look like a
geometry bug. It answers 200 rather than 201 because a fill can legitimately
create nothing (the region is already planted) and there is no single resource
to point a Location at.
POST /objects/:id/clear replaces a client-side loop of PATCHes. That loop
wrote one change set per plop, so clearing a 40-plop bed took 40 presses of
Undo to put back — while the agent's clear_object, for the identical
user-facing action, undid in one click. CLAUDE.md states the rule it violated:
multi-row operations record together so they undo as one unit. Moving it
server-side also removes the partial-failure case the loop had to reconcile.
TestClearObjectIsOneChangeSetAPI pins that: fill a bed, count change sets,
clear it, assert the count rose by exactly one.
DESIGN.md's API block gains the two new routes, and the four it was already
missing: GET/POST/DELETE /gardens/:id/share-link and the unauthenticated
GET /public/gardens/:token. An unauthenticated surface absent from the
architecture doc is the one worth fixing on sight.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
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.
|
||||
|
||||
// fillRequest 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 fillRequest struct {
|
||||
PlantID int64 `json:"plantId" binding:"required"`
|
||||
Region string `json:"region"`
|
||||
Rect *struct {
|
||||
MinX float64 `json:"minXCm"`
|
||||
MinY float64 `json:"minYCm"`
|
||||
MaxX float64 `json:"maxXCm"`
|
||||
MaxY float64 `json:"maxYCm"`
|
||||
} `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 fillRequest
|
||||
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 named {
|
||||
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM)
|
||||
} else {
|
||||
region := service.Region{MinX: req.Rect.MinX, MinY: req.Rect.MinY, MaxX: req.Rect.MaxX, MaxY: req.Rect.MaxY}
|
||||
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, 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})
|
||||
}
|
||||
Reference in New Issue
Block a user