Files
steve 78a04672b5
Build image / build-and-push (push) Successful in 8s
Seed provenance + inventory: source links and seed lots (#50) (#64)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:39:24 +00:00

348 lines
11 KiB
Go

package service
import (
"context"
"log/slog"
"net/url"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// Seed provenance and inventory (#50). A lot is one purchase — a specific packet
// of a specific variety, from a specific vendor, in a specific year — and it is
// owned by the buyer even when it points at a built-in plant. Lots are private:
// they are never shared along with a garden, because what you paid for seed is
// not part of a garden plan.
const (
maxLotTextLen = 200
maxLotNotesLen = 10_000
maxSourceURLLen = 2000
maxLotQuantity = 1_000_000
minPackedYear = 1900
maxPackedYear = 2200
)
// lotUnits is the set of valid quantity units (mirrors the schema CHECK).
var lotUnits = map[string]struct{}{
domain.UnitSeeds: {},
domain.UnitGrams: {},
domain.UnitOunces: {},
domain.UnitPackets: {},
domain.UnitBulbs: {},
domain.UnitPlants: {},
}
// SeedLotInput is the payload for recording a purchase.
type SeedLotInput struct {
PlantID int64
Vendor string
SourceURL string
SKU string
LotCode string
PurchasedAt *string
PackedForYear *int
Quantity float64
Unit string
CostCents *int
GerminationPct *float64
Notes string
}
// SeedLotPatch is a partial update; nil fields are left unchanged. The nullable
// columns use a Set* flag to tell "clear to NULL" from "leave alone". PlantID is
// absent deliberately: re-pointing a purchase at a different variety would
// rewrite history rather than correct it.
type SeedLotPatch struct {
Vendor *string
SourceURL *string
SKU *string
LotCode *string
SetPurchasedAt bool
PurchasedAt *string
SetPackedForYear bool
PackedForYear *int
Quantity *float64
Unit *string
SetCostCents bool
CostCents *int
SetGerminationPct bool
GerminationPct *float64
Notes *string
}
// ListSeedLots returns the actor's own lots, each with Used/Remaining filled in.
// Optionally narrowed to one plant.
func (s *Service) ListSeedLots(ctx context.Context, actorID int64, plantID *int64) ([]domain.SeedLot, error) {
lots, err := s.store.ListSeedLotsForOwner(ctx, actorID, plantID)
if err != nil {
return nil, err
}
if err := s.fillRemaining(ctx, actorID, lots); err != nil {
return nil, err
}
return lots, nil
}
// fillRemaining computes Used and Remaining for a slice of the actor's lots.
//
// Deliberately derived, never stored. 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 is no way to tell what the true number
// was. Attribution plus arithmetic can always be recomputed and audited.
// Remaining may go NEGATIVE, and that is deliberate: it means more was planted
// than the lot recorded buying, which is a real thing that happens (a
// miscounted packet, a lot entered after the fact). Clamping it to zero would
// hide the discrepancy at exactly the moment it is worth seeing.
func (s *Service) fillRemaining(ctx context.Context, actorID int64, lots []domain.SeedLot) error {
if len(lots) == 0 {
return nil
}
ids := make([]int64, 0, len(lots))
for i := range lots {
ids = append(ids, lots[i].ID)
}
uses, err := s.store.SeedLotUsage(ctx, actorID, ids)
if err != nil {
return err
}
used := make(map[int64]float64, len(lots))
for _, u := range uses {
n := derivedCount(u.RadiusCM, u.SpacingCM)
if u.Count != nil {
n = *u.Count
}
used[u.LotID] += float64(n)
}
for i := range lots {
lots[i].Used = used[lots[i].ID]
lots[i].Remaining = lots[i].Quantity - lots[i].Used
}
return nil
}
// ownSeedLot loads a lot and enforces that the actor owns it. Someone else's lot
// is ErrNotFound, not ErrForbidden — existence stays masked, same as gardens and
// other users' plants. A store failure that isn't "no such row" passes through
// unchanged; it is not an authorization answer.
func (s *Service) ownSeedLot(ctx context.Context, actorID, lotID int64) (*domain.SeedLot, error) {
l, err := s.store.GetSeedLot(ctx, lotID)
if err != nil {
return nil, err // ErrNotFound for a missing row; anything else is real
}
if l.OwnerID != actorID {
return nil, domain.ErrNotFound
}
return l, nil
}
// GetSeedLot returns one of the actor's own lots, with Used/Remaining filled in.
func (s *Service) GetSeedLot(ctx context.Context, actorID, lotID int64) (*domain.SeedLot, error) {
l, err := s.ownSeedLot(ctx, actorID, lotID)
if err != nil {
return nil, err
}
return s.withRemaining(ctx, actorID, l)
}
// CreateSeedLot records a purchase owned by the actor. The plant must be one the
// actor can see — a built-in or their own — since you can perfectly well buy
// generic Garlic from a vendor.
func (s *Service) CreateSeedLot(ctx context.Context, actorID int64, in SeedLotInput) (*domain.SeedLot, error) {
if _, err := s.visiblePlant(ctx, actorID, in.PlantID); err != nil {
return nil, err // ErrInvalidInput for an unknown or someone else's plant
}
l := &domain.SeedLot{
OwnerID: actorID,
PlantID: in.PlantID,
Vendor: strings.TrimSpace(in.Vendor),
SourceURL: strings.TrimSpace(in.SourceURL),
SKU: strings.TrimSpace(in.SKU),
LotCode: strings.TrimSpace(in.LotCode),
PurchasedAt: in.PurchasedAt,
PackedForYear: in.PackedForYear,
Quantity: in.Quantity,
Unit: in.Unit,
CostCents: in.CostCents,
GerminationPct: in.GerminationPct,
Notes: strings.TrimSpace(in.Notes),
}
if err := finalizeSeedLot(l); err != nil {
return nil, err
}
created, err := s.store.CreateSeedLot(ctx, l)
if err != nil {
return nil, err
}
// A fresh lot has used nothing, but say so explicitly rather than letting the
// zero value stand in: an unfilled Remaining reads as "none left" on a packet
// you just bought.
return s.withRemaining(ctx, actorID, created)
}
// withRemaining fills Used/Remaining on a single lot.
func (s *Service) withRemaining(ctx context.Context, actorID int64, l *domain.SeedLot) (*domain.SeedLot, error) {
one := []domain.SeedLot{*l}
if err := s.fillRemaining(ctx, actorID, one); err != nil {
return nil, err
}
return &one[0], nil
}
// UpdateSeedLot applies a partial, version-guarded patch to the actor's own lot.
func (s *Service) UpdateSeedLot(ctx context.Context, actorID, lotID int64, patch SeedLotPatch, version int64) (*domain.SeedLot, error) {
l, err := s.ownSeedLot(ctx, actorID, lotID)
if err != nil {
return nil, err
}
applySeedLotPatch(l, patch)
if err := finalizeSeedLot(l); err != nil {
return nil, err
}
l.Version = version
updated, err := s.store.UpdateSeedLot(ctx, l)
if err != nil {
// The conflict path carries the CURRENT row back to the client to rebase
// on, so it needs its computed fields too — a 409 body reporting
// remaining=0 would be worse than no number at all.
if updated != nil {
if filled, ferr := s.withRemaining(ctx, actorID, updated); ferr == nil {
updated = filled
}
}
return updated, err
}
// The write succeeded. If the follow-up read fails, return the row without
// its computed fields rather than reporting a failed update that applied.
filled, ferr := s.withRemaining(ctx, actorID, updated)
if ferr != nil {
slog.Error("service: lot updated but remaining could not be computed", "error", ferr, "lot", lotID)
return updated, nil
}
return filled, nil
}
// DeleteSeedLot removes the actor's own lot. Plantings attributed to it survive
// with a null link (ON DELETE SET NULL) — the plants really were in the ground,
// whatever happened to the record of the packet.
func (s *Service) DeleteSeedLot(ctx context.Context, actorID, lotID int64) error {
if _, err := s.ownSeedLot(ctx, actorID, lotID); err != nil {
return err
}
return s.store.DeleteSeedLot(ctx, lotID)
}
// checkSeedLotForPlanting validates a planting's lot attribution: the lot must be
// the actor's, and must be a lot OF the plant being planted. Attributing a garlic
// planting to a tomato lot is a data error that would silently corrupt every
// remaining count downstream, so it's refused rather than stored.
func (s *Service) checkSeedLotForPlanting(ctx context.Context, actorID int64, lotID *int64, plantID int64) error {
if lotID == nil {
return nil
}
l, err := s.ownSeedLot(ctx, actorID, *lotID)
if err != nil {
return domain.ErrInvalidInput // unknown or not yours: an invalid reference
}
if l.PlantID != plantID {
return domain.ErrInvalidInput
}
return nil
}
func applySeedLotPatch(l *domain.SeedLot, p SeedLotPatch) {
if p.Vendor != nil {
l.Vendor = strings.TrimSpace(*p.Vendor)
}
if p.SourceURL != nil {
l.SourceURL = strings.TrimSpace(*p.SourceURL)
}
if p.SKU != nil {
l.SKU = strings.TrimSpace(*p.SKU)
}
if p.LotCode != nil {
l.LotCode = strings.TrimSpace(*p.LotCode)
}
if p.SetPurchasedAt {
l.PurchasedAt = p.PurchasedAt
}
if p.SetPackedForYear {
l.PackedForYear = p.PackedForYear
}
if p.Quantity != nil {
l.Quantity = *p.Quantity
}
if p.Unit != nil {
l.Unit = *p.Unit
}
if p.SetCostCents {
l.CostCents = p.CostCents
}
if p.SetGerminationPct {
l.GerminationPct = p.GerminationPct
}
if p.Notes != nil {
l.Notes = strings.TrimSpace(*p.Notes)
}
}
// finalizeSeedLot validates a built/merged lot. Shared by create and update.
func finalizeSeedLot(l *domain.SeedLot) error {
if _, ok := lotUnits[l.Unit]; !ok {
return domain.ErrInvalidInput
}
if !isFinite(l.Quantity) || l.Quantity < 0 || l.Quantity > maxLotQuantity {
return domain.ErrInvalidInput
}
if len(l.Vendor) > maxLotTextLen || len(l.SKU) > maxLotTextLen || len(l.LotCode) > maxLotTextLen {
return domain.ErrInvalidInput
}
if len(l.Notes) > maxLotNotesLen {
return domain.ErrInvalidInput
}
if !validSourceURL(l.SourceURL) {
return domain.ErrInvalidInput
}
if !validDatePtr(l.PurchasedAt) {
return domain.ErrInvalidInput
}
if l.PackedForYear != nil && (*l.PackedForYear < minPackedYear || *l.PackedForYear > maxPackedYear) {
return domain.ErrInvalidInput
}
if l.CostCents != nil && *l.CostCents < 0 {
return domain.ErrInvalidInput
}
if l.GerminationPct != nil {
g := *l.GerminationPct
if !isFinite(g) || g < 0 || g > 100 {
return domain.ErrInvalidInput
}
}
return nil
}
// validSourceURL accepts an empty string or an absolute http(s) URL.
//
// This renders as a clickable link, so it is untrusted input in the way that
// matters: without the scheme check a "javascript:" URL stored here becomes
// script execution the moment someone clicks it. Only http and https, and only
// with a host — a relative or schemeless URL would resolve against pansy's own
// origin, which is never what a vendor link means.
func validSourceURL(raw string) bool {
if raw == "" {
return true
}
if len(raw) > maxSourceURLLen {
return false
}
u, err := url.Parse(raw)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
return u.Host != ""
}