Build image / build-and-push (push) Successful in 6s
Gadfly findings on #94, the real ones: - scanSeedPacket extends only the READ deadline; a slow upload + a live vision call runs past the server's absolute 30s WriteTimeout and the successful response is silently dropped (the #78 failure mode). Extend the write deadline too (scanWriteTimeout). - An oversized upload tripping MaxBytesReader was mapped to 400; it's 413. Detect *http.MaxBytesError and report IMAGE_TOO_LARGE. - Split imagenorm error mapping: ErrTooLarge->413, ErrUnsupported->400, genuine read/encode faults (and a failed file.Open)->500, not 400. - CreateFromPacket discarded the plant it created when the lot then failed, contradicting its own doc. Roll the new plant back instead so the confirm is all-or-nothing (a fresh plant has no lots/plantings, so the delete is safe; log-and-continue on cleanup failure). - Dedup: packetLotRequest and seedLotCreateRequest shared every lot field. Extract a seedLotFields base both use. validCategory now reuses plantCategories. EffectiveConfig resolves agent+vision from one settings-row read instead of two. - capabilities swallowed an EffectiveVision error silently; log it. - vision test hand-copied Extract's body (drift risk). Split generate() out of Extract so the hermetic test drives the real request builder. Tests: rollback-on-lot-failure (service), oversized->413 (api). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
183 lines
5.8 KiB
Go
183 lines
5.8 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.
|
|
|
|
// seedLotFields is the lot half of a create body — every field EXCEPT which plant
|
|
// it attaches to. seedLotCreateRequest adds a required plantId; the seed-packet
|
|
// confirm supplies none (the plant comes from its plantId/newPlant choice), so it
|
|
// embeds these fields directly. Sharing one struct keeps the two request shapes —
|
|
// and their validation — from drifting apart.
|
|
type seedLotFields struct {
|
|
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"`
|
|
}
|
|
|
|
// toInput builds the service input with no plant attribution; callers that know
|
|
// the plant (the create handler; the packet confirm) set PlantID afterwards.
|
|
func (f seedLotFields) toInput() service.SeedLotInput {
|
|
return service.SeedLotInput{
|
|
Vendor: f.Vendor, SourceURL: f.SourceURL, SKU: f.SKU, LotCode: f.LotCode,
|
|
PurchasedAt: f.PurchasedAt, PackedForYear: f.PackedForYear, Quantity: f.Quantity,
|
|
Unit: f.Unit, CostCents: f.CostCents, GerminationPct: f.GerminationPct, Notes: f.Notes,
|
|
}
|
|
}
|
|
|
|
// seedLotCreateRequest is the body for POST /seed-lots.
|
|
type seedLotCreateRequest struct {
|
|
PlantID int64 `json:"plantId" binding:"required"`
|
|
seedLotFields
|
|
}
|
|
|
|
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
|
|
in := r.seedLotFields.toInput()
|
|
in.PlantID = r.PlantID
|
|
return in
|
|
}
|
|
|
|
// 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)
|
|
}
|