Files
pansy/internal/store/plantings.go
T
steveandClaude Opus 4.8 2f3699f7fa
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 6m56s
Adversarial Review (Gadfly) / review (pull_request) Successful in 6m56s
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
2026-07-18 22:26:15 -04:00

134 lines
4.5 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// plantingColumns lists plantings columns in the order scanPlanting expects.
// Used unqualified for direct selects; the /full read below qualifies with pl.
const plantingColumns = `id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label,
planted_at, removed_at, version, created_at, updated_at`
func scanPlanting(s scanner) (*domain.Planting, error) {
var p domain.Planting
if err := s.Scan(
&p.ID, &p.ObjectID, &p.PlantID, &p.XCM, &p.YCM, &p.RadiusCM,
&p.Count, &p.Label, &p.PlantedAt, &p.RemovedAt,
&p.Version, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
return &p, nil
}
// ListActivePlantingsForGarden returns every currently-planted plop (removed_at
// IS NULL) across all objects in a garden — the editor's one-shot load. Always a
// non-nil slice (empty until plantings CRUD lands in #14). Plop CRUD itself is
// #14; this is only the /full read side.
func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) ([]domain.Planting, error) {
rows, err := d.sql.QueryContext(ctx,
`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,
)
if err != nil {
return nil, fmt.Errorf("store: list 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
}
// 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
}