- Restructure hierarchy: Month → Event → Expense (new months table, FK) - Add MonthHandler with CRUD, monthly reports, dropdown create form - Events now scoped under months with extended ownership chain - AI extraction: 16 specific expense categories (Airfare, Meals, etc.) - UI: category dropdown, button-consistent cards, centered mobile shell on desktop - Dashboard/month views show claim totals per card - Description field now mandatory, forms simplified - Months sorted by name chronologically (latest first)
748 lines
24 KiB
Go
748 lines
24 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"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// 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
|
|
Name string
|
|
Department string
|
|
Onboarded bool
|
|
CreatedAt string
|
|
}
|
|
|
|
// OTP represents a row in the auth_otps table.
|
|
type OTP struct {
|
|
Email string
|
|
OTPCode string
|
|
ExpiresAt string
|
|
}
|
|
|
|
// Month represents a row in the months table.
|
|
type Month struct {
|
|
ID string
|
|
UserID string
|
|
Name string
|
|
CreatedAt string
|
|
}
|
|
|
|
// Event represents a row in the events table.
|
|
type Event struct {
|
|
ID string
|
|
UserID string
|
|
MonthID string
|
|
Name string
|
|
Status string
|
|
BaseCurrency string
|
|
ExchangeRate float64
|
|
CreatedAt string
|
|
}
|
|
|
|
// Expense represents a row in the expenses table.
|
|
type Expense struct {
|
|
ID string
|
|
EventID string
|
|
Amount float64
|
|
Currency string
|
|
ConvertedAmount float64
|
|
BaseCurrency string
|
|
Merchant string
|
|
Category string
|
|
Description string
|
|
Date string
|
|
ImagePath string
|
|
CreatedAt string
|
|
}
|
|
|
|
// DownloadToken represents a download token for a generated report package.
|
|
type DownloadToken struct {
|
|
Token string
|
|
EventID string
|
|
Filename string
|
|
CreatedAt string
|
|
ExpiresAt string
|
|
Accessed bool
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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("sqlite", "expenses.db")
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: failed to open: %v", time.Now().Format(time.RFC3339), err)
|
|
return nil, err
|
|
}
|
|
|
|
// modernc.org/sqlite supports concurrent reads.
|
|
// A small pool handles HTMX concurrent requests efficiently.
|
|
DB.SetMaxOpenConns(4)
|
|
DB.SetMaxIdleConns(2)
|
|
|
|
if err = createTables(DB); err != nil {
|
|
log.Printf("ERROR [%s] database: table creation failed: %v", time.Now().Format(time.RFC3339), err)
|
|
return nil, err
|
|
}
|
|
|
|
// Run schema migrations for existing databases.
|
|
if err = migrateTables(DB); err != nil {
|
|
log.Printf("ERROR [%s] database: migration 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 tables.
|
|
func createTables(db *sql.DB) error {
|
|
statements := []string{
|
|
`CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
email TEXT UNIQUE NOT NULL,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
department TEXT NOT NULL DEFAULT '',
|
|
onboarded INTEGER NOT NULL DEFAULT 0,
|
|
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 months (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS events (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
month_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open',
|
|
base_currency TEXT NOT NULL DEFAULT 'EUR',
|
|
exchange_rate REAL NOT NULL DEFAULT 1.0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY(user_id) REFERENCES users(id),
|
|
FOREIGN KEY(month_id) REFERENCES months(id) ON DELETE CASCADE
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS expenses (
|
|
id TEXT PRIMARY KEY,
|
|
event_id TEXT NOT NULL,
|
|
amount REAL NOT NULL,
|
|
currency TEXT NOT NULL,
|
|
converted_amount REAL NOT NULL DEFAULT 0,
|
|
base_currency TEXT NOT NULL DEFAULT 'EUR',
|
|
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
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS download_tokens (
|
|
token TEXT PRIMARY KEY,
|
|
event_id TEXT NOT NULL,
|
|
filename TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at DATETIME NOT NULL,
|
|
accessed INTEGER NOT NULL DEFAULT 0
|
|
)`,
|
|
}
|
|
|
|
for _, stmt := range statements {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// migrateTables applies schema changes to existing databases that were
|
|
// created before the current version. Each migration is idempotent —
|
|
// errors from ALTER TABLE (e.g. column already exists) are ignored.
|
|
func migrateTables(db *sql.DB) error {
|
|
migrations := []string{
|
|
"ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT ''",
|
|
"ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''",
|
|
"ALTER TABLE users ADD COLUMN onboarded INTEGER NOT NULL DEFAULT 0",
|
|
"ALTER TABLE events ADD COLUMN month_id TEXT NOT NULL DEFAULT ''",
|
|
}
|
|
|
|
for _, stmt := range migrations {
|
|
db.Exec(stmt) // ignore errors — columns may already exist
|
|
}
|
|
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, name, department, onboarded, created_at FROM users WHERE email = ?", email)
|
|
u := &User{}
|
|
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Department, &u.Onboarded, &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
|
|
}
|
|
|
|
// GetUserByID returns the user with the given ID, or nil if not found.
|
|
func GetUserByID(db *sql.DB, id string) (*User, error) {
|
|
row := db.QueryRow("SELECT id, email, name, department, onboarded, created_at FROM users WHERE id = ?", id)
|
|
u := &User{}
|
|
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Department, &u.Onboarded, &u.CreatedAt); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
log.Printf("ERROR [%s] database: GetUserByID(%s): %v",
|
|
time.Now().Format(time.RFC3339), id, err)
|
|
return nil, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// UpdateUserOnboarding saves the user's name and department and marks them as onboarded.
|
|
func UpdateUserOnboarding(db *sql.DB, userID, name, department string) error {
|
|
_, err := db.Exec(
|
|
"UPDATE users SET name = ?, department = ?, onboarded = 1 WHERE id = ?",
|
|
name, department, userID,
|
|
)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: UpdateUserOnboarding(%s): %v",
|
|
time.Now().Format(time.RFC3339), userID, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Month queries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// CreateMonth inserts a new month row.
|
|
func CreateMonth(db *sql.DB, id, userID, name string) error {
|
|
_, err := db.Exec(
|
|
"INSERT INTO months (id, user_id, name) VALUES (?, ?, ?)",
|
|
id, userID, name,
|
|
)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: CreateMonth(%s, %s, %s): %v",
|
|
time.Now().Format(time.RFC3339), id, userID, name, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// GetMonthsByUser returns all months belonging to a user, ordered by creation date descending.
|
|
func GetMonthsByUser(db *sql.DB, userID string) ([]Month, error) {
|
|
rows, err := db.Query(
|
|
"SELECT id, user_id, name, created_at FROM months WHERE user_id = ? ORDER BY created_at DESC",
|
|
userID,
|
|
)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: GetMonthsByUser(%s): %v",
|
|
time.Now().Format(time.RFC3339), userID, err)
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var months []Month
|
|
for rows.Next() {
|
|
var m Month
|
|
if err := rows.Scan(&m.ID, &m.UserID, &m.Name, &m.CreatedAt); err != nil {
|
|
log.Printf("ERROR [%s] database: GetMonthsByUser scan: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
return nil, err
|
|
}
|
|
months = append(months, m)
|
|
}
|
|
return months, rows.Err()
|
|
}
|
|
|
|
// GetMonthByID returns a single month by ID, or nil if not found.
|
|
func GetMonthByID(db *sql.DB, id string) (*Month, error) {
|
|
row := db.QueryRow("SELECT id, user_id, name, created_at FROM months WHERE id = ?", id)
|
|
m := &Month{}
|
|
if err := row.Scan(&m.ID, &m.UserID, &m.Name, &m.CreatedAt); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
log.Printf("ERROR [%s] database: GetMonthByID(%s): %v",
|
|
time.Now().Format(time.RFC3339), id, err)
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// UpdateMonth updates the name of an existing month.
|
|
func UpdateMonth(db *sql.DB, id, name string) error {
|
|
_, err := db.Exec("UPDATE months SET name = ? WHERE id = ?", name, id)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: UpdateMonth(%s): %v",
|
|
time.Now().Format(time.RFC3339), id, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// DeleteMonth removes a month and all its events (cascade deletes expenses via FK).
|
|
func DeleteMonth(db *sql.DB, id string) error {
|
|
_, err := db.Exec("DELETE FROM months WHERE id = ?", id)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: DeleteMonth(%s): %v",
|
|
time.Now().Format(time.RFC3339), id, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// GetMonthTotalClaim returns the sum of all converted_amounts across all
|
|
// events and expenses in a given month. Returns 0 if no expenses exist.
|
|
func GetMonthTotalClaim(db *sql.DB, monthID string) (float64, error) {
|
|
var total sql.NullFloat64
|
|
err := db.QueryRow(
|
|
`SELECT COALESCE(SUM(e.converted_amount), 0)
|
|
FROM expenses e
|
|
JOIN events ev ON e.event_id = ev.id
|
|
WHERE ev.month_id = ?`, monthID,
|
|
).Scan(&total)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if total.Valid {
|
|
return total.Float64, nil
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// GetEventTotalClaim returns the sum of all converted_amounts for a given event.
|
|
func GetEventTotalClaim(db *sql.DB, eventID string) (float64, error) {
|
|
var total sql.NullFloat64
|
|
err := db.QueryRow(
|
|
`SELECT COALESCE(SUM(converted_amount), 0) FROM expenses WHERE event_id = ?`, eventID,
|
|
).Scan(&total)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if total.Valid {
|
|
return total.Float64, nil
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Event queries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// CreateEvent inserts a new event row with optional base currency and exchange rate.
|
|
func CreateEvent(db *sql.DB, id, userID, monthID, name, baseCurrency string, exchangeRate float64) error {
|
|
if baseCurrency == "" {
|
|
baseCurrency = "EUR"
|
|
}
|
|
if exchangeRate <= 0 {
|
|
exchangeRate = 1.0
|
|
}
|
|
_, err := db.Exec(
|
|
"INSERT INTO events (id, user_id, month_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?, ?)",
|
|
id, userID, monthID, name, baseCurrency, exchangeRate,
|
|
)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %s, %.4f): %v",
|
|
time.Now().Format(time.RFC3339), id, userID, monthID, name, baseCurrency, exchangeRate, 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, month_id, name, status, base_currency, exchange_rate, 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.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &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()
|
|
}
|
|
|
|
// GetEventsByMonth returns all events under a given month, ordered by creation date descending.
|
|
func GetEventsByMonth(db *sql.DB, monthID string) ([]Event, error) {
|
|
rows, err := db.Query(
|
|
"SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE month_id = ? ORDER BY created_at DESC",
|
|
monthID,
|
|
)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: GetEventsByMonth(%s): %v",
|
|
time.Now().Format(time.RFC3339), monthID, 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.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil {
|
|
log.Printf("ERROR [%s] database: GetEventsByMonth 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, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE id = ?", id)
|
|
e := &Event{}
|
|
if err := row.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &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
|
|
}
|
|
|
|
// DeleteEvent removes an event and all its expenses from the database.
|
|
func DeleteEvent(db *sql.DB, id string) error {
|
|
_, err := db.Exec("DELETE FROM events WHERE id = ?", id)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: DeleteEvent(%s): %v",
|
|
time.Now().Format(time.RFC3339), id, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// UpdateEvent updates the base currency and exchange rate of an existing event.
|
|
func UpdateEvent(db *sql.DB, id, baseCurrency string, exchangeRate float64) error {
|
|
_, err := db.Exec("UPDATE events SET base_currency = ?, exchange_rate = ? WHERE id = ?", baseCurrency, exchangeRate, id)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: UpdateEvent(%s): %v",
|
|
time.Now().Format(time.RFC3339), id, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// RecalculateExpenses updates all expense converted_amounts for an event
|
|
// using the new exchange rate. Expenses already in the base currency are left unchanged.
|
|
func RecalculateExpenses(db *sql.DB, eventID, baseCurrency string, exchangeRate float64) error {
|
|
_, err := db.Exec(
|
|
`UPDATE expenses SET
|
|
converted_amount = CASE WHEN currency != ? THEN ROUND(amount * ?, 2) ELSE amount END,
|
|
base_currency = ?
|
|
WHERE event_id = ?`,
|
|
baseCurrency, exchangeRate, baseCurrency, eventID,
|
|
)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: RecalculateExpenses(%s): %v",
|
|
time.Now().Format(time.RFC3339), eventID, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Expense queries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// CreateExpense inserts a new expense row from the provided Expense struct.
|
|
func CreateExpense(db *sql.DB, expense Expense) error {
|
|
_, err := db.Exec(
|
|
`INSERT INTO expenses (id, event_id, amount, currency, converted_amount, base_currency, merchant, category, description, date, image_path)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
expense.ID, expense.EventID, expense.Amount, expense.Currency,
|
|
expense.ConvertedAmount, expense.BaseCurrency,
|
|
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, converted_amount, base_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.ConvertedAmount, &e.BaseCurrency,
|
|
&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()
|
|
}
|
|
|
|
// GetExpenseByID returns a single expense by its ID, or nil if not found.
|
|
func GetExpenseByID(db *sql.DB, id string) (*Expense, error) {
|
|
row := db.QueryRow(
|
|
`SELECT id, event_id, amount, currency, converted_amount, base_currency,
|
|
merchant, category, COALESCE(description, ''), date, image_path, created_at
|
|
FROM expenses WHERE id = ?`, id)
|
|
e := &Expense{}
|
|
if err := row.Scan(
|
|
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.ConvertedAmount, &e.BaseCurrency,
|
|
&e.Merchant, &e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
|
|
); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
log.Printf("ERROR [%s] database: GetExpenseByID(%s): %v",
|
|
time.Now().Format(time.RFC3339), id, err)
|
|
return nil, err
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// UpdateExpense updates all editable fields of an existing expense.
|
|
func UpdateExpense(db *sql.DB, expense Expense) error {
|
|
_, err := db.Exec(
|
|
`UPDATE expenses SET amount=?, currency=?, converted_amount=?, base_currency=?,
|
|
merchant=?, category=?, description=?, date=? WHERE id=?`,
|
|
expense.Amount, expense.Currency, expense.ConvertedAmount, expense.BaseCurrency,
|
|
expense.Merchant, expense.Category, expense.Description, expense.Date, expense.ID,
|
|
)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: UpdateExpense(%s): %v",
|
|
time.Now().Format(time.RFC3339), expense.ID, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// DeleteExpense removes a single expense by its ID.
|
|
func DeleteExpense(db *sql.DB, id string) error {
|
|
_, err := db.Exec("DELETE FROM expenses WHERE id = ?", id)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: DeleteExpense(%s): %v",
|
|
time.Now().Format(time.RFC3339), id, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Download token queries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// CreateDownloadToken inserts a new download token row.
|
|
func CreateDownloadToken(db *sql.DB, token, eventID, filename, expiresAt string) error {
|
|
_, err := db.Exec(
|
|
"INSERT INTO download_tokens (token, event_id, filename, expires_at) VALUES (?, ?, ?, ?)",
|
|
token, eventID, filename, expiresAt,
|
|
)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: CreateDownloadToken(%s): %v",
|
|
time.Now().Format(time.RFC3339), token, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// GetDownloadTokenByToken retrieves a download token record by its token string.
|
|
func GetDownloadTokenByToken(db *sql.DB, token string) (*DownloadToken, error) {
|
|
dt := &DownloadToken{}
|
|
err := db.QueryRow(
|
|
"SELECT token, event_id, filename, created_at, expires_at, accessed FROM download_tokens WHERE token = ?",
|
|
token,
|
|
).Scan(&dt.Token, &dt.EventID, &dt.Filename, &dt.CreatedAt, &dt.ExpiresAt, &dt.Accessed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return dt, nil
|
|
}
|
|
|
|
// MarkDownloadTokenAccessed sets the accessed flag for a token.
|
|
func MarkDownloadTokenAccessed(db *sql.DB, token string) error {
|
|
_, err := db.Exec("UPDATE download_tokens SET accessed = 1 WHERE token = ?", token)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: MarkDownloadTokenAccessed(%s): %v",
|
|
time.Now().Format(time.RFC3339), token, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// DeleteExpiredDownloadTokens removes tokens past their expiry and their files.
|
|
// Returns the filenames of deleted tokens so the caller can clean up disk files.
|
|
func DeleteExpiredDownloadTokens(db *sql.DB) ([]string, error) {
|
|
rows, err := db.Query("SELECT filename FROM download_tokens WHERE expires_at < datetime('now')")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var filenames []string
|
|
for rows.Next() {
|
|
var fn string
|
|
if err := rows.Scan(&fn); err != nil {
|
|
continue
|
|
}
|
|
filenames = append(filenames, fn)
|
|
}
|
|
|
|
if len(filenames) > 0 {
|
|
if _, err := db.Exec("DELETE FROM download_tokens WHERE expires_at < datetime('now')"); err != nil {
|
|
return filenames, err
|
|
}
|
|
}
|
|
|
|
return filenames, nil
|
|
}
|
|
|
|
// DeleteDownloadTokensByEvent removes all download tokens for a given event.
|
|
// Returns the filenames so the caller can clean up disk files.
|
|
func DeleteDownloadTokensByEvent(db *sql.DB, eventID string) ([]string, error) {
|
|
rows, err := db.Query("SELECT filename FROM download_tokens WHERE event_id = ?", eventID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var filenames []string
|
|
for rows.Next() {
|
|
var fn string
|
|
if err := rows.Scan(&fn); err != nil {
|
|
continue
|
|
}
|
|
filenames = append(filenames, fn)
|
|
}
|
|
|
|
if len(filenames) > 0 {
|
|
if _, err := db.Exec("DELETE FROM download_tokens WHERE event_id = ?", eventID); err != nil {
|
|
return filenames, err
|
|
}
|
|
}
|
|
|
|
return filenames, nil
|
|
}
|