package handlers import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "strings" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "git.seaofstars.xyz/mohd/deflated/internal/db" "git.seaofstars.xyz/mohd/deflated/internal/inflation" ) // ReceiptHandler handles HTTP requests related to receipt submissions. type ReceiptHandler struct { queries *db.Queries matcher *inflation.Matcher } func NewReceiptHandler(queries *db.Queries) *ReceiptHandler { return &ReceiptHandler{ queries: queries, matcher: inflation.NewMatcher(), } } type submitRequest struct { StoreName string `json:"store_name"` ReceiptDate string `json:"receipt_date"` // "2024-03-15" City string `json:"city"` Country string `json:"country"` LineItems []lineItem `json:"line_items"` } type lineItem struct { RawName string `json:"raw_name"` PriceCents int `json:"price_cents"` Quantity float64 `json:"quantity"` } // Submit handles POST /api/receipts // Accepts a multipart form with: // - "data" field: JSON with receipt metadata and line items // - "image" field: optional receipt photo func (h *ReceiptHandler) Submit(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10 << 20); err != nil { writeError(w, http.StatusBadRequest, "invalid form data") return } var req submitRequest dataField := r.FormValue("data") if dataField == "" { writeError(w, http.StatusBadRequest, "missing 'data' field") return } if err := json.Unmarshal([]byte(dataField), &req); err != nil { writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON: %v", err)) return } if req.StoreName == "" { writeError(w, http.StatusBadRequest, "store_name is required") return } receiptDate, err := time.Parse("2006-01-02", req.ReceiptDate) if err != nil { writeError(w, http.StatusBadRequest, "receipt_date must be YYYY-MM-DD") return } if len(req.LineItems) == 0 { writeError(w, http.StatusBadRequest, "at least one line item is required") return } // Handle optional image upload imagePath := "" if file, header, err := r.FormFile("image"); err == nil { defer file.Close() imagePath, err = saveUpload(file, header.Filename) if err != nil { slog.Error("failed to save upload", "error", err) writeError(w, http.StatusInternalServerError, "could not save image") return } } receipt, err := h.queries.CreateReceipt(r.Context(), db.CreateReceiptParams{ StoreName: req.StoreName, ReceiptDate: receiptDate, ImagePath: imagePath, City: req.City, Country: coalesce(req.Country, "US"), }) if err != nil { slog.Error("failed to create receipt", "error", err) writeError(w, http.StatusInternalServerError, "database error") return } for _, item := range req.LineItems { if item.Quantity <= 0 { item.Quantity = 1 } canonical := h.matcher.Match(item.RawName) if err := h.queries.CreateLineItem(r.Context(), db.CreateLineItemParams{ ReceiptID: receipt.ID, RawName: item.RawName, CanonicalName: canonical, PriceCents: item.PriceCents, Quantity: item.Quantity, }); err != nil { slog.Error("failed to create line item", "error", err, "raw_name", item.RawName) } } // Rebuild snapshots in background so response stays fast go func() { if err := h.queries.RebuildSnapshots(r.Context()); err != nil { slog.Error("failed to rebuild snapshots", "error", err) } }() writeJSON(w, http.StatusCreated, map[string]any{ "id": receipt.ID, "message": "receipt submitted successfully", }) } // Get handles GET /api/receipts/{id} func (h *ReceiptHandler) Get(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid receipt id") return } receipt, err := h.queries.GetReceiptByID(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "receipt not found") return } writeJSON(w, http.StatusOK, receipt) } func saveUpload(src io.Reader, originalName string) (string, error) { dir := "./uploads" if err := os.MkdirAll(dir, 0755); err != nil { return "", err } ext := strings.ToLower(filepath.Ext(originalName)) if ext == "" { ext = ".jpg" } allowed := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".pdf": true} if !allowed[ext] { return "", fmt.Errorf("unsupported file type: %s", ext) } filename := fmt.Sprintf("%s%s", uuid.New().String(), ext) path := filepath.Join(dir, filename) dst, err := os.Create(path) if err != nil { return "", err } defer dst.Close() limited := io.LimitReader(src, 10<<20) if _, err := io.Copy(dst, limited); err != nil { return "", err } return path, nil } func coalesce(s, fallback string) string { if s == "" { return fallback } return s }