hsa-app/internal/web/upload.go
Jean-Michel Tremblay c715c8e0c0
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
AI classifier correction notes + misread review (AI tab)
Add a global, temporal ai_notes list appended to the classifier prompt
(seeded once from no-PII defaults, documented in README), managed inline
on a new AI tab with a read-only view of the assembled prompt. Every
AI-run upload records the browser-round-tripped suggestion blob + model;
misreads are derived (final field != AI guess) and reviewed one by one
(image + per-field guess-vs-entered + notes-since), attributing which
note fixed each or closing unresolved. Update SPEC (new section 10),
DESIGN item 15, README, and changelog (0.0.3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:04:30 -04:00

491 lines
14 KiB
Go

package web
import (
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"maisym.com/hsa/internal/receipt"
"maisym.com/hsa/internal/storage"
)
const dateLayout = "2006-01-02"
type formView struct {
Subject string
Categories []storage.Lookup
People []storage.Lookup
Amount string
Date string
CategoryID string
PersonID string
Errors []string
// ClassifyModel is the model that will read the receipt to pre-fill the form,
// shown as a footnote. ClassifyEnabled is false when no API key is configured.
ClassifyModel string
ClassifyEnabled bool
// TagChips is the alphabetical tag picker: every known tag plus any unsaved
// labels carried across an error re-render, each marked selected or not.
TagChips []tagChip
}
// tagChip is one selectable tag in the upload form's picker.
type tagChip struct {
Label string
Selected bool
}
func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r.Context())
cats, people, err := s.lookups()
if err != nil {
s.serverError(w, "load lookups", err)
return
}
s.renderForm(w, formView{
Subject: sess.Subject,
Categories: cats,
People: people,
Date: time.Now().Format(dateLayout),
})
}
func (s *Server) lookups() (cats, people []storage.Lookup, err error) {
cats, err = s.store.ListCategories()
if err != nil {
return nil, nil, err
}
people, err = s.store.ListPeople()
if err != nil {
return nil, nil, err
}
return cats, people, nil
}
func (s *Server) renderForm(w http.ResponseWriter, v formView) {
v.ClassifyModel = s.cfg.ClassifyModel
v.ClassifyEnabled = s.classifier != nil
if v.TagChips == nil { // callers that carry selected tags set this themselves
if chips, err := s.tagChipsFor(nil); err == nil {
v.TagChips = chips
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
s.serverError(w, "render form", err)
}
}
// tagChipsFor builds the upload form's tag picker: every known tag in alphabetical
// order, plus any selected labels that aren't saved tags yet (a chip the user
// created before an error re-render), with the selected ones marked.
func (s *Server) tagChipsFor(selected []string) ([]tagChip, error) {
tags, err := s.store.ListTags()
if err != nil {
return nil, err
}
selSet := map[string]bool{}
for _, l := range selected {
if l = strings.TrimSpace(l); l != "" {
selSet[strings.ToLower(l)] = true
}
}
known := map[string]bool{}
var chips []tagChip
for _, t := range tags {
key := strings.ToLower(t.Label)
known[key] = true
chips = append(chips, tagChip{Label: t.Label, Selected: selSet[key]})
}
for _, l := range selected { // pending labels not yet persisted
l = strings.TrimSpace(l)
key := strings.ToLower(l)
if l == "" || known[key] {
continue
}
known[key] = true
chips = append(chips, tagChip{Label: l, Selected: true})
}
sort.Slice(chips, func(i, j int) bool {
return strings.ToLower(chips[i].Label) < strings.ToLower(chips[j].Label)
})
return chips, nil
}
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r.Context())
cats, people, err := s.lookups()
if err != nil {
s.serverError(w, "load lookups", err)
return
}
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes)
if err := r.ParseMultipartForm(10 << 20); err != nil {
s.renderForm(w, formView{
Subject: sess.Subject, Categories: cats, People: people,
Errors: []string{"Upload too large or malformed."},
})
return
}
amountStr := strings.TrimSpace(r.FormValue("amount"))
dateStr := strings.TrimSpace(r.FormValue("receipt_date"))
categoryStr := strings.TrimSpace(r.FormValue("category_id"))
personStr := strings.TrimSpace(r.FormValue("person_id"))
view := formView{
Subject: sess.Subject, Categories: cats, People: people,
Amount: amountStr, Date: dateStr, CategoryID: categoryStr, PersonID: personStr,
}
var errs []string
amountCents, err := receipt.ParseAmountCents(amountStr)
if err != nil {
errs = append(errs, "Enter a valid amount, e.g. 12.34")
}
receiptDate, err := time.Parse(dateLayout, dateStr)
if err != nil {
errs = append(errs, "Enter a valid date.")
}
categoryID, ok := parseLookupID(categoryStr, cats)
if !ok {
errs = append(errs, "Choose a category.")
}
var personID *int64
if personStr != "" {
pid, ok := parseLookupID(personStr, people)
if !ok {
errs = append(errs, "Choose a valid \"Who\" or leave it blank.")
} else {
personID = &pid
}
}
file, header, err := r.FormFile("receipt")
if err != nil {
errs = append(errs, "Attach a receipt photo or PDF.")
}
var data []byte
if file != nil {
defer file.Close()
data, err = io.ReadAll(file)
if err != nil {
errs = append(errs, "Could not read the uploaded file.")
}
}
mimeType := ""
if len(data) > 0 {
mimeType = detectMime(data)
if !allowedMime(mimeType) {
errs = append(errs, "Only images and PDFs are allowed.")
} else {
data = normalizeOrientation(data, mimeType) // bake in EXIF rotation
}
}
// Optional extra files attached in the same submission (item 10). Read and
// validate them up front so a bad attachment re-renders the form before any
// row is written. They are saved after the receipt row exists (see below).
attachments, attErrs := readAttachments(r)
errs = append(errs, attErrs...)
// Selected tags (existing labels and/or newly-typed ones) ride along as repeated
// "tags" fields. Resolved/created at save time so an abandoned upload adds none.
var tagLabels []string
if r.MultipartForm != nil {
tagLabels = r.MultipartForm.Value["tags"]
}
if len(errs) > 0 {
view.Errors = errs
if chips, err := s.tagChipsFor(tagLabels); err == nil {
view.TagChips = chips // keep the user's tag selection across the re-render
}
s.renderForm(w, view)
return
}
// Dual-write: file on disk (dated, amount-tagged name) + blob in DB.
id := newID()
ext := extFor(header.Filename, mimeType)
relPath, err := s.saveReceiptFile(receiptDate, amountCents, ext, data)
if err != nil {
s.serverError(w, "save file", err)
return
}
rec := receipt.Receipt{
ID: id,
UploadedBy: sess.Subject,
UploadedAt: time.Now().UTC(),
ReceiptDate: receiptDate,
AmountCents: amountCents,
CategoryID: categoryID,
PersonID: personID,
FilePath: relPath,
ImageData: data,
FileSizeBytes: int64(len(data)),
OriginalFilename: header.Filename,
MimeType: mimeType,
}
if err := s.store.Insert(rec); err != nil {
s.serverError(w, "insert receipt", err)
return
}
// Link tags now that the receipt id exists (create-if-missing by label).
if len(tagLabels) > 0 {
if err := s.store.SetReceiptTags(id, tagLabels); err != nil {
s.serverError(w, "save tags", err)
return
}
}
// Persist the AI suggestion the browser round-tripped, so misreads can be
// reviewed later (best-effort, diagnostic — never block the upload on it).
if blob := strings.TrimSpace(r.FormValue("classify_json")); blob != "" {
var cr classifyResponse
_ = json.Unmarshal([]byte(blob), &cr) // model only; blob stored verbatim
if err := s.store.InsertClassification(id, cr.Model, blob, time.Now().UTC()); err != nil {
log.Printf("warning: store classification for %s: %v", id, err)
}
}
// Save each attachment now that the parent receipt id exists, keyed to the
// receipt's date+amount so the files sort alongside it.
var attachmentNames []string
for _, a := range attachments {
relPath, err := s.saveAttachmentFile(receiptDate, amountCents, a.ext, a.data)
if err != nil {
s.serverError(w, "save attachment", err)
return
}
if err := s.store.InsertAttachment(receipt.Attachment{
ID: newID(),
ReceiptID: id,
UploadedBy: sess.Subject,
UploadedAt: time.Now().UTC(),
FilePath: relPath,
ImageData: a.data,
FileSizeBytes: int64(len(a.data)),
OriginalFilename: a.filename,
MimeType: a.mimeType,
}); err != nil {
s.serverError(w, "insert attachment", err)
return
}
attachmentNames = append(attachmentNames, a.filename)
}
savedTags, _ := s.store.ListReceiptTags(id)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = confirmPage.ExecuteTemplate(w, "base", confirmView{
Category: labelFor(categoryID, cats),
Who: whoLabel(personID, people),
Amount: fmt.Sprintf("%d.%02d", amountCents/100, amountCents%100),
Date: dateStr,
Filename: header.Filename,
Attachments: attachmentNames,
Tags: savedTags,
})
}
// confirmView is the data for the post-save confirmation page.
type confirmView struct {
Category string
Who string
Amount string
Date string
Filename string
Attachments []string
Tags []string
}
// pendingAttachment is a validated extra file waiting to be saved with its receipt.
type pendingAttachment struct {
data []byte
mimeType string
ext string
filename string
}
// readAttachments reads and validates the optional "attachments" multi-file field.
// Empty parts are skipped; invalid types accumulate into errs (and the upload is
// re-rendered, so nothing is written).
func readAttachments(r *http.Request) (out []pendingAttachment, errs []string) {
if r.MultipartForm == nil {
return nil, nil
}
for _, fh := range r.MultipartForm.File["attachments"] {
af, err := fh.Open()
if err != nil {
errs = append(errs, "Could not read an attachment.")
continue
}
adata, err := io.ReadAll(af)
af.Close()
if err != nil {
errs = append(errs, "Could not read an attachment.")
continue
}
if len(adata) == 0 {
continue
}
amime := detectMime(adata)
if !allowedMime(amime) {
errs = append(errs, "Attachments must be images or PDFs.")
continue
}
adata = normalizeOrientation(adata, amime) // bake in EXIF rotation
out = append(out, pendingAttachment{
data: adata,
mimeType: amime,
ext: extFor(fh.Filename, amime),
filename: fh.Filename,
})
}
return out, errs
}
// parseLookupID parses s as an int64 and confirms it exists in the given set.
func parseLookupID(s string, set []storage.Lookup) (int64, bool) {
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, false
}
for _, l := range set {
if l.ID == id {
return id, true
}
}
return 0, false
}
func labelFor(id int64, set []storage.Lookup) string {
for _, l := range set {
if l.ID == id {
return l.Label
}
}
return ""
}
func whoLabel(id *int64, set []storage.Lookup) string {
if id == nil {
return ""
}
return labelFor(*id, set)
}
// receiptStem returns the dated, amount-tagged base name (no extension) shared by
// a receipt and its attachments, e.g. "06_08_42.50".
func receiptStem(date time.Time, amountCents int64) string {
return fmt.Sprintf("%02d_%02d_%d.%02d", int(date.Month()), date.Day(), amountCents/100, amountCents%100)
}
// saveReceiptFile writes the receipt bytes under
// <StorageDir>/receipts/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>.
func (s *Server) saveReceiptFile(date time.Time, amountCents int64, ext string, data []byte) (string, error) {
return s.saveBlobFile("receipts", fmt.Sprintf("%04d", date.Year()), receiptStem(date, amountCents), ext, data)
}
// saveAttachmentFile writes an attachment's bytes under
// <StorageDir>/attachments/<YYYY>/<MM>_<DD>_<dollars>.<cents>_att<ext>, keyed to
// the PARENT receipt's date and amount so an expense's files sort together.
func (s *Server) saveAttachmentFile(date time.Time, amountCents int64, ext string, data []byte) (string, error) {
return s.saveBlobFile("attachments", fmt.Sprintf("%04d", date.Year()), receiptStem(date, amountCents)+"_att", ext, data)
}
// saveBlobFile writes data to <StorageDir>/<subdir>/<year>/<stem><ext>, adding a
// _1, _2, … suffix on the stem when the name is taken (legitimately possible —
// duplicates can be approved, and a receipt can have several attachments).
// Exclusive create avoids two concurrent uploads racing onto the same name.
// Returns the storage-relative path actually written (forward-slash separated).
func (s *Server) saveBlobFile(subdir, year, stem, ext string, data []byte) (string, error) {
if err := os.MkdirAll(filepath.Join(s.cfg.StorageDir, subdir, year), 0o700); err != nil {
return "", err
}
for i := 0; i < 10000; i++ {
name := stem
if i > 0 {
name += "_" + strconv.Itoa(i)
}
name += ext
rel := filepath.Join(subdir, year, name)
f, err := os.OpenFile(filepath.Join(s.cfg.StorageDir, rel), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if os.IsExist(err) {
continue // name taken — try the next suffix
}
if err != nil {
return "", err
}
_, werr := f.Write(data)
cerr := f.Close()
if werr != nil {
return "", werr
}
if cerr != nil {
return "", cerr
}
return filepath.ToSlash(rel), nil
}
return "", fmt.Errorf("could not find a free filename for %s", stem)
}
func detectMime(data []byte) string {
n := min(len(data), 512)
ct := http.DetectContentType(data[:n])
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
return strings.TrimSpace(ct)
}
func allowedMime(mime string) bool {
return strings.HasPrefix(mime, "image/") || mime == "application/pdf"
}
func extFor(filename, mimeType string) string {
if ext := strings.ToLower(filepath.Ext(filename)); ext != "" && len(ext) <= 5 {
return ext
}
switch mimeType {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "application/pdf":
return ".pdf"
default:
return ".bin"
}
}
// newID returns a random UUIDv4 string.
func newID() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}