Files
Deflated/internal/api/handlers.go
T
2026-05-03 16:43:53 +03:00

248 lines
6.9 KiB
Go

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"
"github.com/yourname/deflated/internal/db"
"github.com/yourname/deflated/internal/models"
"github.com/yourname/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)
}