248 lines
6.9 KiB
Go
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"
|
|
"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)
|
|
}
|