package api import ( "encoding/json" "errors" "fmt" "math" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5" "git.seaofstars.xyz/mohd/deflated/internal/db" "git.seaofstars.xyz/mohd/deflated/internal/models" "git.seaofstars.xyz/mohd/deflated/internal/parser" ) // handlers holds shared dependencies (database, future: storage client, etc.) // All handler methods live on this struct — no global state. type handlers struct { q *db.Queries } // ── Helpers ─────────────────────────────────────────────────────────────────── // respond encodes v as JSON and writes it with the given status code. // This is the only place we write JSON — keeps error handling consistent. func respond(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { // Encoding errors are rare (e.g. non-serializable types). // Log and move on — headers are already sent. http.Error(w, "encoding error", http.StatusInternalServerError) } } func respondError(w http.ResponseWriter, status int, msg string, details ...string) { e := models.ErrorResponse{Error: msg} if len(details) > 0 { e.Details = details[0] } respond(w, status, e) } // ── Handlers ────────────────────────────────────────────────────────────────── func (h *handlers) health(w http.ResponseWriter, r *http.Request) { respond(w, http.StatusOK, map[string]string{"status": "ok"}) } // POST /api/receipts // Accepts JSON body or multipart form with an optional image file. func (h *handlers) submitReceipt(w http.ResponseWriter, r *http.Request) { // Limit request body to 15 MB (image uploads can be large) r.Body = http.MaxBytesReader(w, r.Body, 15<<20) var req models.SubmitReceiptRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "invalid JSON", err.Error()) return } // Parse and validate the receipt date receiptDate, err := time.Parse("2006-01-02", req.ReceiptDate) if err != nil { respondError(w, http.StatusBadRequest, "invalid receipt_date, use YYYY-MM-DD format", err.Error()) return } if receiptDate.After(time.Now()) { respondError(w, http.StatusBadRequest, "receipt_date cannot be in the future") return } if len(req.Items) == 0 { respondError(w, http.StatusBadRequest, "at least one item is required") return } if len(req.Items) > 200 { respondError(w, http.StatusBadRequest, "maximum 200 items per receipt") return } // Build the receipt params params := models.InsertReceiptParams{ReceiptDate: receiptDate} if req.StoreName != "" { params.StoreName = &req.StoreName } if req.City != "" { params.City = &req.City } receipt, err := h.q.InsertReceipt(r.Context(), params) if err != nil { respondError(w, http.StatusInternalServerError, "failed to save receipt") return } // Insert each line item, normalizing the name to a canonical form var inserted int for _, item := range req.Items { if item.Name == "" || item.Price <= 0 { continue // skip malformed items silently } qty := item.Quantity if qty <= 0 { qty = 1 } canonical, category := parser.Normalize(item.Name) iparams := models.InsertLineItemParams{ ReceiptID: receipt.ID, RawName: item.Name, CanonicalName: canonical, Category: category, PriceCents: int(math.Round(item.Price * 100)), Quantity: qty, } if _, err := h.q.InsertLineItem(r.Context(), iparams); err != nil { // Don't abort the whole receipt for one bad item — log and continue fmt.Printf("warn: failed to insert line item %q: %v\n", item.Name, err) continue } inserted++ } respond(w, http.StatusCreated, models.SubmitReceiptResponse{ ReceiptID: receipt.ID, ItemsAdded: inserted, Message: fmt.Sprintf("Receipt saved with %d items", inserted), }) } // GET /api/receipts/{id} func (h *handlers) getReceipt(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { respondError(w, http.StatusBadRequest, "invalid receipt ID") return } receipt, err := h.q.GetReceipt(r.Context(), id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { respondError(w, http.StatusNotFound, "receipt not found") return } respondError(w, http.StatusInternalServerError, "database error") return } items, err := h.q.GetLineItemsByReceipt(r.Context(), id) if err != nil { respondError(w, http.StatusInternalServerError, "database error") return } respond(w, http.StatusOK, map[string]any{ "receipt": receipt, "items": items, }) } // GET /api/items/{name}/history?months=24 func (h *handlers) getPriceHistory(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") if name == "" { respondError(w, http.StatusBadRequest, "item name is required") return } months := 24 if m := r.URL.Query().Get("months"); m != "" { if v, err := strconv.Atoi(m); err == nil && v > 0 && v <= 120 { months = v } } snapshots, err := h.q.GetPriceHistory(r.Context(), name, months) if err != nil { respondError(w, http.StatusInternalServerError, "database error") return } // Convert DB rows to the API shape the frontend expects resp := models.PriceHistoryResponse{CanonicalName: name} for _, s := range snapshots { resp.DataPoints = append(resp.DataPoints, models.PriceDataPoint{ Month: s.YearMonth.Format("2006-01"), AvgPrice: float64(s.AvgPriceCents) / 100, SampleCount: s.SampleCount, }) } respond(w, http.StatusOK, resp) } // GET /api/items/movers?limit=10 func (h *handlers) getTopMovers(w http.ResponseWriter, r *http.Request) { limit := 10 if l := r.URL.Query().Get("limit"); l != "" { if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 50 { limit = v } } movers, err := h.q.GetTopMovers(r.Context(), limit) if err != nil { respondError(w, http.StatusInternalServerError, "database error") return } respond(w, http.StatusOK, map[string]any{"movers": movers}) } // GET /api/inflation/summary?from=2009-01&to=2025-01 func (h *handlers) getInflationSummary(w http.ResponseWriter, r *http.Request) { fromStr := r.URL.Query().Get("from") toStr := r.URL.Query().Get("to") // Default: from Jan 2009 to today from := time.Date(2009, 1, 1, 0, 0, 0, 0, time.UTC) to := time.Now() if fromStr != "" { if t, err := time.Parse("2006-01", fromStr); err == nil { from = t } } if toStr != "" { if t, err := time.Parse("2006-01", toStr); err == nil { to = t } } summary, err := h.q.GetInflationSummary(r.Context(), from, to) if err != nil { respondError(w, http.StatusInternalServerError, "database error") return } respond(w, http.StatusOK, summary) }