Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
348 lines
11 KiB
Go
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 != ""
|
|
}
|