Copy a garden (deep duplicate) from the gardens list
Build image / build-and-push (push) Successful in 23s
Gadfly review (reusable) / review (pull_request) Successful in 6m30s
Adversarial Review (Gadfly) / review (pull_request) Successful in 6m30s

Adds POST /gardens/:id/copy: duplicates a garden the actor owns, along
with its objects and their currently-planted plops, into a new garden
owned by the actor. A blank name derives "<source> (copy)".

Deliberately not carried over:
  * public_token — the share link is a capability granted to the
    original; a copy must not silently inherit a live public URL
  * garden_shares — a copy is private to whoever made it
  * removed plantings — the copy is a fresh layout, not a history

Owner-only for now: a copy's plops keep pointing at the SOURCE's
plant_ids, so letting a viewer copy someone else's garden would hand
them plants outside their catalog and pin the original owner's plants
against deletion (plantings.plant_id is ON DELETE RESTRICT). Copying a
shared garden needs a plant-cloning policy first.

The whole copy runs in one transaction, so a failure part-way leaves no
half-populated garden. The object/planting list scans are factored into
queryObjects/queryPlantings so the same code serves a plain read and a
read inside that transaction.

UI: a Copy action on the owner's garden card opens a modal prefilled
with the derived name, then lands in the new garden.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-20 23:49:50 -04:00
co-authored by Claude Opus 4.8
parent e74fb308c1
commit 5dd35c3800
14 changed files with 632 additions and 29 deletions
+28
View File
@@ -103,6 +103,34 @@ func (h *handlers) updateGarden(c *gin.Context) {
c.JSON(http.StatusOK, g)
}
// gardenCopyRequest is the (optional) body of POST /gardens/:id/copy. A blank or
// absent name lets the service derive "<source> (copy)".
type gardenCopyRequest struct {
Name string `json:"name"`
}
func (h *handlers) copyGarden(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
// The body is optional — POST with no body means "copy under the default
// name" — so only decode when one was actually sent.
var req gardenCopyRequest
if c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "name must be a string")
return
}
}
g, err := h.svc.CopyGarden(c.Request.Context(), mustActor(c).ID, id, req.Name)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusCreated, g)
}
func (h *handlers) deleteGarden(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {