package db import ( "database/sql" _ "embed" "fmt" _ "modernc.org/sqlite" ) //go:embed schema.sql var schemaSQL string func Open(path string) (*sql.DB, error) { dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)", path) conn, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } if err := conn.Ping(); err != nil { conn.Close() return nil, fmt.Errorf("ping sqlite: %w", err) } if _, err := conn.Exec(schemaSQL); err != nil { conn.Close() return nil, fmt.Errorf("apply schema: %w", err) } if err := addColumnIfMissing(conn, "items", "min_price", "REAL"); err != nil { conn.Close() return nil, err } if err := addColumnIfMissing(conn, "items", "exclude_keywords", "TEXT"); err != nil { conn.Close() return nil, err } if err := addColumnIfMissing(conn, "results", "matched_query", "TEXT"); err != nil { conn.Close() return nil, err } if err := addColumnIfMissing(conn, "items", "condition", "TEXT"); err != nil { conn.Close() return nil, err } if err := addColumnIfMissing(conn, "items", "region", "TEXT"); err != nil { conn.Close() return nil, err } if err := addColumnIfMissing(conn, "items", "best_price_currency", "TEXT"); err != nil { conn.Close() return nil, err } if err := addColumnIfMissing(conn, "results", "ends_at", "DATETIME"); err != nil { conn.Close() return nil, err } // Per-user item ownership. On a migrated DB the column is added without a // REFERENCES clause (ALTER TABLE ADD COLUMN can't carry one cleanly), then // orphaned items are assigned to the first admin (or, failing that, the // first user) so the operator keeps their existing watchlist. Fresh DBs get // the FK from schema.sql and have no rows to backfill. if err := addColumnIfMissing(conn, "items", "user_id", "INTEGER"); err != nil { conn.Close() return nil, err } if _, err := conn.Exec(` UPDATE items SET user_id = ( SELECT id FROM users ORDER BY (role = 'admin') DESC, id ASC LIMIT 1 ) WHERE user_id IS NULL AND EXISTS (SELECT 1 FROM users) `); err != nil { conn.Close() return nil, fmt.Errorf("backfill item ownership: %w", err) } if _, err := conn.Exec(`CREATE INDEX IF NOT EXISTS idx_items_user ON items(user_id)`); err != nil { conn.Close() return nil, fmt.Errorf("create items user index: %w", err) } // Multi-user / email columns (Authentik forward-auth + Resend mail). for _, c := range []struct{ col, typ string }{ {"email", "TEXT"}, {"auth_source", "TEXT NOT NULL DEFAULT 'local'"}, {"email_deal_alerts", "INTEGER NOT NULL DEFAULT 0"}, {"email_weekly_digest", "INTEGER NOT NULL DEFAULT 0"}, } { if err := addColumnIfMissing(conn, "users", c.col, c.typ); err != nil { conn.Close() return nil, err } } // Email is the identity key for forward-auth (Authentik) and the Resend // destination. Partial unique index: many local users may have no email // (NULL/'') while any populated address stays unique. Created here, after // the email column exists, so it works on both fresh and migrated DBs. if _, err := conn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL AND email != ''`); err != nil { conn.Close() return nil, fmt.Errorf("create users email index: %w", err) } return conn, nil } func addColumnIfMissing(conn *sql.DB, table, column, typ string) error { rows, err := conn.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, table)) if err != nil { return fmt.Errorf("inspect %s: %w", table, err) } defer rows.Close() for rows.Next() { var cid int var name, ctype string var notnull, pk int var dflt sql.NullString if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { return err } if name == column { return nil } } if _, err := conn.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, column, typ)); err != nil { return fmt.Errorf("add column %s.%s: %w", table, column, err) } return nil }