hsa-app/cmd/hsa/main.go
Jean-Michel Tremblay fbfdc6877d Add scheduled metadata-only DB backups (spec item 11)
- internal/backup: weekly (configurable) snapshots via the existing VACUUM INTO
  + blob-strip path; restart-safe (only backs up if newest is older than the
  interval), retains BACKUP_KEEP most recent, prunes the rest. Runs in a
  background goroutine; failures are logged, never fatal.
- Config: BACKUP_DIR (./data/backups), BACKUP_INTERVAL (168h; 0 disables),
  BACKUP_KEEP (8).
- Fix: blob-strip now clears attachments.image_data too, not just receipts.
- Fix: STORAGE_DIR is the storage ROOT (default ./data) — receipts/ and
  attachments/ live under it; corrects the doubled-nesting from item 10.

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

78 lines
2.5 KiB
Go

package main
import (
"context"
"log"
"net/http"
"os"
"path/filepath"
"time"
"maisym.com/hsa/internal/backup"
"maisym.com/hsa/internal/classify"
"maisym.com/hsa/internal/config"
"maisym.com/hsa/internal/storage"
"maisym.com/hsa/internal/web"
)
func main() {
// Best-effort local dev convenience: load .env if present.
if err := config.LoadDotEnv(".env"); err != nil {
log.Fatalf("loading .env: %v", err)
}
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o700); err != nil {
log.Fatalf("create db dir: %v", err)
}
store, err := storage.Open(cfg.DBPath)
if err != nil {
log.Fatalf("open store: %v", err)
}
defer store.Close()
// Load the people/categories catalog and seed it into the DB (insert-if-absent).
catalog, err := config.LoadCatalog(cfg.ConfigPath)
if err != nil {
log.Fatalf("config catalog: %v", err)
}
if err := store.Seed(catalog.CategoryNames(), catalog.PersonLabels()); err != nil {
log.Fatalf("seed catalog: %v", err)
}
// Collapse any stray partial-name people (e.g. "Jude" → "Jude Tremblay") left
// over from earlier data, reassigning their receipts to the canonical person.
if merged, err := store.ReconcilePeople(catalog.PersonLabels()); err != nil {
log.Fatalf("reconcile people: %v", err)
} else if merged > 0 {
log.Printf("reconciled %d stray person entr(ies) into canonical labels", merged)
}
// Receipt auto-classification is best-effort: only enabled when an API key is set.
var classifier *classify.Classifier
if cfg.ClassifyAPIKey != "" {
classifier = classify.New(cfg.ClassifyAPIKey, cfg.ClassifyModel, catalog.Persons, catalog.Categories)
log.Printf("receipt classification enabled (model %s)", cfg.ClassifyModel)
} else {
log.Printf("receipt classification disabled (CLAUDE_API_KEY not set)")
}
srv, err := web.NewServer(context.Background(), cfg, store, classifier)
if err != nil {
log.Fatalf("server init: %v", err)
}
// Scheduled metadata-only DB backups (best-effort, runs for the process life).
if cfg.BackupEvery > 0 {
go backup.Schedule(context.Background(), store, cfg.BackupDir, cfg.BackupEvery, cfg.BackupKeep, time.Now)
log.Printf("DB backups: every %s into %s (keep %d, metadata-only)", cfg.BackupEvery, cfg.BackupDir, cfg.BackupKeep)
}
log.Printf("HSA listening on %s (issuer %s)", cfg.ListenAddr, cfg.IssuerURL)
if err := http.ListenAndServe(cfg.ListenAddr, srv.Routes()); err != nil {
log.Fatalf("listen: %v", err)
}
}