NextReceipt/internal/database/db.go
cclohmar f0c576c543 feat: transform NextExpense into NextReceipt — receipt saver for warranty & returns
- Replace events+expenses with flat purchases table
- Add warranty_months, return_days, product_name fields
- Remove currency conversion, CSV/PDF reporting, event filing
- Simplify auth (no onboarding/department/profile)
- Update AI extraction prompts for product/warranty info
- Update all branding: templates, install.sh, Makefile, service file
2026-06-21 18:57:29 +00:00

323 lines
10 KiB
Go

// Package database provides SQLite initialization and query helpers for NextReceipt.
//
// 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
Onboarded bool
CreatedAt string
}
// OTP represents a row in the auth_otps table.
type OTP struct {
Email string
OTPCode string
ExpiresAt string
}
// Purchase represents a saved receipt / purchase for warranty and return tracking.
type Purchase struct {
ID string
UserID string
ProductName string
Store string
Category string
Amount float64
Currency string
PurchaseDate string
WarrantyMonths int
ReturnDays int
Notes string
ImagePath string
CreatedAt string
}
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
// Init opens (or creates) nextreceipt.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", "nextreceipt.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
}
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,
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 purchases (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
product_name TEXT NOT NULL DEFAULT '',
store TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
amount REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'EUR',
purchase_date TEXT NOT NULL DEFAULT '',
warranty_months INTEGER NOT NULL DEFAULT 0,
return_days INTEGER NOT NULL DEFAULT 0,
notes TEXT,
image_path TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)`,
}
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, onboarded, created_at FROM users WHERE email = ?", email)
u := &User{}
if err := row.Scan(&u.ID, &u.Email, &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, onboarded, created_at FROM users WHERE id = ?", id)
u := &User{}
if err := row.Scan(&u.ID, &u.Email, &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
}
// MarkUserOnboarded sets the onboarded flag for a user after their first purchase.
func MarkUserOnboarded(db *sql.DB, userID string) error {
_, err := db.Exec("UPDATE users SET onboarded = 1 WHERE id = ?", userID)
if err != nil {
log.Printf("ERROR [%s] database: MarkUserOnboarded(%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
}
// ---------------------------------------------------------------------------
// Purchase queries
// ---------------------------------------------------------------------------
// CreatePurchase inserts a new purchase row.
func CreatePurchase(db *sql.DB, p Purchase) error {
_, err := db.Exec(
`INSERT INTO purchases (id, user_id, product_name, store, category, amount, currency,
purchase_date, warranty_months, return_days, notes, image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.UserID, p.ProductName, p.Store, p.Category,
p.Amount, p.Currency, p.PurchaseDate,
p.WarrantyMonths, p.ReturnDays, p.Notes, p.ImagePath,
)
if err != nil {
log.Printf("ERROR [%s] database: CreatePurchase(%s): %v",
time.Now().Format(time.RFC3339), p.ID, err)
}
return err
}
// GetPurchasesByUser returns all purchases belonging to a user, ordered by
// purchase date descending (newest first).
func GetPurchasesByUser(db *sql.DB, userID string) ([]Purchase, error) {
rows, err := db.Query(
`SELECT id, user_id, product_name, store, category, amount, currency,
purchase_date, warranty_months, return_days, COALESCE(notes, ''), image_path, created_at
FROM purchases WHERE user_id = ? ORDER BY purchase_date DESC, created_at DESC`,
userID,
)
if err != nil {
log.Printf("ERROR [%s] database: GetPurchasesByUser(%s): %v",
time.Now().Format(time.RFC3339), userID, err)
return nil, err
}
defer rows.Close()
var purchases []Purchase
for rows.Next() {
var p Purchase
if err := rows.Scan(
&p.ID, &p.UserID, &p.ProductName, &p.Store, &p.Category,
&p.Amount, &p.Currency, &p.PurchaseDate,
&p.WarrantyMonths, &p.ReturnDays, &p.Notes, &p.ImagePath, &p.CreatedAt,
); err != nil {
log.Printf("ERROR [%s] database: GetPurchasesByUser scan: %v",
time.Now().Format(time.RFC3339), err)
return nil, err
}
purchases = append(purchases, p)
}
return purchases, rows.Err()
}
// GetPurchaseByID returns a single purchase by its ID, or nil if not found.
func GetPurchaseByID(db *sql.DB, id string) (*Purchase, error) {
row := db.QueryRow(
`SELECT id, user_id, product_name, store, category, amount, currency,
purchase_date, warranty_months, return_days, COALESCE(notes, ''), image_path, created_at
FROM purchases WHERE id = ?`, id)
p := &Purchase{}
if err := row.Scan(
&p.ID, &p.UserID, &p.ProductName, &p.Store, &p.Category,
&p.Amount, &p.Currency, &p.PurchaseDate,
&p.WarrantyMonths, &p.ReturnDays, &p.Notes, &p.ImagePath, &p.CreatedAt,
); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
log.Printf("ERROR [%s] database: GetPurchaseByID(%s): %v",
time.Now().Format(time.RFC3339), id, err)
return nil, err
}
return p, nil
}
// UpdatePurchase updates all editable fields of an existing purchase.
func UpdatePurchase(db *sql.DB, p Purchase) error {
_, err := db.Exec(
`UPDATE purchases SET product_name=?, store=?, category=?, amount=?, currency=?,
purchase_date=?, warranty_months=?, return_days=?, notes=? WHERE id=?`,
p.ProductName, p.Store, p.Category, p.Amount, p.Currency,
p.PurchaseDate, p.WarrantyMonths, p.ReturnDays, p.Notes, p.ID,
)
if err != nil {
log.Printf("ERROR [%s] database: UpdatePurchase(%s): %v",
time.Now().Format(time.RFC3339), p.ID, err)
}
return err
}
// DeletePurchase removes a single purchase by its ID.
func DeletePurchase(db *sql.DB, id string) error {
_, err := db.Exec("DELETE FROM purchases WHERE id = ?", id)
if err != nil {
log.Printf("ERROR [%s] database: DeletePurchase(%s): %v",
time.Now().Format(time.RFC3339), id, err)
}
return err
}