// Package database provides SQLite initialization and query helpers for ExpenseFlow. // // It auto-creates the database file and all required tables on startup, // and exports a shared DB handle for use by other packages. package database import ( "database/sql" "log" "time" _ "github.com/mattn/go-sqlite3" ) // DB is the shared database handle, initialized by Init(). var DB *sql.DB // --------------------------------------------------------------------------- // Struct types // --------------------------------------------------------------------------- // User represents a row in the users table. type User struct { ID string Email string CreatedAt string } // OTP represents a row in the auth_otps table. type OTP struct { Email string OTPCode string ExpiresAt string } // Event represents a row in the events table. type Event struct { ID string UserID string Name string Status string CreatedAt string } // Expense represents a row in the expenses table. type Expense struct { ID string EventID string Amount float64 Currency string Merchant string Category string Description string Date string ImagePath string CreatedAt string } // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- // Init opens (or creates) expenses.db, configures the connection pool for // SQLite safety, and runs the DDL statements for all required tables. // It also sets the package-level DB variable for shared use. func Init() (*sql.DB, error) { var err error DB, err = sql.Open("sqlite3", "expenses.db") if err != nil { log.Printf("ERROR [%s] database: failed to open: %v", time.Now().Format(time.RFC3339), err) return nil, err } // SQLite does not support concurrent writes; limit to one connection. DB.SetMaxOpenConns(1) if err = createTables(DB); err != nil { log.Printf("ERROR [%s] database: table creation failed: %v", time.Now().Format(time.RFC3339), err) return nil, err } log.Printf("INFO [%s] database: initialized successfully", time.Now().Format(time.RFC3339)) return DB, nil } // createTables executes the DDL statements for all four tables. func createTables(db *sql.DB) error { statements := []string{ `CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS auth_otps ( email TEXT PRIMARY KEY, otp_code TEXT NOT NULL, expires_at DATETIME NOT NULL )`, `CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) )`, `CREATE TABLE IF NOT EXISTS expenses ( id TEXT PRIMARY KEY, event_id TEXT NOT NULL, amount REAL NOT NULL, currency TEXT NOT NULL, merchant TEXT NOT NULL, category TEXT NOT NULL, description TEXT, date TEXT NOT NULL, image_path TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE )`, } for _, stmt := range statements { if _, err := db.Exec(stmt); err != nil { return err } } return nil } // --------------------------------------------------------------------------- // User queries // --------------------------------------------------------------------------- // CreateUser inserts a new user row. func CreateUser(db *sql.DB, id, email string) error { _, err := db.Exec( "INSERT INTO users (id, email) VALUES (?, ?)", id, email, ) if err != nil { log.Printf("ERROR [%s] database: CreateUser(%s, %s): %v", time.Now().Format(time.RFC3339), id, email, err) } return err } // GetUserByEmail returns the user with the given email, or nil if not found. func GetUserByEmail(db *sql.DB, email string) (*User, error) { row := db.QueryRow("SELECT id, email, created_at FROM users WHERE email = ?", email) u := &User{} if err := row.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil { if err == sql.ErrNoRows { return nil, nil } log.Printf("ERROR [%s] database: GetUserByEmail(%s): %v", time.Now().Format(time.RFC3339), email, err) return nil, err } return u, nil } // --------------------------------------------------------------------------- // OTP queries // --------------------------------------------------------------------------- // SaveOTP upserts an OTP record for the given email. func SaveOTP(db *sql.DB, email, code, expiresAt string) error { _, err := db.Exec( `INSERT INTO auth_otps (email, otp_code, expires_at) VALUES (?, ?, ?) ON CONFLICT(email) DO UPDATE SET otp_code = excluded.otp_code, expires_at = excluded.expires_at`, email, code, expiresAt, ) if err != nil { log.Printf("ERROR [%s] database: SaveOTP(%s): %v", time.Now().Format(time.RFC3339), email, err) } return err } // GetOTP returns the OTP record for the given email, or nil if not found. func GetOTP(db *sql.DB, email string) (*OTP, error) { row := db.QueryRow("SELECT email, otp_code, expires_at FROM auth_otps WHERE email = ?", email) o := &OTP{} if err := row.Scan(&o.Email, &o.OTPCode, &o.ExpiresAt); err != nil { if err == sql.ErrNoRows { return nil, nil } log.Printf("ERROR [%s] database: GetOTP(%s): %v", time.Now().Format(time.RFC3339), email, err) return nil, err } return o, nil } // DeleteOTP removes the OTP record for the given email. func DeleteOTP(db *sql.DB, email string) error { _, err := db.Exec("DELETE FROM auth_otps WHERE email = ?", email) if err != nil { log.Printf("ERROR [%s] database: DeleteOTP(%s): %v", time.Now().Format(time.RFC3339), email, err) } return err } // --------------------------------------------------------------------------- // Event queries // --------------------------------------------------------------------------- // CreateEvent inserts a new event row. func CreateEvent(db *sql.DB, id, userID, name string) error { _, err := db.Exec( "INSERT INTO events (id, user_id, name) VALUES (?, ?, ?)", id, userID, name, ) if err != nil { log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s): %v", time.Now().Format(time.RFC3339), id, userID, name, err) } return err } // GetEventsByUser returns all events belonging to a user, ordered by creation date descending. func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) { rows, err := db.Query( "SELECT id, user_id, name, status, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC", userID, ) if err != nil { log.Printf("ERROR [%s] database: GetEventsByUser(%s): %v", time.Now().Format(time.RFC3339), userID, err) return nil, err } defer rows.Close() var events []Event for rows.Next() { var e Event if err := rows.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil { log.Printf("ERROR [%s] database: GetEventsByUser scan: %v", time.Now().Format(time.RFC3339), err) return nil, err } events = append(events, e) } return events, rows.Err() } // GetEventByID returns a single event by ID, or nil if not found. func GetEventByID(db *sql.DB, id string) (*Event, error) { row := db.QueryRow("SELECT id, user_id, name, status, created_at FROM events WHERE id = ?", id) e := &Event{} if err := row.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil { if err == sql.ErrNoRows { return nil, nil } log.Printf("ERROR [%s] database: GetEventByID(%s): %v", time.Now().Format(time.RFC3339), id, err) return nil, err } return e, nil } // UpdateEventStatus changes the status of an event (open/closed). func UpdateEventStatus(db *sql.DB, id, status string) error { _, err := db.Exec("UPDATE events SET status = ? WHERE id = ?", status, id) if err != nil { log.Printf("ERROR [%s] database: UpdateEventStatus(%s, %s): %v", time.Now().Format(time.RFC3339), id, status, err) } return err } // --------------------------------------------------------------------------- // Expense queries // --------------------------------------------------------------------------- // CreateExpense inserts a new expense row from the provided Expense struct. // The expense's ID, EventID, and other fields must be set by the caller. func CreateExpense(db *sql.DB, expense Expense) error { _, err := db.Exec( `INSERT INTO expenses (id, event_id, amount, currency, merchant, category, description, date, image_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, expense.ID, expense.EventID, expense.Amount, expense.Currency, expense.Merchant, expense.Category, expense.Description, expense.Date, expense.ImagePath, ) if err != nil { log.Printf("ERROR [%s] database: CreateExpense(%s): %v", time.Now().Format(time.RFC3339), expense.ID, err) } return err } // GetExpensesByEvent returns all expenses for a given event, ordered by creation date descending. func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) { rows, err := db.Query( `SELECT id, event_id, amount, currency, merchant, category, COALESCE(description, ''), date, image_path, created_at FROM expenses WHERE event_id = ? ORDER BY created_at DESC`, eventID, ) if err != nil { log.Printf("ERROR [%s] database: GetExpensesByEvent(%s): %v", time.Now().Format(time.RFC3339), eventID, err) return nil, err } defer rows.Close() var expenses []Expense for rows.Next() { var e Expense if err := rows.Scan( &e.ID, &e.EventID, &e.Amount, &e.Currency, &e.Merchant, &e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt, ); err != nil { log.Printf("ERROR [%s] database: GetExpensesByEvent scan: %v", time.Now().Format(time.RFC3339), err) return nil, err } expenses = append(expenses, e) } return expenses, rows.Err() }