inboxer/internal/database/db.go
cclohmar ca970104ee chore: initial commit — ExpenseFlow AI-Powered Expense Tracker
- Passwordless email OTP authentication
- Event-based expense tracking with HTMX UI
- AI receipt extraction via DeepSeek Vision API
- CSV/PDF report generation with email filing
- PWA with service worker and manifest
- Mobile-first responsive design
- SQLite database with auto-migration
2026-05-29 19:43:30 +00:00

324 lines
9.7 KiB
Go

// 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()
}