Add plantings backend: plop CRUD, derived counts, removed_at (#14)
- domain: Planting gains a computed (non-persisted) DerivedCount field. - store/plantings.go: Get/Create/Update (version-guarded)/Delete alongside the existing /full read helper. - service/plantings.go: place/move/resize/soft-remove a plop; editor role on the object's garden; object must be plantable; plant_id must be visible to the actor (built-in or own) else ErrInvalidInput; center must sit within the object's unrotated local bounds (radius may overhang); planted_at defaults to today. derivedCount = max(1, round(π·r²/spacing²)) — one unit-tested helper, reused by /full (via a spacing map, no N+1) and single responses. - api: POST /objects/:id/plantings, PATCH/DELETE /plantings/:id; nullable count/label/plantedAt/removedAt use RawMessage so null (clear) is distinct from absent (unchanged). removedAt is the soft-remove / "clear bed" seam. - /full now enriches each active plop with its derivedCount. Service tests: formula edge cases (tiny radius → 1), defaults + derived, count override, move/resize + clear override, non-plantable rejection, foreign/unknown plant rejection, bounds, soft-remove leaves /full, version conflict, cross-user masking, delete. Plus an API-level create/patch/full/delete flow. GOWORK=off go build/vet/test ./internal/... green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -2,6 +2,8 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
@@ -54,3 +56,78 @@ func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) (
|
||||
}
|
||||
return plantings, nil
|
||||
}
|
||||
|
||||
// GetPlanting returns the planting with the given id, or domain.ErrNotFound.
|
||||
func (d *DB) GetPlanting(ctx context.Context, id int64) (*domain.Planting, error) {
|
||||
p, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
||||
`SELECT `+plantingColumns+` FROM plantings WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: get planting: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// CreatePlanting inserts a plop (fields already validated by the service) and
|
||||
// returns the stored row. removed_at is always NULL on create — a new plop is
|
||||
// active; "clear bed" sets removed_at later via UpdatePlanting.
|
||||
func (d *DB) CreatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
||||
created, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING `+plantingColumns,
|
||||
p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: insert planting: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// UpdatePlanting applies a version-guarded update of all mutable columns (the
|
||||
// service merges partial patches first). Returns the updated row, or
|
||||
// (current row, ErrVersionConflict) / ErrNotFound — the same contract as the
|
||||
// other mutable resources.
|
||||
func (d *DB) UpdatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
||||
updated, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
||||
`UPDATE plantings
|
||||
SET plant_id = ?, x_cm = ?, y_cm = ?, radius_cm = ?, count = ?, label = ?,
|
||||
planted_at = ?, removed_at = ?,
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ? AND version = ?
|
||||
RETURNING `+plantingColumns,
|
||||
p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt,
|
||||
p.ID, p.Version,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
current, gerr := d.GetPlanting(ctx, p.ID)
|
||||
if gerr != nil {
|
||||
return nil, gerr
|
||||
}
|
||||
return current, domain.ErrVersionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: update planting: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// DeletePlanting hard-deletes a plop (for mistakes; "removed/harvested" flows set
|
||||
// removed_at instead). Returns domain.ErrNotFound if no row was deleted.
|
||||
func (d *DB) DeletePlanting(ctx context.Context, id int64) error {
|
||||
res, err := d.sql.ExecContext(ctx, `DELETE FROM plantings WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete planting: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: planting delete rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user