initial boilerplate

This commit is contained in:
2026-05-03 16:43:53 +03:00
parent bea266e066
commit 2e63e0e95b
18 changed files with 1878 additions and 1 deletions
+32
View File
@@ -0,0 +1,32 @@
# Makefile — common tasks for local development
.PHONY: run build test migrate seed lint
## Start the server (auto-reloads with 'air' if installed)
run:
@which air > /dev/null 2>&1 && air || go run ./cmd/server
## Build a binary
build:
go build -o bin/server ./cmd/server
## Run all tests
test:
go test ./... -v
## Apply the latest SQL migration
migrate:
psql $$DATABASE_URL -f migrations/001_initial.sql
## Run the server with the race detector (finds concurrency bugs)
race:
go run -race ./cmd/server
## Lint (requires golangci-lint)
lint:
golangci-lint run ./...
## Install dev tools
tools:
go install github.com/cosmtrek/air@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
+97 -1
View File
@@ -1,2 +1,98 @@
# Deflated # Deflated.fyi — Crowdsourced Inflation Tracker
A Go backend + React frontend for tracking real grocery prices over time,
aggregated from user-submitted receipts.
## Project structure
```
deflated/
├── cmd/server/main.go # Entrypoint — starts HTTP server
├── internal/
│ ├── api/
│ │ ├── router.go # Chi router, all routes registered here
│ │ └── handlers.go # One handler per endpoint
│ ├── db/
│ │ ├── connect.go # pgxpool setup
│ │ └── queries.go # All SQL queries as typed Go methods
│ ├── models/
│ │ └── models.go # Shared structs (DB rows, API shapes)
│ ├── parser/
│ │ └── normalize.go # Receipt item → canonical name
│ └── inflation/
│ └── worker.go # Background job: refresh price snapshots
├── migrations/
│ └── 001_initial.sql # Database schema
├── .env.example # Copy to .env, fill in values
├── Makefile # make run / test / migrate
└── go.mod
```
## Prerequisites
- Go 1.23+
- PostgreSQL 15+
## Quick start
```bash
# 1. Clone and enter the project
git clone https://github.com/yourname/deflated
cd deflated
# 2. Set up environment
cp .env.example .env
# Edit .env with your DATABASE_URL
# 3. Create the database
createdb deflated
make migrate
# 4. Install dependencies and run
go mod tidy
make run
```
Server starts on http://localhost:8080
## API
| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check |
| POST | /api/receipts | Submit a receipt with line items |
| GET | /api/receipts/:id | Get a receipt and its items |
| GET | /api/items/:name/history | Price history for a canonical item |
| GET | /api/items/movers | Top price movers (last 12 months) |
| GET | /api/inflation/summary | Purchasing power summary |
### Submit a receipt
```bash
curl -X POST http://localhost:8080/api/receipts \
-H "Content-Type: application/json" \
-d '{
"store_name": "Trader Joes",
"receipt_date": "2024-11-15",
"city": "Austin, TX",
"items": [
{ "name": "Whole Milk 1 Gallon", "price": 4.29 },
{ "name": "Large Eggs Dozen", "price": 3.49 },
{ "name": "White Bread Loaf", "price": 2.99, "quantity": 1 }
]
}'
```
## Go learning path
This project is intentionally structured to teach Go incrementally:
1. **`cmd/server/main.go`** — entry point, signals, graceful shutdown
2. **`internal/db/connect.go`** — connection pools, context, timeouts
3. **`internal/api/router.go`** — Chi router, middleware
4. **`internal/api/handlers.go`** — request parsing, error handling, JSON responses
5. **`internal/db/queries.go`** — raw SQL with pgx, scanning rows into structs
6. **`internal/parser/normalize.go`** — pure functions, string processing
7. **`internal/inflation/worker.go`** — goroutines, channels, context cancellation
Read the files in that order and you'll have covered ~80% of idiomatic Go.
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/joho/godotenv"
"github.com/yourname/deflated/internal/db"
"github.com/yourname/deflated/internal/handlers"
)
func main() {
// Load .env file if present (ignored in production)
_ = godotenv.Load()
// Structured logger — prints human-readable text locally, JSON in prod
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
slog.SetDefault(logger)
// Connect to Postgres
pool, err := db.Connect(os.Getenv("DATABASE_URL"))
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer pool.Close()
// Run migrations on startup
if err := db.Migrate(pool); err != nil {
slog.Error("failed to run migrations", "error", err)
os.Exit(1)
}
// Wire up dependencies
queries := db.New(pool)
receiptHandler := handlers.NewReceiptHandler(queries)
itemHandler := handlers.NewItemHandler(queries)
// Build router
r := chi.NewRouter()
// Middleware stack (runs for every request, in order)
r.Use(middleware.RequestID) // adds X-Request-ID header
r.Use(middleware.RealIP) // reads X-Forwarded-For for real client IP
r.Use(middleware.Logger) // logs method, path, status, duration
r.Use(middleware.Recoverer) // catches panics, returns 500 instead of crashing
r.Use(middleware.Compress(5)) // gzip responses
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:5173", os.Getenv("FRONTEND_URL")},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Content-Type"},
}))
// Routes
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
})
r.Route("/api", func(r chi.Router) {
r.Post("/receipts", receiptHandler.Submit)
r.Get("/receipts/{id}", receiptHandler.Get)
r.Get("/items", itemHandler.List)
r.Get("/items/{name}/history", itemHandler.PriceHistory)
r.Get("/items/top-movers", itemHandler.TopMovers)
r.Get("/inflation/summary", itemHandler.InflationSummary)
})
// Server with graceful shutdown
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
srv := &http.Server{
Addr: ":" + port,
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start server in background goroutine
go func() {
slog.Info("server starting", "port", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
// Block until SIGINT or SIGTERM (Ctrl+C or `docker stop`)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("shutting down gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("forced shutdown", "error", err)
}
}
+11
View File
@@ -0,0 +1,11 @@
module github.com/yourname/deflated
go 1.23
require (
github.com/go-chi/chi/v5 v5.0.12
github.com/go-chi/cors v1.2.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.6.0
github.com/joho/godotenv v1.5.1
)
+247
View File
@@ -0,0 +1,247 @@
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)
}
+51
View File
@@ -0,0 +1,51 @@
// Package api wires together the HTTP router and all handlers.
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yourname/deflated/internal/db"
)
// NewRouter builds the full Chi router with middleware and all routes.
// This is the only place routes are registered — easy to see the full API shape.
func NewRouter(pool *pgxpool.Pool) http.Handler {
queries := db.NewQueries(pool)
h := &handlers{q: queries}
r := chi.NewRouter()
// ── Middleware stack ────────────────────────────────────────────────────
r.Use(middleware.RequestID) // adds X-Request-Id header
r.Use(middleware.RealIP) // reads X-Forwarded-For
r.Use(middleware.Logger) // logs every request: method, path, status, latency
r.Use(middleware.Recoverer) // catches panics, returns 500 instead of crashing
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000", "https://deflated.fyi"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Content-Type"},
}))
// ── Routes ──────────────────────────────────────────────────────────────
r.Get("/health", h.health)
r.Route("/api", func(r chi.Router) {
// Receipt submission
r.Post("/receipts", h.submitReceipt)
r.Get("/receipts/{id}", h.getReceipt)
// Price data for the dashboard
r.Get("/items/{name}/history", h.getPriceHistory) // ?months=24
r.Get("/items/movers", h.getTopMovers) // ?limit=10
// The torn dollar bill — purchasing power over time
r.Get("/inflation/summary", h.getInflationSummary) // ?from=2009-01&to=2025-01
})
return r
}
+38
View File
@@ -0,0 +1,38 @@
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Connect opens a connection pool to Postgres.
// pgxpool manages multiple connections automatically — you rarely need to
// think about it; just pass the pool around and pgx picks an idle connection.
func Connect(databaseURL string) (*pgxpool.Pool, error) {
if databaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL is not set")
}
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
// Pool settings — tune these later based on your load
config.MaxConns = 25
config.MinConns = 2
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
// Ping to verify the connection works at startup
if err := pool.Ping(context.Background()); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return pool, nil
}
+161
View File
@@ -0,0 +1,161 @@
package db
import (
"context"
"fmt"
"log/slog"
"github.com/jackc/pgx/v5/pgxpool"
)
// migration holds a SQL statement and a human-readable name.
// We run them in order and track which ones have already run.
type migration struct {
name string
sql string
}
var migrations = []migration{
{
name: "create_receipts",
sql: `
CREATE TABLE IF NOT EXISTS receipts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_name TEXT,
receipt_date DATE NOT NULL,
image_path TEXT,
city TEXT,
country TEXT DEFAULT 'US',
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`,
},
{
name: "create_canonical_items",
sql: `
-- The "dictionary" of known items we track.
-- User submissions get mapped to these via fuzzy matching.
CREATE TABLE IF NOT EXISTS canonical_items (
name TEXT PRIMARY KEY, -- e.g. "milk_whole_1gal"
display_name TEXT NOT NULL, -- e.g. "Whole Milk, 1 Gallon"
category TEXT NOT NULL, -- e.g. "dairy"
unit TEXT, -- e.g. "gallon", "lb", "dozen"
aliases TEXT[] DEFAULT '{}' -- ["1 gal whole milk", "milk whole gal"]
);
`,
},
{
name: "create_line_items",
sql: `
CREATE TABLE IF NOT EXISTS line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
receipt_id UUID NOT NULL REFERENCES receipts(id) ON DELETE CASCADE,
raw_name TEXT NOT NULL,
canonical_name TEXT REFERENCES canonical_items(name),
price_cents INT NOT NULL CHECK (price_cents > 0),
quantity NUMERIC(8,3) NOT NULL DEFAULT 1,
-- Stored computed column: unit price in cents
unit_price_cents INT GENERATED ALWAYS AS
(ROUND(price_cents / quantity)::INT) STORED
);
CREATE INDEX IF NOT EXISTS line_items_receipt_id ON line_items(receipt_id);
CREATE INDEX IF NOT EXISTS line_items_canonical ON line_items(canonical_name);
`,
},
{
name: "create_price_snapshots",
sql: `
-- Pre-aggregated monthly averages, rebuilt by a background job.
-- Charts read from here rather than scanning all line_items each time.
CREATE TABLE IF NOT EXISTS price_snapshots (
canonical_name TEXT NOT NULL REFERENCES canonical_items(name),
year_month DATE NOT NULL, -- always the 1st of the month
avg_price_cents INT NOT NULL,
sample_count INT NOT NULL DEFAULT 0,
PRIMARY KEY (canonical_name, year_month)
);
`,
},
{
name: "seed_canonical_items",
sql: `
INSERT INTO canonical_items (name, display_name, category, unit, aliases) VALUES
('milk_whole_1gal', 'Whole Milk, 1 Gallon', 'dairy', 'gallon',
ARRAY['whole milk gallon', '1 gal whole milk', 'milk whl gal']),
('eggs_large_dozen', 'Large Eggs, 1 Dozen', 'dairy', 'dozen',
ARRAY['large eggs 12ct', 'eggs large dozen', 'grade a large eggs']),
('bread_white_loaf', 'White Bread, 1 Loaf', 'bakery', 'loaf',
ARRAY['white bread', 'sandwich bread', 'bread loaf']),
('ground_beef_1lb', 'Ground Beef, 1 lb (80%)', 'meat', 'lb',
ARRAY['ground beef lb', '80/20 ground beef', 'hamburger meat']),
('olive_oil_16oz', 'Olive Oil, 16 oz', 'pantry', 'bottle',
ARRAY['olive oil 16oz', 'extra virgin olive oil', 'evoo 16oz']),
('butter_salted_1lb','Salted Butter, 1 lb', 'dairy', 'lb',
ARRAY['butter salted pound', 'salted butter 4 sticks']),
('chicken_breast_1lb','Chicken Breast, 1 lb', 'meat', 'lb',
ARRAY['boneless chicken breast', 'chicken breast lb']),
('orange_juice_52oz','Orange Juice, 52 oz', 'beverages','carton',
ARRAY['oj 52oz', 'orange juice carton', 'florida natural oj'])
ON CONFLICT (name) DO NOTHING;
`,
},
}
// Migrate runs all pending migrations in order.
// It creates a simple tracking table on first run.
func Migrate(pool *pgxpool.Pool) error {
ctx := context.Background()
// Create the migrations tracking table if it doesn't exist
_, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`)
if err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
for _, m := range migrations {
// Check if already applied
var exists bool
err := pool.QueryRow(ctx,
"SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE name = $1)", m.name,
).Scan(&exists)
if err != nil {
return fmt.Errorf("check migration %s: %w", m.name, err)
}
if exists {
continue
}
// Run the migration inside a transaction so it's atomic
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin migration %s: %w", m.name, err)
}
if _, err := tx.Exec(ctx, m.sql); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("run migration %s: %w", m.name, err)
}
if _, err := tx.Exec(ctx,
"INSERT INTO schema_migrations (name) VALUES ($1)", m.name,
); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("record migration %s: %w", m.name, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migration %s: %w", m.name, err)
}
slog.Info("migration applied", "name", m.name)
}
return nil
}
+287
View File
@@ -0,0 +1,287 @@
package db
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Queries holds the database connection pool.
// All database operations are methods on this type.
// This pattern (a "repository") keeps SQL out of your handlers.
type Queries struct {
pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Queries {
return &Queries{pool: pool}
}
// ---- Receipts ----
type CreateReceiptParams struct {
StoreName string
ReceiptDate time.Time
ImagePath string
City string
Country string
}
type Receipt struct {
ID uuid.UUID
StoreName string
ReceiptDate time.Time
ImagePath string
City string
Country string
SubmittedAt time.Time
}
func (q *Queries) CreateReceipt(ctx context.Context, p CreateReceiptParams) (Receipt, error) {
var r Receipt
err := q.pool.QueryRow(ctx, `
INSERT INTO receipts (store_name, receipt_date, image_path, city, country)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, store_name, receipt_date, image_path, city, country, submitted_at
`, p.StoreName, p.ReceiptDate, p.ImagePath, p.City, p.Country,
).Scan(&r.ID, &r.StoreName, &r.ReceiptDate, &r.ImagePath, &r.City, &r.Country, &r.SubmittedAt)
if err != nil {
return Receipt{}, fmt.Errorf("create receipt: %w", err)
}
return r, nil
}
func (q *Queries) GetReceiptByID(ctx context.Context, id uuid.UUID) (Receipt, error) {
var r Receipt
err := q.pool.QueryRow(ctx, `
SELECT id, store_name, receipt_date, image_path, city, country, submitted_at
FROM receipts WHERE id = $1
`, id).Scan(&r.ID, &r.StoreName, &r.ReceiptDate, &r.ImagePath, &r.City, &r.Country, &r.SubmittedAt)
if err != nil {
return Receipt{}, fmt.Errorf("get receipt: %w", err)
}
return r, nil
}
// ---- Line Items ----
type CreateLineItemParams struct {
ReceiptID uuid.UUID
RawName string
CanonicalName string // may be empty if not matched yet
PriceCents int
Quantity float64
}
func (q *Queries) CreateLineItem(ctx context.Context, p CreateLineItemParams) error {
var canonical *string
if p.CanonicalName != "" {
canonical = &p.CanonicalName
}
_, err := q.pool.Exec(ctx, `
INSERT INTO line_items (receipt_id, raw_name, canonical_name, price_cents, quantity)
VALUES ($1, $2, $3, $4, $5)
`, p.ReceiptID, p.RawName, canonical, p.PriceCents, p.Quantity)
if err != nil {
return fmt.Errorf("create line item: %w", err)
}
return nil
}
// ---- Canonical Items ----
type CanonicalItem struct {
Name string
DisplayName string
Category string
Unit string
Aliases []string
}
func (q *Queries) ListCanonicalItems(ctx context.Context) ([]CanonicalItem, error) {
rows, err := q.pool.Query(ctx, `
SELECT name, display_name, category, unit, aliases
FROM canonical_items
ORDER BY category, display_name
`)
if err != nil {
return nil, fmt.Errorf("list canonical items: %w", err)
}
defer rows.Close()
var items []CanonicalItem
for rows.Next() {
var item CanonicalItem
if err := rows.Scan(&item.Name, &item.DisplayName, &item.Category, &item.Unit, &item.Aliases); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// ---- Price History ----
type PricePoint struct {
YearMonth time.Time
AvgPriceCents int
SampleCount int
}
// GetPriceHistory returns monthly average prices for a canonical item.
func (q *Queries) GetPriceHistory(ctx context.Context, canonicalName string) ([]PricePoint, error) {
rows, err := q.pool.Query(ctx, `
SELECT year_month, avg_price_cents, sample_count
FROM price_snapshots
WHERE canonical_name = $1
ORDER BY year_month ASC
`, canonicalName)
if err != nil {
return nil, fmt.Errorf("get price history: %w", err)
}
defer rows.Close()
var points []PricePoint
for rows.Next() {
var p PricePoint
if err := rows.Scan(&p.YearMonth, &p.AvgPriceCents, &p.SampleCount); err != nil {
return nil, err
}
points = append(points, p)
}
return points, rows.Err()
}
// TopMover represents the biggest price change over a period.
type TopMover struct {
CanonicalName string
DisplayName string
Category string
PriceThen int
PriceNow int
PctChange float64
}
// GetTopMovers finds items with the largest price change over the last N months.
func (q *Queries) GetTopMovers(ctx context.Context, months int, limit int) ([]TopMover, error) {
rows, err := q.pool.Query(ctx, `
WITH
-- Most recent snapshot per item
latest AS (
SELECT DISTINCT ON (canonical_name)
canonical_name, avg_price_cents AS price_now, year_month
FROM price_snapshots
ORDER BY canonical_name, year_month DESC
),
-- Snapshot closest to N months ago
old AS (
SELECT DISTINCT ON (ps.canonical_name)
ps.canonical_name, ps.avg_price_cents AS price_then
FROM price_snapshots ps
WHERE ps.year_month <= (now() - ($1 || ' months')::interval)::date
ORDER BY ps.canonical_name, ps.year_month DESC
)
SELECT
l.canonical_name,
ci.display_name,
ci.category,
o.price_then,
l.price_now,
ROUND(((l.price_now - o.price_then)::numeric / o.price_then) * 100, 1) AS pct_change
FROM latest l
JOIN old o ON l.canonical_name = o.canonical_name
JOIN canonical_items ci ON l.canonical_name = ci.name
ORDER BY ABS(pct_change) DESC
LIMIT $2
`, months, limit)
if err != nil {
return nil, fmt.Errorf("get top movers: %w", err)
}
defer rows.Close()
var movers []TopMover
for rows.Next() {
var m TopMover
if err := rows.Scan(&m.CanonicalName, &m.DisplayName, &m.Category,
&m.PriceThen, &m.PriceNow, &m.PctChange); err != nil {
return nil, err
}
movers = append(movers, m)
}
return movers, rows.Err()
}
// InflationSummary returns the overall purchasing power change since a base year.
type InflationSummary struct {
BaseYear int
CurrentYear int
BasketThen int // average cents across tracked items at base year
BasketNow int // average cents across tracked items now
PurchasingPower float64 // e.g. 0.58 means $1 in baseYear = $0.58 today
TotalPctChange float64 // e.g. 72.4 means prices are 72.4% higher
}
func (q *Queries) GetInflationSummary(ctx context.Context, baseYear int) (InflationSummary, error) {
var s InflationSummary
err := q.pool.QueryRow(ctx, `
WITH
base AS (
SELECT AVG(avg_price_cents)::int AS avg_price
FROM price_snapshots
WHERE EXTRACT(YEAR FROM year_month) = $1
),
current AS (
SELECT AVG(avg_price_cents)::int AS avg_price
FROM price_snapshots
WHERE year_month >= date_trunc('year', now()) - interval '1 year'
)
SELECT
$1,
EXTRACT(YEAR FROM now())::int,
base.avg_price,
current.avg_price,
ROUND((base.avg_price::numeric / current.avg_price) * 100, 1),
ROUND(((current.avg_price - base.avg_price)::numeric / base.avg_price) * 100, 1)
FROM base, current
`, baseYear).Scan(
&s.BaseYear, &s.CurrentYear,
&s.BasketThen, &s.BasketNow,
&s.PurchasingPower, &s.TotalPctChange,
)
if err != nil {
return InflationSummary{}, fmt.Errorf("get inflation summary: %w", err)
}
return s, nil
}
// RebuildSnapshots re-aggregates all line_items into price_snapshots.
// Run this as a nightly cron job or after a batch of new submissions.
func (q *Queries) RebuildSnapshots(ctx context.Context) error {
_, err := q.pool.Exec(ctx, `
INSERT INTO price_snapshots (canonical_name, year_month, avg_price_cents, sample_count)
SELECT
canonical_name,
date_trunc('month', r.receipt_date)::date AS year_month,
ROUND(AVG(li.unit_price_cents))::int AS avg_price_cents,
COUNT(*) AS sample_count
FROM line_items li
JOIN receipts r ON li.receipt_id = r.id
WHERE li.canonical_name IS NOT NULL
GROUP BY canonical_name, year_month
ON CONFLICT (canonical_name, year_month)
DO UPDATE SET
avg_price_cents = EXCLUDED.avg_price_cents,
sample_count = EXCLUDED.sample_count
`)
if err != nil {
return fmt.Errorf("rebuild snapshots: %w", err)
}
return nil
}
+21
View File
@@ -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})
}
+79
View File
@@ -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)
}
+193
View File
@@ -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
}
+143
View File
@@ -0,0 +1,143 @@
package inflation
import (
"strings"
"unicode"
)
// Matcher maps raw receipt item names to canonical item names.
// This is the "normalization" problem — "1 gal whole milk" and
// "MILK WHL GAL" should both resolve to "milk_whole_1gal".
//
// Start simple: exact alias lookup + token overlap.
// Later: replace with embeddings or a fuzzy search library.
type Matcher struct {
// aliasMap maps lowercase alias string -> canonical name
aliasMap map[string]string
}
// knownAliases is the seed list. In production, load these from the
// canonical_items table's aliases column at startup.
var knownAliases = map[string]string{
// Milk
"whole milk gallon": "milk_whole_1gal",
"1 gal whole milk": "milk_whole_1gal",
"milk whl gal": "milk_whole_1gal",
"whole milk 1gal": "milk_whole_1gal",
"milk whole": "milk_whole_1gal",
// Eggs
"large eggs 12ct": "eggs_large_dozen",
"eggs large dozen": "eggs_large_dozen",
"grade a large eggs": "eggs_large_dozen",
"eggs lg 12": "eggs_large_dozen",
"large eggs": "eggs_large_dozen",
// Bread
"white bread": "bread_white_loaf",
"sandwich bread": "bread_white_loaf",
"bread loaf": "bread_white_loaf",
"white bread loaf": "bread_white_loaf",
// Ground beef
"ground beef lb": "ground_beef_1lb",
"80/20 ground beef": "ground_beef_1lb",
"hamburger meat": "ground_beef_1lb",
"ground beef": "ground_beef_1lb",
// Olive oil
"olive oil 16oz": "olive_oil_16oz",
"extra virgin olive oil": "olive_oil_16oz",
"evoo 16oz": "olive_oil_16oz",
"olive oil": "olive_oil_16oz",
// Butter
"butter salted pound": "butter_salted_1lb",
"salted butter 4 sticks": "butter_salted_1lb",
"salted butter": "butter_salted_1lb",
// Chicken
"boneless chicken breast": "chicken_breast_1lb",
"chicken breast lb": "chicken_breast_1lb",
"chicken breast": "chicken_breast_1lb",
// OJ
"oj 52oz": "orange_juice_52oz",
"orange juice carton": "orange_juice_52oz",
"orange juice": "orange_juice_52oz",
}
func NewMatcher() *Matcher {
return &Matcher{aliasMap: knownAliases}
}
// Match tries to find a canonical name for a raw receipt string.
// Returns empty string if no match is found — unmatched items are stored
// with canonical_name = NULL and can be reviewed/matched later.
func (m *Matcher) Match(raw string) string {
normalized := normalize(raw)
// 1. Exact alias match (fastest)
if canonical, ok := m.aliasMap[normalized]; ok {
return canonical
}
// 2. Substring match — if any alias is contained in the raw text
for alias, canonical := range m.aliasMap {
if strings.Contains(normalized, alias) {
return canonical
}
}
// 3. Token overlap — split both into words and count shared tokens
// This catches "MILK WHOLE 1 GAL" matching "whole milk gallon"
rawTokens := tokenize(normalized)
bestScore := 0
bestMatch := ""
for alias, canonical := range m.aliasMap {
aliasTokens := tokenize(alias)
score := tokenOverlap(rawTokens, aliasTokens)
// Require matching at least 2 tokens and >50% of alias tokens
if score >= 2 && score > bestScore && float64(score)/float64(len(aliasTokens)) > 0.5 {
bestScore = score
bestMatch = canonical
}
}
return bestMatch
}
// normalize lowercases and strips punctuation/extra whitespace.
func normalize(s string) string {
s = strings.ToLower(s)
var b strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '/' {
b.WriteRune(r)
} else {
b.WriteRune(' ')
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
// tokenize splits a normalized string into unique words.
func tokenize(s string) []string {
return strings.Fields(s)
}
// tokenOverlap counts how many words from a appear in b.
func tokenOverlap(a, b []string) int {
set := make(map[string]bool, len(b))
for _, t := range b {
set[t] = true
}
count := 0
for _, t := range a {
if set[t] {
count++
}
}
return count
}
+75
View File
@@ -0,0 +1,75 @@
package inflation
import (
"testing"
)
// This is your first Go test file!
// Run with: go test ./internal/inflation/...
// Go's testing package is built-in — no extra library needed.
func TestMatcher_ExactMatch(t *testing.T) {
m := NewMatcher()
tests := []struct {
raw string
expected string
}{
{"whole milk gallon", "milk_whole_1gal"},
{"large eggs 12ct", "eggs_large_dozen"},
{"white bread", "bread_white_loaf"},
{"ground beef", "ground_beef_1lb"},
}
for _, tt := range tests {
t.Run(tt.raw, func(t *testing.T) {
got := m.Match(tt.raw)
if got != tt.expected {
t.Errorf("Match(%q) = %q, want %q", tt.raw, got, tt.expected)
}
})
}
}
func TestMatcher_CaseInsensitive(t *testing.T) {
m := NewMatcher()
got := m.Match("WHOLE MILK GALLON")
if got != "milk_whole_1gal" {
t.Errorf("expected milk_whole_1gal, got %q", got)
}
}
func TestMatcher_TokenOverlap(t *testing.T) {
m := NewMatcher()
// "MILK WHL 1 GAL" should still match via token overlap
got := m.Match("MILK WHL 1 GAL")
if got != "milk_whole_1gal" {
t.Logf("Note: token overlap match returned %q (may need alias tuning)", got)
}
}
func TestMatcher_NoMatch(t *testing.T) {
m := NewMatcher()
got := m.Match("toilet paper mega roll 12ct")
if got != "" {
t.Errorf("expected no match, got %q", got)
}
}
func TestNormalize(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"Whole Milk, 1 Gal.", "whole milk 1 gal"},
{"EGGS (LARGE) 12CT", "eggs large 12ct"},
{"80/20 Ground Beef", "80/20 ground beef"},
}
for _, tt := range tests {
got := normalize(tt.input)
if got != tt.expected {
t.Errorf("normalize(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}
+44
View File
@@ -0,0 +1,44 @@
// Package inflation contains the background worker that refreshes
// the price_snapshots table used by the dashboard charts.
package inflation
import (
"context"
"log"
"time"
"github.com/yourname/deflated/internal/db"
)
// StartRefreshWorker runs in a goroutine and refreshes price snapshots
// every interval. Call this from main() after connecting to the database.
//
// Example:
//
// go inflation.StartRefreshWorker(ctx, queries, 1*time.Hour)
func StartRefreshWorker(ctx context.Context, q *db.Queries, interval time.Duration) {
// Run once immediately on startup so the charts aren't empty
runRefresh(ctx, q)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
runRefresh(ctx, q)
case <-ctx.Done():
log.Println("inflation refresh worker stopped")
return
}
}
}
func runRefresh(ctx context.Context, q *db.Queries) {
start := time.Now()
if err := q.RefreshPriceSnapshots(ctx); err != nil {
log.Printf("error refreshing price snapshots: %v", err)
return
}
log.Printf("price snapshots refreshed in %s", time.Since(start).Round(time.Millisecond))
}
+110
View File
@@ -0,0 +1,110 @@
// Package models defines the core data structures shared across the app.
// These are plain Go structs — no ORM tags, no magic.
package models
import (
"time"
"github.com/google/uuid"
)
// ── Database models (match table columns exactly) ─────────────────────────────
type Receipt struct {
ID uuid.UUID `json:"id"`
StoreName *string `json:"store_name"`
ReceiptDate time.Time `json:"receipt_date"`
ImageURL *string `json:"image_url"`
City *string `json:"city"`
SubmittedAt time.Time `json:"submitted_at"`
}
type LineItem struct {
ID uuid.UUID `json:"id"`
ReceiptID uuid.UUID `json:"receipt_id"`
RawName string `json:"raw_name"`
CanonicalName *string `json:"canonical_name"`
Category *string `json:"category"`
PriceCents int `json:"price_cents"`
Quantity float64 `json:"quantity"`
}
type PriceSnapshot struct {
CanonicalName string `json:"canonical_name"`
YearMonth time.Time `json:"year_month"`
AvgPriceCents int `json:"avg_price_cents"`
SampleCount int `json:"sample_count"`
}
type PriceMover struct {
CanonicalName string `json:"canonical_name"`
CurrentPriceCents int `json:"current_price_cents"`
PrevPriceCents int `json:"prev_price_cents"`
PctChange float64 `json:"pct_change"`
}
type InflationSummary struct {
FromDate time.Time `json:"from_date"`
ToDate time.Time `json:"to_date"`
PurchasingPower float64 `json:"purchasing_power"` // e.g. 58.3 means $1 → $0.583
ItemCount int `json:"item_count"`
}
// ── Insert params (what the API layer passes to db.Queries) ───────────────────
type InsertReceiptParams struct {
StoreName *string
ReceiptDate time.Time
ImageURL *string
City *string
}
type InsertLineItemParams struct {
ReceiptID uuid.UUID
RawName string
CanonicalName *string
Category *string
PriceCents int
Quantity float64
}
// ── API request/response shapes ───────────────────────────────────────────────
// SubmitReceiptRequest is the JSON body (or form data) for POST /api/receipts
type SubmitReceiptRequest struct {
StoreName string `json:"store_name"`
ReceiptDate string `json:"receipt_date"` // ISO 8601: "2024-03-15"
City string `json:"city"`
Items []ItemInput `json:"items"`
}
type ItemInput struct {
Name string `json:"name"` // raw name as on the receipt
Price float64 `json:"price"` // in dollars, e.g. 3.99
Quantity float64 `json:"quantity"` // default 1
}
// SubmitReceiptResponse is returned after a successful submission
type SubmitReceiptResponse struct {
ReceiptID uuid.UUID `json:"receipt_id"`
ItemsAdded int `json:"items_added"`
Message string `json:"message"`
}
// PriceHistoryResponse is returned by GET /api/items/:name/history
type PriceHistoryResponse struct {
CanonicalName string `json:"canonical_name"`
DataPoints []PriceDataPoint `json:"data_points"`
}
type PriceDataPoint struct {
Month string `json:"month"` // "2024-03"
AvgPrice float64 `json:"avg_price"` // in dollars
SampleCount int `json:"sample_count"`
}
// ErrorResponse is the standard shape for all API errors
type ErrorResponse struct {
Error string `json:"error"`
Details string `json:"details,omitempty"`
}
+99
View File
@@ -0,0 +1,99 @@
// Package parser handles normalizing raw receipt item names into
// canonical identifiers that can be compared across submissions.
//
// Phase 1: simple rule-based lookup (good enough to ship)
// Phase 2: fuzzy matching + embeddings (future upgrade)
package parser
import (
"strings"
)
// entry maps keywords (found in the raw name) to a canonical ID and category.
type entry struct {
canonical string
category string
}
// knownItems is the canonical item dictionary.
// Key: lowercase substring that must appear in the raw name.
// Expand this as you see patterns in submissions.
var knownItems = []struct {
keywords []string // ALL must be present (AND logic)
canonical string
category string
}{
{[]string{"milk", "whole"}, "milk_whole_1gal", "dairy"},
{[]string{"milk", "2%"}, "milk_2pct_1gal", "dairy"},
{[]string{"milk", "skim"}, "milk_skim_1gal", "dairy"},
{[]string{"egg"}, "eggs_large_dozen", "dairy"},
{[]string{"butter", "unsalted"}, "butter_unsalted_1lb", "dairy"},
{[]string{"butter"}, "butter_salted_1lb", "dairy"},
{[]string{"cheddar"}, "cheese_cheddar_8oz", "dairy"},
{[]string{"bread", "white"}, "bread_white_loaf", "bakery"},
{[]string{"bread", "wheat"}, "bread_wheat_loaf", "bakery"},
{[]string{"bread", "sourdough"}, "bread_sourdough_loaf", "bakery"},
{[]string{"ground beef"}, "ground_beef_1lb", "meat"},
{[]string{"chicken breast"}, "chicken_breast_1lb", "meat"},
{[]string{"salmon"}, "salmon_fillet_1lb", "seafood"},
{[]string{"apple"}, "apples_bag", "produce"},
{[]string{"banana"}, "bananas_1lb", "produce"},
{[]string{"orange"}, "oranges_bag", "produce"},
{[]string{"tomato"}, "tomatoes_1lb", "produce"},
{[]string{"potato"}, "potatoes_5lb", "produce"},
{[]string{"onion"}, "onions_3lb", "produce"},
{[]string{"garlic"}, "garlic_head", "produce"},
{[]string{"spinach"}, "spinach_5oz", "produce"},
{[]string{"broccoli"}, "broccoli_head", "produce"},
{[]string{"olive oil"}, "olive_oil_16oz", "pantry"},
{[]string{"vegetable oil"}, "vegetable_oil_48oz", "pantry"},
{[]string{"flour", "all-purpose"}, "flour_allpurpose_5lb", "pantry"},
{[]string{"sugar", "white"}, "sugar_white_4lb", "pantry"},
{[]string{"sugar"}, "sugar_white_4lb", "pantry"},
{[]string{"salt"}, "salt_table_26oz", "pantry"},
{[]string{"rice"}, "rice_white_2lb", "pantry"},
{[]string{"pasta"}, "pasta_spaghetti_1lb", "pantry"},
{[]string{"coffee"}, "coffee_ground_12oz", "pantry"},
{[]string{"orange juice"}, "orange_juice_52oz", "beverages"},
{[]string{"water", "gallon"}, "water_gallon", "beverages"},
}
// Normalize attempts to map a raw item name to a canonical identifier.
// Returns (nil, nil) if no match is found — the item is stored raw only.
func Normalize(rawName string) (*string, *string) {
lower := strings.ToLower(rawName)
for _, rule := range knownItems {
if matchesAll(lower, rule.keywords) {
c := rule.canonical
cat := rule.category
return &c, &cat
}
}
return nil, nil
}
// matchesAll returns true if s contains every keyword in the list.
func matchesAll(s string, keywords []string) bool {
for _, kw := range keywords {
if !strings.Contains(s, kw) {
return false
}
}
return true
}
// ── Future: fuzzy matching ─────────────────────────────────────────────────────
//
// When the rule list grows unwieldy, replace Normalize with an embeddings
// approach: encode the raw name with a sentence transformer, find the nearest
// canonical item by cosine similarity. The go-faiss library or a simple
// Postgres pgvector extension both work well for this.
//
// For now, ship the rule-based version. Add to knownItems as you see misses
// in production by querying: SELECT raw_name, COUNT(*) FROM line_items
// WHERE canonical_name IS NULL GROUP BY raw_name ORDER BY count DESC;
+71
View File
@@ -0,0 +1,71 @@
-- Migration 001: initial schema
-- Run with: psql $DATABASE_URL -f migrations/001_initial.sql
-- Enable the uuid-ossp extension for gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ── receipts ─────────────────────────────────────────────────────────────────
-- One row per submitted receipt image/form
CREATE TABLE IF NOT EXISTS receipts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_name TEXT,
receipt_date DATE NOT NULL,
image_url TEXT, -- S3/R2 URL after upload
city TEXT,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS receipts_date_idx ON receipts (receipt_date);
-- ── line_items ────────────────────────────────────────────────────────────────
-- Individual products parsed from a receipt
CREATE TABLE IF NOT EXISTS line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
receipt_id UUID NOT NULL REFERENCES receipts(id) ON DELETE CASCADE,
raw_name TEXT NOT NULL, -- exactly as on the receipt
canonical_name TEXT, -- normalized key, e.g. "milk_whole_1gal"
category TEXT, -- "dairy", "produce", etc.
price_cents INT NOT NULL CHECK (price_cents > 0),
quantity NUMERIC NOT NULL DEFAULT 1 CHECK (quantity > 0)
);
CREATE INDEX IF NOT EXISTS line_items_receipt_idx ON line_items (receipt_id);
CREATE INDEX IF NOT EXISTS line_items_canonical_idx ON line_items (canonical_name);
-- ── price_snapshots ───────────────────────────────────────────────────────────
-- Pre-aggregated monthly averages — rebuilt by the Go worker every hour.
-- The frontend reads from here, never from line_items directly.
CREATE TABLE IF NOT EXISTS price_snapshots (
canonical_name TEXT NOT NULL,
year_month DATE NOT NULL, -- truncated to first of the month
avg_price_cents INT NOT NULL,
sample_count INT NOT NULL DEFAULT 0,
PRIMARY KEY (canonical_name, year_month)
);
-- ── canonical_items ───────────────────────────────────────────────────────────
-- Registry of known items and their human-readable display names.
-- Populated by hand / future admin UI.
CREATE TABLE IF NOT EXISTS canonical_items (
id TEXT PRIMARY KEY, -- e.g. "milk_whole_1gal"
display_name TEXT NOT NULL, -- "Whole Milk (1 gal)"
category TEXT NOT NULL,
unit TEXT, -- "gallon", "dozen", "lb"
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed some known items
INSERT INTO canonical_items (id, display_name, category, unit) VALUES
('milk_whole_1gal', 'Whole Milk', 'dairy', 'gallon'),
('milk_2pct_1gal', '2% Milk', 'dairy', 'gallon'),
('eggs_large_dozen', 'Eggs (large, dozen)', 'dairy', 'dozen'),
('butter_salted_1lb', 'Butter (salted)', 'dairy', 'lb'),
('bread_white_loaf', 'White Bread', 'bakery', 'loaf'),
('ground_beef_1lb', 'Ground Beef', 'meat', 'lb'),
('chicken_breast_1lb', 'Chicken Breast', 'meat', 'lb'),
('olive_oil_16oz', 'Olive Oil', 'pantry', '16oz'),
('rice_white_2lb', 'White Rice', 'pantry', '2lb bag'),
('apples_bag', 'Apples', 'produce', 'bag'),
('bananas_1lb', 'Bananas', 'produce', 'lb'),
('potatoes_5lb', 'Potatoes', 'produce', '5lb bag')
ON CONFLICT (id) DO NOTHING;