Files
pansy/internal/api/seed_lots.go
T
steveandClaude Opus 4.8 94aed26e14
Build image / build-and-push (push) Successful in 15s
Address Gadfly review on seed lots
Three real bugs, two of which would have quietly corrupted inventory numbers.

CopyGarden carried seed_lot_id into the copied plantings, so copying a garden
double-counted the original lot's usage — the copy is a plan, not a second
planting out of the same packet. It also quietly attached a private lot to a
garden that may later be shared, contradicting the "lots are never shared with a
garden" invariant this feature was built on. The copy now drops the link.

RestorePlanting restored a seed_lot_id verbatim from a history snapshot. Live
rows get their link nulled by ON DELETE SET NULL when a lot is deleted, but the
snapshot still holds the old id, so undoing a planting deletion after retiring
its lot would violate the foreign key and abort the whole revert. The revert now
drops a dangling link before restoring: the plant really was in the ground,
which is the part worth restoring.

CreateSeedLot returned a lot with Used/Remaining at their zero values, so a
packet you just bought reported "0 remaining" — the exact opposite of the truth.
The version-conflict path had the same gap, and that row is what the client
rebases on, so a 409 reporting remaining=0 is worse than no number at all. Both
fill them now.

Remaining can go negative and that is deliberate — it means more was planted
than the lot recorded buying, which happens (a miscounted packet, a lot entered
after the fact). Clamping to zero would hide the discrepancy at exactly the
moment it is worth seeing. Now documented and tested rather than incidental.

Performance and tidiness: SeedLotUsage is scoped to the lots being filled
instead of scanning every planting the owner has, so reading one lot is one
lot's work; ListSeedLotsForOwner gained the LIMIT every other list read has;
parseNullable moved out of seed_lots.go into the shared request helpers, since
every nullable field in the API needs that three-way absent/null/value
distinction; finalizePlant validates against its own maxPlantVendorLen rather
than borrowing a constant named for seed lots; seedLotForPlanting became
checkSeedLotForPlanting, since it validates rather than fetches; and the
duplicate intPtr test helper gave way to the existing ptrInt.

Declined: recording seed-lot mutations in the revision history. change_sets are
anchored to a garden and lots are user-scoped, deliberately — they belong to
you, not to any one garden, so there is no garden to record them against.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:38:54 -04:00

167 lines
5.2 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Seed lots (#50): what you actually bought, and what's left of it. Private to
// the buyer — a lot is never shared along with a garden — so every handler here
// scopes to the session actor with no garden in the picture.
// seedLotCreateRequest is the body for POST /seed-lots.
type seedLotCreateRequest struct {
PlantID int64 `json:"plantId" binding:"required"`
Vendor string `json:"vendor"`
SourceURL string `json:"sourceUrl"`
SKU string `json:"sku"`
LotCode string `json:"lotCode"`
PurchasedAt *string `json:"purchasedAt"`
PackedForYear *int `json:"packedForYear"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit" binding:"required"`
CostCents *int `json:"costCents"`
GerminationPct *float64 `json:"germinationPct"`
Notes string `json:"notes"`
}
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
return service.SeedLotInput{
PlantID: r.PlantID, Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU,
LotCode: r.LotCode, PurchasedAt: r.PurchasedAt, PackedForYear: r.PackedForYear,
Quantity: r.Quantity, Unit: r.Unit, CostCents: r.CostCents,
GerminationPct: r.GerminationPct, Notes: r.Notes,
}
}
// seedLotUpdateRequest is the body for PATCH /seed-lots/:id: every field
// optional, plus the required current version. The nullable columns are
// json.RawMessage so an explicit null (clear it) is distinguishable from an
// absent field (leave it alone). plantId is not accepted — see SeedLotPatch.
type seedLotUpdateRequest struct {
Vendor *string `json:"vendor"`
SourceURL *string `json:"sourceUrl"`
SKU *string `json:"sku"`
LotCode *string `json:"lotCode"`
PurchasedAt json.RawMessage `json:"purchasedAt"`
PackedForYear json.RawMessage `json:"packedForYear"`
Quantity *float64 `json:"quantity"`
Unit *string `json:"unit"`
CostCents json.RawMessage `json:"costCents"`
GerminationPct json.RawMessage `json:"germinationPct"`
Notes *string `json:"notes"`
Version int64 `json:"version" binding:"required"`
}
func (r seedLotUpdateRequest) toPatch() (service.SeedLotPatch, error) {
p := service.SeedLotPatch{
Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU, LotCode: r.LotCode,
Quantity: r.Quantity, Unit: r.Unit, Notes: r.Notes,
}
var err error
if p.PurchasedAt, p.SetPurchasedAt, err = parseNullable[string](r.PurchasedAt); err != nil {
return p, err
}
if p.PackedForYear, p.SetPackedForYear, err = parseNullable[int](r.PackedForYear); err != nil {
return p, err
}
if p.CostCents, p.SetCostCents, err = parseNullable[int](r.CostCents); err != nil {
return p, err
}
if p.GerminationPct, p.SetGerminationPct, err = parseNullable[float64](r.GerminationPct); err != nil {
return p, err
}
return p, nil
}
func (h *handlers) listSeedLots(c *gin.Context) {
var plantID *int64
if raw := c.Query("plantId"); raw != "" {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id < 1 {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid plantId")
return
}
plantID = &id
}
lots, err := h.svc.ListSeedLots(c.Request.Context(), mustActor(c).ID, plantID)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, lots)
}
func (h *handlers) createSeedLot(c *gin.Context) {
var req seedLotCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid seed lot: plantId and unit are required")
return
}
lot, err := h.svc.CreateSeedLot(c.Request.Context(), mustActor(c).ID, req.toInput())
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusCreated, lot)
}
func (h *handlers) getSeedLot(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
lot, err := h.svc.GetSeedLot(c.Request.Context(), mustActor(c).ID, id)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, lot)
}
func (h *handlers) updateSeedLot(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
var req seedLotUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid update: a current version is required")
return
}
patch, err := req.toPatch()
if err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid update payload")
return
}
lot, err := h.svc.UpdateSeedLot(c.Request.Context(), mustActor(c).ID, id, patch, req.Version)
if err != nil {
if errors.Is(err, domain.ErrVersionConflict) {
writeVersionConflict(c, lot)
return
}
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, lot)
}
func (h *handlers) deleteSeedLot(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.svc.DeleteSeedLot(c.Request.Context(), mustActor(c).ID, id); err != nil {
writeServiceError(c, err)
return
}
c.Status(http.StatusNoContent)
}