- Search purchasers by product name, store, or notes (case-insensitive) - GET /search?q=... returns purchase list fragment - 300ms debounced keyup trigger on search input - Empty query returns all purchases
362 lines
12 KiB
Go
362 lines
12 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
|
|
}
|
|
|
|
// SearchPurchases searches purchases by product_name, store, or notes (case-insensitive).
|
|
// When query is empty, falls back to GetPurchasesByUser.
|
|
func SearchPurchases(db *sql.DB, userID, query string) ([]Purchase, error) {
|
|
if query == "" {
|
|
return GetPurchasesByUser(db, userID)
|
|
}
|
|
|
|
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 = ?
|
|
AND (product_name LIKE ? OR store LIKE ? OR notes LIKE ?)
|
|
ORDER BY purchase_date DESC, created_at DESC`,
|
|
userID, "%"+query+"%", "%"+query+"%", "%"+query+"%",
|
|
)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] database: SearchPurchases(%s, %s): %v",
|
|
time.Now().Format(time.RFC3339), userID, query, 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: SearchPurchases scan: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
return nil, err
|
|
}
|
|
purchases = append(purchases, p)
|
|
}
|
|
return purchases, rows.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
|
|
}
|