Files

194 lines
4.8 KiB
Go

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
}