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:
@@ -163,6 +163,101 @@ func (d *DB) DeleteGarden(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyGarden deep-copies garden srcID into a new garden owned by ownerID and
|
||||
// named name, returning the new row. Objects are copied with their geometry,
|
||||
// grid settings and z-order, and each object's ACTIVE plantings (removed_at IS
|
||||
// NULL) are re-parented onto the copied object.
|
||||
//
|
||||
// Deliberately not copied:
|
||||
// - public_token — the share link is a capability granted to the original, so 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 the original's history.
|
||||
//
|
||||
// The whole copy runs in one transaction, so a failure part-way leaves no
|
||||
// half-populated garden behind. Returns domain.ErrNotFound if srcID is gone.
|
||||
func (d *DB) CopyGarden(ctx context.Context, srcID, ownerID int64, name string) (*domain.Garden, error) {
|
||||
tx, err := d.sql.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: begin copy garden tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
|
||||
|
||||
// INSERT ... SELECT copies the source's own columns; owner and name are
|
||||
// supplied. If the source is gone the SELECT is empty, nothing is inserted,
|
||||
// and RETURNING yields no rows.
|
||||
created, err := scanGarden(tx.QueryRowContext(ctx,
|
||||
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid)
|
||||
SELECT ?, ?, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid
|
||||
FROM gardens WHERE id = ?
|
||||
RETURNING `+gardenColumns,
|
||||
ownerID, name, srcID,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: copy garden: %w", err)
|
||||
}
|
||||
|
||||
// Read the source's objects fully before inserting: SQLite can't have an open
|
||||
// cursor and a write on the same connection, and the pool is pinned to one
|
||||
// connection for in-memory DBs.
|
||||
srcObjects, err := queryObjects(ctx, tx,
|
||||
`SELECT `+objectColumns+` FROM garden_objects WHERE garden_id = ? ORDER BY id`, srcID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// objectIDs maps a source object id to its copy, so plantings can be
|
||||
// re-parented onto the right new object.
|
||||
objectIDs := make(map[int64]int64, len(srcObjects))
|
||||
for _, o := range srcObjects {
|
||||
copied, err := scanObject(tx.QueryRowContext(ctx,
|
||||
`INSERT INTO garden_objects
|
||||
(garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
||||
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING `+objectColumns,
|
||||
created.ID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
|
||||
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: copy object: %w", err)
|
||||
}
|
||||
objectIDs[o.ID] = copied.ID
|
||||
}
|
||||
|
||||
srcPlantings, err := queryPlantings(ctx, tx,
|
||||
`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`, srcID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range srcPlantings {
|
||||
objectID, ok := objectIDs[p.ObjectID]
|
||||
if !ok {
|
||||
// Unreachable: every active planting joins an object we just copied.
|
||||
return nil, fmt.Errorf("store: copy planting %d: source object %d not copied", p.ID, p.ObjectID)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
objectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: copy planting: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("store: commit garden copy: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// GetGardenByPublicToken returns the garden whose public_token matches, or
|
||||
// domain.ErrNotFound. Possession of the token is the capability, so there is no
|
||||
// owner/actor check here — the service exposes this only for the unauthenticated
|
||||
|
||||
@@ -66,10 +66,17 @@ func (d *DB) GetObject(ctx context.Context, id int64) (*domain.GardenObject, err
|
||||
// ListObjectsForGarden returns a garden's objects, bottom-to-top (z_index then
|
||||
// id). Always a non-nil slice.
|
||||
func (d *DB) ListObjectsForGarden(ctx context.Context, gardenID int64) ([]domain.GardenObject, error) {
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
return queryObjects(ctx, d.sql,
|
||||
`SELECT `+objectColumns+` FROM garden_objects WHERE garden_id = ? ORDER BY z_index, id`,
|
||||
gardenID,
|
||||
)
|
||||
gardenID)
|
||||
}
|
||||
|
||||
// queryObjects runs an object query and scans every row. The result set is fully
|
||||
// drained and the cursor closed before returning, so a caller inside a
|
||||
// transaction may write on the same connection afterwards. Always a non-nil
|
||||
// slice.
|
||||
func queryObjects(ctx context.Context, q queryer, query string, args ...any) ([]domain.GardenObject, error) {
|
||||
rows, err := q.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list objects: %w", err)
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -19,6 +19,12 @@ type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// queryer is the read surface shared by *sql.DB and *sql.Tx, so a list helper can
|
||||
// serve both a standalone read and one inside a transaction.
|
||||
type queryer interface {
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
// scanUser reads one users row. is_admin is stored as INTEGER 0/1 (the driver
|
||||
// returns it as int64, which does not convert straight to bool), so it is read
|
||||
// into an int64 and mapped.
|
||||
|
||||
Reference in New Issue
Block a user