- 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>
192 lines
6.1 KiB
Go
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 }
|