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 }