Files
pansy/internal/api/ops.go
T
steveandClaude Opus 4.8 3cfa72cb25
Build image / build-and-push (push) Successful in 18s
Gadfly review (reusable) / review (pull_request) Successful in 5m18s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m19s
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
2026-07-21 18:20:54 -04:00

97 lines
3.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.
// 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})
}