Garden objects (beds, bags, containers, in-ground, trees, paths, structures) in one polymorphic table, following the #7 service conventions. - store/objects.go: scanObject + Create (RETURNING), Get, ListForGarden (z_index order), version-guarded Update (RETURNING, returns current row on conflict), Delete (plantings cascade via FK). - store/plantings.go + plants.go: the read side /full needs now — ListActivePlantingsForGarden (removed_at IS NULL) and ListReferencedPlants (distinct plants used by active plantings). Both return empty until #14 adds plantings; #12/#14 extend these files. - service/objects.go: Create/Update(partial patch)/Delete/GardenFull, all (ctx, actorID, ...) through requireGardenRole(editor for mutations, viewer for /full). finalizeObject validates kind + shape (rect/circle; polygon reserved), finite dims in [1cm,100m], NaN/Inf rejection, loose bbox-overlaps-garden placement (partial overhang OK), hex color, valid JSON props, name/notes caps; normalizes rotation to [0,360). Plantable defaults by kind, overridable. - api/objects.go: POST /gardens/:id/objects, PATCH/DELETE /objects/:id, GET /gardens/:id/full ({garden,objects,plantings,plants}). props is any JSON value stored as text; PATCH is partial + required version, 409 returns the current row. Tests: service (defaults, plantable-by-kind, rotation normalize, validation rejects incl. off-field/polygon/bad-color/bad-props, partial patch + version conflict, cross-user ErrNotFound, delete, /full shape) and api (CRUD + /full, 409 envelope, cross-user 404, polygon/no-kind 400, props round-trip). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
56 lines
1.7 KiB
Go
56 lines
1.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// scanPlant reads a plants row in table-declared column order (id, owner_id,
|
|
// name, category, spacing_cm, color, icon, days_to_maturity, notes, version,
|
|
// created_at, updated_at) — the order SELECT p.* yields.
|
|
func scanPlant(s scanner) (*domain.Plant, error) {
|
|
var p domain.Plant
|
|
if err := s.Scan(
|
|
&p.ID, &p.OwnerID, &p.Name, &p.Category, &p.SpacingCM, &p.Color, &p.Icon,
|
|
&p.DaysToMaturity, &p.Notes, &p.Version, &p.CreatedAt, &p.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// ListReferencedPlants returns the distinct plants used by a garden's active
|
|
// plantings — the catalog subset the editor needs to render them. Always a
|
|
// non-nil slice (empty until plantings exist, #14). Full plant CRUD/seeding is
|
|
// #12; this is only the /full read side.
|
|
func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64) ([]domain.Plant, error) {
|
|
// p.* returns the plants columns in table-declared order, matching scanPlant.
|
|
rows, err := d.sql.QueryContext(ctx,
|
|
`SELECT DISTINCT p.* FROM plants p
|
|
JOIN plantings pl ON pl.plant_id = p.id
|
|
JOIN garden_objects o ON o.id = pl.object_id
|
|
WHERE o.garden_id = ? AND pl.removed_at IS NULL
|
|
ORDER BY p.id`,
|
|
gardenID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list referenced plants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
plants := []domain.Plant{}
|
|
for rows.Next() {
|
|
p, err := scanPlant(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: scan plant: %w", err)
|
|
}
|
|
plants = append(plants, *p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("store: iterate plants: %w", err)
|
|
}
|
|
return plants, nil
|
|
}
|