package service import ( "context" "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. func (s *Service) fillRemaining(ctx context.Context, actorID int64, lots []domain.SeedLot) error { if len(lots) == 0 { return nil } uses, err := s.store.SeedLotUsage(ctx, actorID) 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. 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 } 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 } one := []domain.SeedLot{*l} if err := s.fillRemaining(ctx, actorID, one); err != nil { return nil, err } return &one[0], nil } // 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 } return s.store.CreateSeedLot(ctx, l) } // 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 { return updated, err } one := []domain.SeedLot{*updated} if ferr := s.fillRemaining(ctx, actorID, one); ferr != nil { return nil, ferr } return &one[0], 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) } // seedLotForPlanting 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) seedLotForPlanting(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 != "" }