initial boilerplate
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// writeJSON encodes v as JSON and writes it to w with the given status code.
|
||||
// This is a tiny helper that every handler uses — it keeps handlers clean.
|
||||
func writeJSON(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 {
|
||||
http.Error(w, "encoding error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// writeError writes a standard {"error": "..."} JSON response.
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/yourname/deflated/internal/db"
|
||||
)
|
||||
|
||||
// ItemHandler handles routes that return price and inflation data.
|
||||
type ItemHandler struct {
|
||||
queries *db.Queries
|
||||
}
|
||||
|
||||
func NewItemHandler(queries *db.Queries) *ItemHandler {
|
||||
return &ItemHandler{queries: queries}
|
||||
}
|
||||
|
||||
// List handles GET /api/items
|
||||
func (h *ItemHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.queries.ListCanonicalItems(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("list items", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// PriceHistory handles GET /api/items/{name}/history
|
||||
func (h *ItemHandler) PriceHistory(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
history, err := h.queries.GetPriceHistory(r.Context(), name)
|
||||
if err != nil {
|
||||
slog.Error("get price history", "error", err, "name", name)
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, history)
|
||||
}
|
||||
|
||||
// TopMovers handles GET /api/items/top-movers?months=12&limit=10
|
||||
func (h *ItemHandler) TopMovers(w http.ResponseWriter, r *http.Request) {
|
||||
months, _ := strconv.Atoi(r.URL.Query().Get("months"))
|
||||
if months <= 0 {
|
||||
months = 12
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
movers, err := h.queries.GetTopMovers(r.Context(), months, limit)
|
||||
if err != nil {
|
||||
slog.Error("get top movers", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, movers)
|
||||
}
|
||||
|
||||
// InflationSummary handles GET /api/inflation/summary?base_year=2009
|
||||
func (h *ItemHandler) InflationSummary(w http.ResponseWriter, r *http.Request) {
|
||||
baseYear, _ := strconv.Atoi(r.URL.Query().Get("base_year"))
|
||||
if baseYear <= 0 {
|
||||
baseYear = 2009
|
||||
}
|
||||
|
||||
summary, err := h.queries.GetInflationSummary(r.Context(), baseYear)
|
||||
if err != nil {
|
||||
slog.Error("get inflation summary", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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"
|
||||
|
||||
"github.com/yourname/deflated/internal/db"
|
||||
"github.com/yourname/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
|
||||
}
|
||||
Reference in New Issue
Block a user