- 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
324 lines
9.7 KiB
Go
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()
|
|
}
|