NextExpense/internal/database/db.go
cclohmar d65c0cd5fa feat: add month hierarchy, AI categories, and UI polish
- 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)
2026-07-14 09:29:26 +00:00

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
}