hsa-app/internal/storage/storage.go
Jean-Michel Tremblay 04d6e8177d Add receipt attachments captured at upload time (spec item 10)
- New attachments table (FK to receipts), dual-write: file on disk + DB blob.
- On-disk layout split: receipts under <ROOT>/receipts/<YYYY>/..., attachments
  under <ROOT>/attachments/<YYYY>/..., attachments named from the parent
  receipt's date+amount stem (_att, _att_1, ...).
- Upload form gains an optional multi-file "Additional files" field; the files
  ride along with POST /upload, saved after the receipt row exists. No AI runs
  on attachments; primary-image auto-fill unchanged.
- GET /attachment/{id}/file serves blobs; confirm page lists them; recent list
  links them. Adding attachments to an already-saved receipt is not yet supported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:06:15 -04:00

192 lines
6.1 KiB
Go

// Package storage is the SQLite persistence layer for receipts.
package storage
import (
"database/sql"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
"maisym.com/hsa/internal/receipt"
)
// Store wraps the SQLite database.
type Store struct {
db *sql.DB
}
// Lookup is an editable label referenced by receipts via foreign key.
type Lookup struct {
ID int64
Label string
}
// seedCategories are inserted on first open. They can be renamed (and more added)
// from the manage portal; receipts reference them by id, so renames propagate.
var seedCategories = []string{"Medical", "Dental", "Vision", "Pharmacy", "Other"}
const schema = `
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS receipts (
id TEXT PRIMARY KEY,
uploaded_by TEXT NOT NULL,
uploaded_at TEXT NOT NULL,
receipt_date TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
category_id INTEGER NOT NULL REFERENCES categories(id),
person_id INTEGER REFERENCES people(id),
file_path TEXT NOT NULL,
image_data BLOB NOT NULL,
file_size_bytes INTEGER NOT NULL,
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_receipts_active ON receipts(deleted_at);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
receipt_id TEXT NOT NULL REFERENCES receipts(id),
uploaded_by TEXT NOT NULL,
uploaded_at TEXT NOT NULL,
file_path TEXT NOT NULL,
image_data BLOB NOT NULL,
file_size_bytes INTEGER NOT NULL,
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_attachments_receipt ON attachments(receipt_id, deleted_at);
`
// Open opens (creating if needed) the SQLite database at path, applies the schema,
// and seeds the category list on first creation.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;`); err != nil {
db.Close()
return nil, fmt.Errorf("set pragmas: %w", err)
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
for _, label := range seedCategories {
if _, err := db.Exec(`INSERT OR IGNORE INTO categories(label) VALUES (?)`, label); err != nil {
db.Close()
return nil, fmt.Errorf("seed categories: %w", err)
}
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
// Seed inserts the given category and person labels if they are not already
// present (INSERT OR IGNORE), so renames made in the manage portal are preserved
// across restarts. Called on startup from the config catalog.
func (s *Store) Seed(categories, people []string) error {
insert := func(table string, labels []string) error {
for _, label := range labels {
label = strings.TrimSpace(label)
if label == "" {
continue
}
if _, err := s.db.Exec(`INSERT OR IGNORE INTO `+table+`(label) VALUES (?)`, label); err != nil {
return fmt.Errorf("seed %s: %w", table, err)
}
}
return nil
}
if err := insert("categories", categories); err != nil {
return err
}
return insert("people", people)
}
const rfc3339 = time.RFC3339
// Insert stores a receipt (metadata + image blob) in a single statement.
func (s *Store) Insert(r receipt.Receipt) error {
var personID any
if r.PersonID != nil {
personID = *r.PersonID
}
_, err := s.db.Exec(
`INSERT INTO receipts
(id, uploaded_by, uploaded_at, receipt_date, amount_cents, category_id, person_id,
file_path, image_data, file_size_bytes, original_filename, mime_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.ID, r.UploadedBy, r.UploadedAt.UTC().Format(rfc3339),
r.ReceiptDate.UTC().Format(rfc3339), r.AmountCents, r.CategoryID, personID,
r.FilePath, r.ImageData, r.FileSizeBytes, r.OriginalFilename, r.MimeType,
)
if err != nil {
return fmt.Errorf("insert receipt: %w", err)
}
return nil
}
// Get returns the receipt with the given id (including soft-deleted rows).
func (s *Store) Get(id string) (receipt.Receipt, error) {
row := s.db.QueryRow(
`SELECT id, uploaded_by, uploaded_at, receipt_date, amount_cents, category_id, person_id,
file_path, image_data, file_size_bytes, original_filename, mime_type, deleted_at
FROM receipts WHERE id = ?`, id)
var r receipt.Receipt
var uploadedAt, receiptDate string
var personID sql.NullInt64
var deletedAt sql.NullString
if err := row.Scan(
&r.ID, &r.UploadedBy, &uploadedAt, &receiptDate, &r.AmountCents, &r.CategoryID, &personID,
&r.FilePath, &r.ImageData, &r.FileSizeBytes, &r.OriginalFilename, &r.MimeType, &deletedAt,
); err != nil {
return receipt.Receipt{}, fmt.Errorf("get receipt: %w", err)
}
r.UploadedAt, _ = time.Parse(rfc3339, uploadedAt)
r.ReceiptDate, _ = time.Parse(rfc3339, receiptDate)
if personID.Valid {
r.PersonID = &personID.Int64
}
if deletedAt.Valid {
if t, err := time.Parse(rfc3339, deletedAt.String); err == nil {
r.DeletedAt = &t
}
}
return r, nil
}
// SoftDelete marks a receipt deleted without removing the row or its blob.
func (s *Store) SoftDelete(id string) error {
_, err := s.db.Exec(
`UPDATE receipts SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
time.Now().UTC().Format(rfc3339), id)
if err != nil {
return fmt.Errorf("soft delete: %w", err)
}
return nil
}
// CountActive returns the number of non-deleted receipts.
func (s *Store) CountActive() (int, error) {
var n int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM receipts WHERE deleted_at IS NULL`).Scan(&n); err != nil {
return 0, fmt.Errorf("count active: %w", err)
}
return n, nil
}
// DB exposes the underlying handle for the export snapshot.
func (s *Store) DB() *sql.DB { return s.db }