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 }