"German Red Garlic" now links back to the Johnny's page it came from, and pansy knows how much is left. Two modelling calls, both of which look like omissions unless stated. The plant catalog stays FLAT — no species/variety hierarchy. A row in your catalog just is the thing you plant; the built-in "Garlic" is a generic starting point you clone. A hierarchy buys taxonomy nobody asked for and complicates every join. Inventory belongs to a PURCHASE, not to a variety. You might buy the same garlic from two vendors in two years at two prices and two germination rates; quantity columns on plants would collapse all of that into one number and lose it. So lots get their own table, and owner_id sits on the lot rather than being inherited from the plant — you can buy generic built-in Garlic from Johnny's and the record is still yours alone. Lots are never shared along with a garden. `remaining` is derived, never stored: quantity minus the summed effective count of the active plantings attributed to the lot. The alternative — decrementing a column when you plant — drifts the moment anything edits or removes a planting behind its back, and once it has drifted there's no way to tell what the true number was. Removing a plop gives the seed back with nothing having to remember to. The usage query returns raw radius/count/spacing rather than a SUM, because the effective-count formula lives in the service and reproducing it in SQL would guarantee the two drift apart. source_url is validated as http(s) with a host, on both plants and lots. It renders as a clickable link, so that check is load-bearing rather than tidiness: without it a stored javascript: URL becomes script execution the moment someone clicks it. Schemeless and relative URLs are refused too — they'd resolve against pansy's own origin, which is never what a vendor link means. A planting's lot must be the actor's AND a lot of the plant being planted. Attributing a garlic planting to a tomato lot would silently corrupt every remaining count downstream, so it's refused rather than stored, and re-checked on update in case a plant swap invalidated a previously-valid attribution. Deleting a lot leaves its plantings alone with a null link: the plants really were in the ground, whatever happened to the record of the packet. Closes #50 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
211 lines
6.6 KiB
Go
211 lines
6.6 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// Plant field bounds. Spacing is mature in-row spacing in cm; the ceiling is a
|
|
// generous sanity limit (also rejecting NaN/Inf). Name/notes are length-capped so
|
|
// untrusted input can't balloon storage; icon holds one emoji (possibly a
|
|
// multi-codepoint ZWJ sequence, hence a byte cap rather than length 1).
|
|
const (
|
|
maxPlantNameLen = 200
|
|
maxPlantNotesLen = 10_000
|
|
maxPlantIconLen = 32
|
|
minPlantSpacingCM = 0.1
|
|
maxPlantSpacingCM = 10_000 // 100 m — headroom for large trees/shrubs
|
|
maxDaysToMaturity = 3650 // ~10 years
|
|
)
|
|
|
|
// plantCategories is the set of valid category enum values (mirrors the schema
|
|
// CHECK constraint in 0001_init.sql).
|
|
var plantCategories = map[string]struct{}{
|
|
domain.CategoryVegetable: {},
|
|
domain.CategoryHerb: {},
|
|
domain.CategoryFlower: {},
|
|
domain.CategoryFruit: {},
|
|
domain.CategoryTreeShrub: {},
|
|
domain.CategoryCover: {},
|
|
}
|
|
|
|
// PlantInput is the mutable field set for creating a plant. DaysToMaturity is
|
|
// optional (nil = unknown).
|
|
type PlantInput struct {
|
|
Name string
|
|
Category string
|
|
SpacingCM float64
|
|
Color string
|
|
Icon string
|
|
DaysToMaturity *int
|
|
// Where this variety came from — the page you'd go back to to buy it again.
|
|
// What you actually bought, and how much is left, is a SeedLot instead.
|
|
SourceURL string
|
|
Vendor string
|
|
Notes string
|
|
}
|
|
|
|
// PlantPatch is a partial update: nil fields are left unchanged. DaysToMaturity
|
|
// is itself nullable, so SetDays distinguishes "clear to NULL" (SetDays=true,
|
|
// value nil) from "leave unchanged" (SetDays=false) — a plain nil can't.
|
|
type PlantPatch struct {
|
|
Name *string
|
|
Category *string
|
|
SpacingCM *float64
|
|
Color *string
|
|
Icon *string
|
|
SetDays bool
|
|
DaysToMaturity *int
|
|
SourceURL *string
|
|
Vendor *string
|
|
Notes *string
|
|
}
|
|
|
|
// ListPlants returns the plants the actor can see: built-ins plus their own.
|
|
func (s *Service) ListPlants(ctx context.Context, actorID int64) ([]domain.Plant, error) {
|
|
return s.store.ListPlantsForActor(ctx, actorID)
|
|
}
|
|
|
|
// CreatePlant adds a plant owned by the actor. To "clone" a built-in the client
|
|
// simply POSTs a copy — there is no dedicated endpoint, and the copy is owned by
|
|
// (and editable by) the actor.
|
|
func (s *Service) CreatePlant(ctx context.Context, actorID int64, in PlantInput) (*domain.Plant, error) {
|
|
p := &domain.Plant{
|
|
Name: strings.TrimSpace(in.Name),
|
|
Category: in.Category,
|
|
SpacingCM: in.SpacingCM,
|
|
Color: in.Color,
|
|
Icon: strings.TrimSpace(in.Icon),
|
|
DaysToMaturity: in.DaysToMaturity,
|
|
SourceURL: strings.TrimSpace(in.SourceURL),
|
|
Vendor: strings.TrimSpace(in.Vendor),
|
|
Notes: strings.TrimSpace(in.Notes),
|
|
}
|
|
if err := finalizePlant(p); err != nil {
|
|
return nil, err
|
|
}
|
|
owner := actorID
|
|
p.OwnerID = &owner
|
|
return s.store.CreatePlant(ctx, p)
|
|
}
|
|
|
|
// writablePlant loads a plant and enforces that the actor may modify it. It is
|
|
// THE authorization point for plant mutation. Built-in rows (owner_id nil) are
|
|
// visible to everyone but read-only → ErrForbidden. A row owned by someone else
|
|
// is invisible → ErrNotFound (masking existence, as gardens/objects do). Only the
|
|
// actor's own rows are writable.
|
|
func (s *Service) writablePlant(ctx context.Context, actorID, plantID int64) (*domain.Plant, error) {
|
|
p, err := s.store.GetPlant(ctx, plantID)
|
|
if err != nil {
|
|
return nil, err // ErrNotFound
|
|
}
|
|
if p.OwnerID == nil {
|
|
return nil, domain.ErrForbidden // built-in: seeded and read-only
|
|
}
|
|
if *p.OwnerID != actorID {
|
|
return nil, domain.ErrNotFound // another user's plant: not visible
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// UpdatePlant applies a partial, version-guarded patch to the actor's own plant.
|
|
// On a version mismatch it returns (current, ErrVersionConflict).
|
|
func (s *Service) UpdatePlant(ctx context.Context, actorID, plantID int64, patch PlantPatch, version int64) (*domain.Plant, error) {
|
|
p, err := s.writablePlant(ctx, actorID, plantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyPlantPatch(p, patch)
|
|
if err := finalizePlant(p); err != nil {
|
|
return nil, err
|
|
}
|
|
p.Version = version
|
|
return s.store.UpdatePlant(ctx, p)
|
|
}
|
|
|
|
// DeletePlant removes the actor's own plant. A plant still referenced by any
|
|
// planting (even a removed one — the FK is RESTRICT) is refused with
|
|
// ErrPlantInUse rather than orphaning history; the client clones-and-edits or
|
|
// clears the plantings first.
|
|
func (s *Service) DeletePlant(ctx context.Context, actorID, plantID int64) error {
|
|
if _, err := s.writablePlant(ctx, actorID, plantID); err != nil {
|
|
return err
|
|
}
|
|
n, err := s.store.CountPlantingsForPlant(ctx, plantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
return domain.ErrPlantInUse
|
|
}
|
|
return s.store.DeletePlant(ctx, plantID)
|
|
}
|
|
|
|
// applyPlantPatch mutates p with each provided (non-nil) patch field.
|
|
func applyPlantPatch(p *domain.Plant, patch PlantPatch) {
|
|
if patch.Name != nil {
|
|
p.Name = strings.TrimSpace(*patch.Name)
|
|
}
|
|
if patch.Category != nil {
|
|
p.Category = *patch.Category
|
|
}
|
|
if patch.SpacingCM != nil {
|
|
p.SpacingCM = *patch.SpacingCM
|
|
}
|
|
if patch.Color != nil {
|
|
p.Color = *patch.Color
|
|
}
|
|
if patch.Icon != nil {
|
|
p.Icon = strings.TrimSpace(*patch.Icon)
|
|
}
|
|
if patch.SetDays {
|
|
p.DaysToMaturity = patch.DaysToMaturity
|
|
}
|
|
if patch.SourceURL != nil {
|
|
p.SourceURL = strings.TrimSpace(*patch.SourceURL)
|
|
}
|
|
if patch.Vendor != nil {
|
|
p.Vendor = strings.TrimSpace(*patch.Vendor)
|
|
}
|
|
if patch.Notes != nil {
|
|
p.Notes = strings.TrimSpace(*patch.Notes)
|
|
}
|
|
}
|
|
|
|
// finalizePlant validates a built/merged plant. Shared by create and update so
|
|
// both enforce the same invariants. isFinite/isHexColor live in objects.go.
|
|
func finalizePlant(p *domain.Plant) error {
|
|
if p.Name == "" || len(p.Name) > maxPlantNameLen {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if _, ok := plantCategories[p.Category]; !ok {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if !isFinite(p.SpacingCM) || p.SpacingCM < minPlantSpacingCM || p.SpacingCM > maxPlantSpacingCM {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if !isHexColor(p.Color) {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if p.Icon == "" || len(p.Icon) > maxPlantIconLen {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if p.DaysToMaturity != nil && (*p.DaysToMaturity < 1 || *p.DaysToMaturity > maxDaysToMaturity) {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if len(p.Notes) > maxPlantNotesLen {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if len(p.Vendor) > maxLotTextLen {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
// Rendered as a clickable link, so the scheme check is load-bearing (see
|
|
// validSourceURL) rather than tidiness.
|
|
if !validSourceURL(p.SourceURL) {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
return nil
|
|
}
|