Copy a garden (deep duplicate) from the gardens list
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:
+11
-23
@@ -30,13 +30,19 @@ func scanPlanting(s scanner) (*domain.Planting, error) {
|
||||
// IS NULL) across all objects in a garden — the editor's one-shot load. Always a
|
||||
// non-nil slice. The service fills each row's DerivedCount; this is the raw read.
|
||||
func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) ([]domain.Planting, error) {
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
return queryPlantings(ctx, d.sql,
|
||||
`SELECT `+qualifyColumns("pl", plantingColumns)+` FROM plantings pl
|
||||
JOIN garden_objects o ON o.id = pl.object_id
|
||||
WHERE o.garden_id = ? AND pl.removed_at IS NULL
|
||||
ORDER BY pl.id`,
|
||||
gardenID,
|
||||
)
|
||||
gardenID)
|
||||
}
|
||||
|
||||
// queryPlantings runs a planting query and scans every row. Like queryObjects it
|
||||
// drains and closes the cursor before returning, so a transaction caller may
|
||||
// write afterwards. Always a non-nil slice.
|
||||
func queryPlantings(ctx context.Context, q queryer, query string, args ...any) ([]domain.Planting, error) {
|
||||
rows, err := q.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list plantings: %w", err)
|
||||
}
|
||||
@@ -60,27 +66,9 @@ func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) (
|
||||
// (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid
|
||||
// stacking new plops inside existing ones.
|
||||
func (d *DB) ListActivePlantingsForObject(ctx context.Context, objectID int64) ([]domain.Planting, error) {
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
return queryPlantings(ctx, d.sql,
|
||||
`SELECT `+plantingColumns+` FROM plantings WHERE object_id = ? AND removed_at IS NULL ORDER BY id`,
|
||||
objectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list object plantings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
plantings := []domain.Planting{}
|
||||
for rows.Next() {
|
||||
p, err := scanPlanting(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan planting: %w", err)
|
||||
}
|
||||
plantings = append(plantings, *p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate plantings: %w", err)
|
||||
}
|
||||
return plantings, nil
|
||||
objectID)
|
||||
}
|
||||
|
||||
// ClearObjectPlantings soft-removes every active plop in an object in one UPDATE
|
||||
|
||||
Reference in New Issue
Block a user