package api import ( "errors" "net/http" "time" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/imagenorm" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // Seed-packet capture (#81). Two steps, deliberately separate: // POST /seed-lots/scan multipart image → a proposal (reads only) // POST /seed-lots/from-packet confirmed proposal → a plant + lot // The scan never writes; creation happens only from an explicit confirm, so a // misread can't add anything to the catalog on its own. // scanUploadLimit bounds the multipart body. imagenorm caps the decoded image at // 25 MiB; this is a little over that for the multipart envelope. A phone photo is // a few MB, so this is generous. const scanUploadLimit = 30 << 20 // scanReadTimeout is how long we allow the image upload to take. The server's // default ReadTimeout (15s) is fine for JSON but tight for a multi-megabyte photo // on a slow phone connection, so this endpoint extends it — the same // ResponseController mechanism the SSE path uses for writes (#78). const scanReadTimeout = 60 * time.Second // scanSeedPacket reads an uploaded packet photo and returns a proposal. func (h *handlers) scanSeedPacket(c *gin.Context) { // Extend the read deadline for the (potentially large, potentially slow) // upload. Best-effort: if the writer doesn't support it, the default applies. _ = http.NewResponseController(c.Writer).SetReadDeadline(time.Now().Add(scanReadTimeout)) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit) file, err := c.FormFile("image") if err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "attach an image as the \"image\" field") return } f, err := file.Open() if err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "could not read the uploaded image") return } defer f.Close() // Normalize to JPEG (decodes HEIC/webp/png/jpeg, downscales, re-encodes) so // everything downstream — including the vision model — only sees a format it // can read. This is where an iPhone HEIC becomes usable. jpeg, format, err := imagenorm.Normalize(f, imagenorm.Options{}) if err != nil { if errors.Is(err, imagenorm.ErrTooLarge) { writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo") return } writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)") return } _ = format // available for logging if wanted prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg) if err != nil { // A missing vision model surfaces as ErrInvalidInput from the service; give // it a clearer message than the generic 400, since the UI shouldn't have // offered the button at all in that case. if errors.Is(err, domain.ErrInvalidInput) { writeAPIError(c, http.StatusServiceUnavailable, "VISION_DISABLED", "packet scanning isn't set up on this instance") return } writeServiceError(c, err) return } c.JSON(http.StatusOK, prop) } // packetLotRequest is the lot half of a confirm. It's seedLotCreateRequest // WITHOUT plantId — the plant comes from the confirm's plantId/newPlant choice, // not the lot body, and reusing seedLotCreateRequest would wrongly require one. type packetLotRequest 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"` } func (r packetLotRequest) toInput() service.SeedLotInput { return service.SeedLotInput{ 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, } } // fromPacketRequest confirms a proposal: exactly one of plantId (attach to an // existing plant) or newPlant (create a variety), plus the lot to record. type fromPacketRequest struct { PlantID *int64 `json:"plantId"` NewPlant *plantCreateRequest `json:"newPlant"` Lot packetLotRequest `json:"lot"` } // createFromPacket turns a confirmed proposal into a plant + lot. func (h *handlers) createFromPacket(c *gin.Context) { var req fromPacketRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a lot and exactly one of plantId or newPlant are required") return } confirm := service.PacketConfirm{ PlantID: req.PlantID, Lot: req.Lot.toInput(), // no plantId in the lot body; the service attributes it } if req.NewPlant != nil { in := req.NewPlant.toInput() confirm.NewPlant = &in } res, err := h.svc.CreateFromPacket(c.Request.Context(), mustActor(c).ID, confirm) if err != nil { writeServiceError(c, err) return } c.JSON(http.StatusCreated, res) }