Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e94a17160b | ||
|
|
866bc175fb | ||
|
|
c930f715b7 | ||
|
|
c715c8e0c0 | ||
|
|
da7fc56920 | ||
|
|
2ff5c3847c | ||
|
|
7ba0d5abe7 | ||
|
|
ef2b0665b8 |
38 changed files with 2711 additions and 26 deletions
69
CHANGELOG.md
Normal file
69
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented here. Versions are git tags;
|
||||||
|
release tags `X.Y.Z` are built and deployed automatically (pre-release tags such
|
||||||
|
as `0.0.0a1` are built and staged only).
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-06-20
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Enforce the 12h session lifetime server-side (reject sessions older than the TTL
|
||||||
|
even if the sealed cookie is intact), so a leaked cookie can't be replayed forever.
|
||||||
|
- Send `X-Content-Type-Options: nosniff` when serving user-uploaded receipt and
|
||||||
|
attachment bytes.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- Add deploy docs: `deploy/CICD.md` (which git push/tag triggers which pipeline
|
||||||
|
steps) and `deploy/AUTH.md` (the Authelia/OIDC integration contract + a sequence
|
||||||
|
diagram of the login flow). Cross-linked from the README and `deploy/INSTALL.md`.
|
||||||
|
|
||||||
|
## [0.0.4] - 2026-06-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- AI tab: correction notes are now full-width, wrapping, auto-growing textareas
|
||||||
|
(Save/Delete per note) instead of single-line inputs that truncated long notes,
|
||||||
|
so the whole note is readable and editable.
|
||||||
|
|
||||||
|
## [0.0.3] - 2026-06-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- AI classifier correction notes + misread review (new "AI" tab).
|
||||||
|
- A global list of free-text correction notes is appended to the classifier
|
||||||
|
prompt; managed inline (add/edit/delete). The notes table is temporal (edits
|
||||||
|
soft-delete + insert), seeded once from a no-PII default list (documented in the
|
||||||
|
README), and read live per classification.
|
||||||
|
- A read-only view of the exact assembled prompt.
|
||||||
|
- Every AI-run upload records the suggestion blob + model (round-tripped from the
|
||||||
|
browser). Misreads — where a final field differs from the AI's guess — are
|
||||||
|
derived and listed for review; reviewing shows the image and per-field
|
||||||
|
guess-vs-entered, and lets the user attribute which note(s) fixed it (or close
|
||||||
|
it unresolved).
|
||||||
|
- See SPEC.md §10 and DESIGN.md item 15.
|
||||||
|
|
||||||
|
## [0.0.2] - 2026-06-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Optional tags on a receipt. An "Add tags" button on the upload form (after
|
||||||
|
"Who") opens an in-page card with an alphabetical chip mosaic; tap to select or
|
||||||
|
deselect, and create a new tag inline (auto-selected for this receipt). Tags are
|
||||||
|
a shared, free-form vocabulary stored separately from the fixed categories.
|
||||||
|
- New tags are written to the catalog only when the receipt is actually saved,
|
||||||
|
matched case-insensitively so casing variants don't duplicate.
|
||||||
|
- A receipt's tags are shown on the confirmation page and in the recent lists.
|
||||||
|
- See spec.md item 14. Out of scope for now: tag-based filtering, tally-by-tag,
|
||||||
|
and editing/merging tags in Manage.
|
||||||
|
|
||||||
|
## [0.0.1] - 2026-06-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Upright receipt images (EXIF orientation normalization). Phone cameras often
|
||||||
|
store a photo sideways with an EXIF "Orientation" tag that only some viewers
|
||||||
|
honor, so receipts could appear rotated. On upload the indicated rotation is now
|
||||||
|
baked into the pixels (and the tag dropped) so the stored image is upright in
|
||||||
|
every consumer — browser, download, the AI classifier, and any future export.
|
||||||
|
- Applies only when orientation is actually known (a JPEG with an EXIF tag of
|
||||||
|
2..8). Images with no tag, an already-upright tag, non-JPEG images, and PDFs
|
||||||
|
are left byte-for-byte untouched — no guessing from image content.
|
||||||
|
- Runs on the primary receipt, every additional attachment, and the image sent
|
||||||
|
to the AI classifier. New uploads only; existing stored receipts are unchanged.
|
||||||
|
- See spec.md item 13.
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
================================================================================
|
||||||
|
DESIGN JOURNAL — historical, chronological record of decisions and rationale.
|
||||||
|
For the current expected behavior of the app, see SPEC.md (the source of truth).
|
||||||
|
This file explains WHY things were built the way they were; SPEC.md says WHAT the
|
||||||
|
app does today. When behavior changes, update SPEC.md; append context here only if
|
||||||
|
the reasoning is worth preserving.
|
||||||
|
================================================================================
|
||||||
|
|
||||||
HSA Receipt Tracker — Requirements (v1)
|
HSA Receipt Tracker — Requirements (v1)
|
||||||
Purpose
|
Purpose
|
||||||
Capture and archive HSA-eligible receipts for future reimbursement and tax substantiation. No parsing, no OCR, no reporting.
|
Capture and archive HSA-eligible receipts for future reimbursement and tax substantiation. No parsing, no OCR, no reporting.
|
||||||
|
|
@ -354,3 +362,192 @@ half-written row, and recover automatically on the next boot — no manual repai
|
||||||
- Recovery from true corruption (beyond crash-consistency) is restore-from-backup:
|
- Recovery from true corruption (beyond crash-consistency) is restore-from-backup:
|
||||||
the metadata-only daily backup (item 11) recovers the records; the full .db
|
the metadata-only daily backup (item 11) recovers the records; the full .db
|
||||||
export (/export/db, blobs included) recovers records + images.
|
export (/export/db, blobs included) recovers records + images.
|
||||||
|
|
||||||
|
13. Upright receipt images (EXIF orientation normalization)
|
||||||
|
|
||||||
|
Phone cameras often save a photo in the sensor's native orientation plus an EXIF
|
||||||
|
"Orientation" tag that says "rotate me when displaying". Some viewers honor the
|
||||||
|
tag and some don't, so a receipt that looks fine in one place shows up sideways in
|
||||||
|
another (and the bytes are stored verbatim, so the problem follows the file). On
|
||||||
|
upload, bake the indicated rotation into the pixels so the stored image is upright
|
||||||
|
for EVERY consumer — browser, download, the AI classifier, a future PDF export —
|
||||||
|
not just EXIF-aware ones.
|
||||||
|
|
||||||
|
- Act only when orientation is actually KNOWN. A JPEG carrying an EXIF
|
||||||
|
Orientation tag of 2..8 is decoded, rotated/flipped upright, re-encoded
|
||||||
|
(JPEG, q≈90), and the tag dropped. A tag of 1 (already upright) or NO tag at
|
||||||
|
all — the common case, where there is genuinely no way to know which way is up
|
||||||
|
— leaves the original bytes byte-for-byte untouched. We never infer orientation
|
||||||
|
from image content or guess; an unrotatable image is left as-is, never wrong.
|
||||||
|
- Scope: JPEG only (where camera orientation tags live in practice). PDFs and
|
||||||
|
other image types pass through unchanged, as does any file that fails to decode
|
||||||
|
(kept rather than lost).
|
||||||
|
- Applies to the primary receipt, every additional attachment (item 10), and the
|
||||||
|
image sent to the AI classifier (an upright image reads more reliably). It runs
|
||||||
|
after the MIME allowlist check and before the dual-write, so file_size_bytes and
|
||||||
|
both stored copies (disk + DB blob) reflect the normalized bytes.
|
||||||
|
- Client side cannot help: there is no browser/camera API to disable capture
|
||||||
|
rotation, so this is necessarily a server-side fix.
|
||||||
|
|
||||||
|
Out of scope: content-based auto-rotation (detecting "up" without metadata),
|
||||||
|
rotating PDFs, and backfilling already-stored receipts.
|
||||||
|
|
||||||
|
14. Optional tags on a receipt
|
||||||
|
|
||||||
|
Let the user attach zero or more free-form TAGS to a receipt at upload time, on top
|
||||||
|
of the fixed category and the optional "who". Tags are a shared, user-extensible
|
||||||
|
vocabulary (e.g. "orthodontics", "tax-2026", "Jude-braces") for grouping receipts
|
||||||
|
however the household likes, without touching the fixed category list. Optional:
|
||||||
|
a receipt with no tags is normal.
|
||||||
|
|
||||||
|
Upload UI:
|
||||||
|
- An "Add tags" button on the upload form, placed AFTER the "Who" control. A
|
||||||
|
summary next to it shows the current selection (e.g. "3 tags" or the chip
|
||||||
|
labels), so the user sees what's attached without opening the picker.
|
||||||
|
- Tapping it opens an IN-PAGE overlay (modal card), NOT a separate page. The
|
||||||
|
upload form already holds in-progress state — the chosen file, the AI-filled
|
||||||
|
amount/date/category, the "who" — and navigating away would lose it (the file
|
||||||
|
input especially). The picker must preserve all of that; closing it returns to
|
||||||
|
the same half-filled form.
|
||||||
|
- The card shows every existing tag as a chip in a wrap/mosaic layout, sorted
|
||||||
|
alphabetically (case-insensitive, like the other lookups). Tap a chip to select
|
||||||
|
it; tap again to deselect. Selected chips are visually distinct (filled vs
|
||||||
|
outline, checkmark, etc.). Multiple selections allowed.
|
||||||
|
- At the bottom, a "new tag" text field + add control. Creating a tag adds its
|
||||||
|
chip to the mosaic and marks it SELECTED for this receipt by default. The new
|
||||||
|
chip is client-side only until the receipt is submitted (see persistence).
|
||||||
|
Creating a name that already exists (case-insensitive, trimmed) just selects
|
||||||
|
the existing chip rather than making a duplicate.
|
||||||
|
- A "Done with tags" button closes the card and returns to the form with the
|
||||||
|
selection retained. The receipt is then submitted normally; tags ride along in
|
||||||
|
the same POST /upload submission (no separate endpoint), like attachments.
|
||||||
|
|
||||||
|
Data model — new `tags` lookup table and a many-to-many join:
|
||||||
|
- tags: id (INTEGER PK), label (TEXT, unique case-insensitively). Same shape and
|
||||||
|
ordering convention as categories/people (ORDER BY label COLLATE NOCASE).
|
||||||
|
- receipt_tags: (receipt_id FK → receipts.id, tag_id FK → tags.id), composite
|
||||||
|
primary key (receipt_id, tag_id) so a tag can't be linked twice to one receipt.
|
||||||
|
Index on tag_id for "receipts with this tag" lookups later.
|
||||||
|
- Tags are global/shared (both users see the same catalog), consistent with the
|
||||||
|
shared-visibility model. No per-user tag namespaces.
|
||||||
|
|
||||||
|
Persistence and submission:
|
||||||
|
- The form submits the selected tags as a list (existing tag ids and/or new tag
|
||||||
|
LABELS). On insert, the server resolves each: known label/id → reuse; unknown
|
||||||
|
label → create the tag row first (create-if-missing by normalized label), then
|
||||||
|
link. This means a NEW tag is written to the catalog only when its receipt is
|
||||||
|
actually saved — an abandoned upload never litters the tag list.
|
||||||
|
- Tag links are written after the receipt row exists (it owns the id), in the same
|
||||||
|
request that saves the receipt and its attachments.
|
||||||
|
- Normalization: trim surrounding whitespace; match/dedup case-insensitively;
|
||||||
|
store the label as the user first typed it (display casing preserved).
|
||||||
|
- Soft-deleting a receipt hides its tag links along with it (the catalog entries
|
||||||
|
persist). Tags never affect duplicate detection or Tally totals.
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- A receipt's tags are shown as chips wherever its details appear (the recent
|
||||||
|
lists, item 4/5, and the confirm page after upload). AI auto-fill does NOT
|
||||||
|
suggest tags; tagging is a manual, deliberate act.
|
||||||
|
|
||||||
|
Out of scope (for now):
|
||||||
|
- Tag-based filtering/search of receipts and a tally-by-tag view (likely the next
|
||||||
|
step once tags exist).
|
||||||
|
- Editing/renaming/merging/deleting tags in the Manage page. Free-form tags will
|
||||||
|
accumulate cruft and a cleanup surface (akin to item 6 for people) will be
|
||||||
|
wanted eventually, but not in this item.
|
||||||
|
- Adding/removing tags on an already-saved receipt (no edit/detail page yet, same
|
||||||
|
limitation as attachments in item 10).
|
||||||
|
|
||||||
|
15. AI classifier notes + failure review (implemented — see SPEC.md §10)
|
||||||
|
|
||||||
|
A closed loop for improving receipt classification over time: the user accumulates
|
||||||
|
corrective instructions ("notes") that are appended to the classifier prompt, and
|
||||||
|
reviews past misreads one by one to author and attribute those fixes. Big change;
|
||||||
|
spans storage, the upload path, the classifier prompt, and a new top-level tab.
|
||||||
|
|
||||||
|
Motivation: classification will misread some receipts (a vendor's odd date format,
|
||||||
|
a statement that lists the patient under "Guarantor", etc.). Rather than hardcode
|
||||||
|
ever-more rules, let the two users add their own corrections as they hit failures,
|
||||||
|
and give them a place to study failures and decide what fixed them.
|
||||||
|
|
||||||
|
A. Notes — what they are
|
||||||
|
- A single, GLOBAL, free-text list of correction lines, appended to the system
|
||||||
|
prompt as a "Corrections/Notes" appendix. Global because at classify time the
|
||||||
|
app does not yet know the category/vendor, so scoped notes couldn't be selected.
|
||||||
|
- Authored English, separate from the prompt's DERIVED parts (people + name
|
||||||
|
variants, category names/examples, today's date), which stay computed in code.
|
||||||
|
The static prompt scaffolding (the rules/warnings) stays in code too — notes
|
||||||
|
only AUGMENT it; this item does not externalize the whole prompt.
|
||||||
|
- Curated, not append-forever: each active note costs tokens on every scan and too
|
||||||
|
many dilute the instructions, so delete/edit matter as much as add. Realistically
|
||||||
|
a handful.
|
||||||
|
|
||||||
|
B. Notes — storage (temporal)
|
||||||
|
- Table ai_notes(id, text, created_at, deleted_at). Active set = deleted_at IS
|
||||||
|
NULL, ordered by created_at; that set is what gets appended to the prompt.
|
||||||
|
- EDIT = soft-delete the old row + insert a new one (never update in place), so the
|
||||||
|
full history is preserved. The notes active at any time T are
|
||||||
|
created_at <= T AND (deleted_at IS NULL OR deleted_at > T) — which is what lets a
|
||||||
|
failure be matched against the notes that existed when it happened (section E).
|
||||||
|
- Read per /classify call (live; no restart needed). Rides along in /export/db and
|
||||||
|
the daily backup like everything else.
|
||||||
|
- SEEDED ONCE from a hardcoded default list in code (the same defaults are
|
||||||
|
published in the README for humans), inserted only when the table is completely
|
||||||
|
empty — so a deliberately-deleted default does not resurrect on restart. Never
|
||||||
|
read from a file/config at runtime; no export/mirror file. Defaults carry no PII;
|
||||||
|
user-authored notes live only in the DB (private, not in git).
|
||||||
|
|
||||||
|
C. Capturing classifications (the plumbing)
|
||||||
|
- The /classify result is produced server-side but only reaches the browser (to
|
||||||
|
pre-fill the form); by POST /upload the server no longer holds it. So the browser
|
||||||
|
PERSISTS the suggestion blob + model and sends it back in a hidden field on
|
||||||
|
submit. (The API key is and remains server-side — only the suggestions round-trip.)
|
||||||
|
- Store one row per AI-run upload:
|
||||||
|
classifications(receipt_id PK/FK, model, response_json, created_at,
|
||||||
|
reviewed BOOL, reviewed_at, resolution).
|
||||||
|
Skip-AI or classification-disabled uploads create NO row.
|
||||||
|
- response_json is the /classify blob (suggested amount/date/category_id/person_id,
|
||||||
|
the raw_* text the model read, cost, model). Treat it as DIAGNOSTIC data, not
|
||||||
|
ground truth — it is client-supplied and could be tampered with; nothing
|
||||||
|
security-relevant depends on it. Mild PII (raw read name) but DB-only.
|
||||||
|
|
||||||
|
D. Failures are DERIVED, not stored
|
||||||
|
- A "failure"/miss = at least one of the 4 AI-suggested fields (amount, date,
|
||||||
|
category, who) differs from the receipt's final stored value, where AI returning
|
||||||
|
null/empty and the user filling it COUNTS as an override (the AI missed it).
|
||||||
|
- Receipts are immutable (no edit) and the blob is immutable, so the comparison
|
||||||
|
inputs never change — derived overrides can never drift, so we store no
|
||||||
|
redundant per-field booleans. Per-field detail (which of the 4, AI-guess vs.
|
||||||
|
corrected) is computed on demand from the blob vs. the receipt when a failure is
|
||||||
|
displayed. Volume is tiny, so deriving each time is cheap. (If SQL-level
|
||||||
|
filtering/stats ever matter, a single computed had_override flag could be added
|
||||||
|
purely as an index — not the four booleans.)
|
||||||
|
- Review state (reviewed, reviewed_at, resolution) and the fix links below are the
|
||||||
|
only non-derivable things, so they ARE stored.
|
||||||
|
|
||||||
|
E. Review — the loop
|
||||||
|
- New top-level "AI" tab with three sections (one tab unless it grows):
|
||||||
|
1. Notes — list active notes; add / edit / delete (each edit = soft-delete +
|
||||||
|
insert per section B).
|
||||||
|
2. Prompt — READ-ONLY view of the live assembled system prompt (static
|
||||||
|
scaffolding + injected people/categories/today + current active notes), so
|
||||||
|
the user sees exactly what is sent. View-only for now; not editable.
|
||||||
|
3. Review — unreviewed failures, one at a time.
|
||||||
|
- Reviewing one failure shows: the receipt IMAGE, the MODEL used, which fields were
|
||||||
|
overridden (AI guess vs. corrected, derived from the blob), and the NOTES ADDED
|
||||||
|
SINCE this failure's created_at (from the ai_notes temporal history). The user
|
||||||
|
marks which of those notes were the fix → recorded in miss_fixes(receipt_id,
|
||||||
|
note_id); if none fixed it, close as resolution='unresolved'. Either way set
|
||||||
|
reviewed=1, reviewed_at. ("Switched to a stronger model" as a resolution reason
|
||||||
|
may be added later, since model is recorded.)
|
||||||
|
|
||||||
|
F. Out of scope / future
|
||||||
|
- Per-category or vendor-scoped notes (global only, by design above).
|
||||||
|
- Automatic re-classification/recompute of past failures. The data supports it
|
||||||
|
(model + active-notes-at-time-T + the stored image), but review stays MANUAL —
|
||||||
|
the user eyeballs the image to understand the miss.
|
||||||
|
- Editing the static prompt scaffolding (view-only here) and externalizing the
|
||||||
|
whole prompt.
|
||||||
|
- Accuracy-rate stats across all classifications.
|
||||||
|
- No prompting the user to write a note at upload time — failures are recorded
|
||||||
|
silently and dealt with later in the Review tab (likely on a real computer).
|
||||||
43
README.md
Normal file
43
README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# HSA Receipt Tracker
|
||||||
|
|
||||||
|
A small, mobile-first web app for two household users to capture and archive
|
||||||
|
HSA-eligible receipts (photo or PDF) for future reimbursement and tax
|
||||||
|
substantiation, with optional AI auto-fill of the amount/date/category/patient.
|
||||||
|
|
||||||
|
- **What it does (current behavior):** [SPEC.md](SPEC.md) — the source of truth.
|
||||||
|
- **Why it's built this way (history & rationale):** [DESIGN.md](DESIGN.md).
|
||||||
|
- **Version log:** [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
It's a single static Go binary (`CGO_ENABLED=0`, pure-Go SQLite). Configure via
|
||||||
|
environment (see [.env.example](.env.example)); `./scripts/build.sh` builds it and
|
||||||
|
`./scripts/run.sh` runs it locally.
|
||||||
|
|
||||||
|
Deployment:
|
||||||
|
- **[deploy/CICD.md](deploy/CICD.md)** — what each git push/tag triggers (branch =
|
||||||
|
build+test; release tag `X.Y.Z` = build+deploy; pre-release tag = build+stage).
|
||||||
|
- **[deploy/INSTALL.md](deploy/INSTALL.md)** — one-time host setup, on-disk layout,
|
||||||
|
manual deploy, and rollback.
|
||||||
|
- **[deploy/AUTH.md](deploy/AUTH.md)** — the Authelia (OIDC) integration: which
|
||||||
|
config fields must agree with which app env vars, and how access is granted/revoked.
|
||||||
|
|
||||||
|
## AI classifier correction notes
|
||||||
|
|
||||||
|
When an API key is configured, each upload is read by the model to pre-fill the
|
||||||
|
form. You can steer it with **correction notes** — free-text rules appended to the
|
||||||
|
classifier prompt — managed under the **AI** tab. When the model misreads a
|
||||||
|
receipt, that upload is recorded; the AI tab lets you review misreads one by one
|
||||||
|
and attribute which note fixed each.
|
||||||
|
|
||||||
|
Notes live only in the database (private, never committed, included in `/export/db`
|
||||||
|
backups). On first run the table is seeded once with these **default notes** (no
|
||||||
|
PII), which you can edit or delete:
|
||||||
|
|
||||||
|
1. Amounts that use a comma as the decimal separator (e.g. "12,50") mean 12.50, not 1250.
|
||||||
|
2. When both a service/visit date and a separate statement, print, or due date appear, use the service date.
|
||||||
|
3. "Patient Pay", "You Paid", "Amount Due", and "Patient Responsibility" are the amount actually paid — prefer them over subtotals or insurance-covered amounts.
|
||||||
|
|
||||||
|
These defaults are defined in code (`internal/storage/ai_notes.go`); this list is
|
||||||
|
the human-readable copy. They are only seeded when the notes table is empty, so a
|
||||||
|
deleted default does not come back on restart.
|
||||||
357
SPEC.md
Normal file
357
SPEC.md
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
# HSA Receipt Tracker — Specification
|
||||||
|
|
||||||
|
This is the source of truth for the app's **current** expected behavior, organized
|
||||||
|
by domain. It describes what the app does today, not how it got here — for the
|
||||||
|
history and rationale of each decision see [DESIGN.md](DESIGN.md); for the
|
||||||
|
version-by-version log see [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
|
Keywords **MUST**, **SHOULD**, and **MUST NOT** mark hard requirements vs.
|
||||||
|
recommendations. When behavior changes, amend the relevant section here in the same
|
||||||
|
commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose & scope
|
||||||
|
|
||||||
|
Capture and archive HSA-eligible receipts for two household users, for future
|
||||||
|
reimbursement and tax substantiation. The app is mobile-first (phone camera
|
||||||
|
capture matters). It deliberately does **not** do reimbursement tracking, in-place
|
||||||
|
editing, or general reporting beyond the Tally view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Users, authentication & authorization
|
||||||
|
|
||||||
|
- Two users, both with full shared access — there is no per-user data isolation;
|
||||||
|
everything is visible to every authorized user.
|
||||||
|
- Authentication is **OIDC with PKCE** against an Authelia issuer (`ISSUER_URL`),
|
||||||
|
using the registered client (`OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET`).
|
||||||
|
- The login flow:
|
||||||
|
- `GET /login` generates a PKCE verifier, state, and nonce, stores them in a
|
||||||
|
short-lived (10 min) encrypted `hsa_login` cookie, and redirects to Authelia.
|
||||||
|
- `GET /callback` validates state (CSRF), exchanges the code with the PKCE
|
||||||
|
verifier, verifies the ID token and nonce, then reads claims. Groups and email
|
||||||
|
come from the ID token, falling back to the UserInfo endpoint (Authelia serves
|
||||||
|
these from UserInfo by default).
|
||||||
|
- The user identity (**subject**) is the email, or the preferred username if no
|
||||||
|
email is present.
|
||||||
|
- **Authorization gate:** the user MUST be a member of `REQUIRED_GROUP` (exact,
|
||||||
|
case-sensitive match). Otherwise the callback returns **403**.
|
||||||
|
- On success a `hsa_session` cookie is set for **12 hours**. The session is an
|
||||||
|
**AES-256-GCM** sealed token (tamper-evident; key = `SHA-256(SESSION_SECRET)`),
|
||||||
|
and the 12h lifetime is enforced **server-side** (a session older than the TTL is
|
||||||
|
rejected even if the cookie value is intact), not just via the cookie's MaxAge.
|
||||||
|
Cookies are `HttpOnly`, `SameSite=Lax`, and `Secure` whenever `REDIRECT_URL` is
|
||||||
|
`https://`.
|
||||||
|
- `GET /logout` clears the session and redirects to `/login`.
|
||||||
|
- **Public routes** (no session required): `/healthz`, `/static/*`, `/login`,
|
||||||
|
`/callback`, `/logout`. **Every other route requires a valid session**; missing
|
||||||
|
or invalid sessions redirect to `/login`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configuration
|
||||||
|
|
||||||
|
All runtime config is read from environment variables (a local `.env` is loaded
|
||||||
|
best-effort on startup; see [.env.example](.env.example)). Required vars (startup
|
||||||
|
fails if any is missing): `ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
|
||||||
|
`REDIRECT_URL`, `REQUIRED_GROUP`, `SESSION_SECRET`.
|
||||||
|
|
||||||
|
| Var | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `LISTEN_ADDR` | `:8080` | HTTP listen address |
|
||||||
|
| `DB_PATH` | `./data/hsa.db` | SQLite database file |
|
||||||
|
| `STORAGE_DIR` | `./data` | storage **root** (receipts/attachments live under it) |
|
||||||
|
| `MAX_UPLOAD_MB` | `32` | per-file upload cap (reject larger) |
|
||||||
|
| `CONFIG_PATH` | `./config.json` | people + categories catalog |
|
||||||
|
| `CLAUDE_API_KEY` | _(empty)_ | Anthropic key; empty **disables** AI classification |
|
||||||
|
| `CLASSIFY_MODEL` | `claude-haiku-4-5-20251001` | classification model id |
|
||||||
|
| `BACKUP_DIR` | `./data/dbbackup` | scheduled metadata-only backups |
|
||||||
|
| `BACKUP_INTERVAL` | `168h` | backup frequency (Go duration); `0` disables |
|
||||||
|
| `BACKUP_KEEP` | `8` | most-recent backups to retain |
|
||||||
|
|
||||||
|
**Catalog (`config.json`):** defines `persons` (last/first/middle) and
|
||||||
|
`categories` (name + authored `examples`). On startup the app seeds the canonical
|
||||||
|
person labels (`First Last`) and category names into the DB (insert-if-absent), so
|
||||||
|
labels renamed via Manage survive restarts. The per-category `examples` are used
|
||||||
|
only to build the classifier prompt and are intentionally **not** stored in the DB.
|
||||||
|
A missing/invalid catalog is a fatal startup error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data model (SQLite)
|
||||||
|
|
||||||
|
- **categories** — `id`, `label` (unique). Seeded; renamable via Manage.
|
||||||
|
- **people** — `id`, `label` (unique). Seeded from catalog; renamable via Manage. Format is "<FIRST NAME> <LAST NAME>"
|
||||||
|
- **receipts** — `id` (UUID), `uploaded_by`, `uploaded_at`, `receipt_date`,
|
||||||
|
`amount_cents` (integer — money is never a float), `category_id` (FK, required),
|
||||||
|
`person_id` (FK, nullable), `file_path`, `image_data` (BLOB), `file_size_bytes`,
|
||||||
|
`original_filename`, `mime_type`, `deleted_at` (nullable, soft delete).
|
||||||
|
- **attachments** — same shape as receipts minus the receipt-specific fields, plus
|
||||||
|
`receipt_id` (FK). Supplementary files for a receipt; no amount/date/category of
|
||||||
|
their own.
|
||||||
|
- **tags** — `id`, `label` (unique, **case-insensitive** via `COLLATE NOCASE`).
|
||||||
|
- **receipt_tags** — (`receipt_id`, `tag_id`) many-to-many, composite primary key.
|
||||||
|
- **ai_notes** — `id`, `text`, `created_at`, `deleted_at` (nullable). Temporal:
|
||||||
|
edits soft-delete + insert, so the active set at any past time is recoverable.
|
||||||
|
- **classifications** — `receipt_id` (PK/FK), `model`, `response_json` (the AI
|
||||||
|
suggestion blob), `created_at`, `reviewed`, `reviewed_at`, `resolution`.
|
||||||
|
- **miss_fixes** — (`receipt_id`, `note_id`) linking a reviewed misread to the
|
||||||
|
note(s) credited with fixing it.
|
||||||
|
|
||||||
|
`PRAGMA foreign_keys=ON`, `journal_mode=WAL`, `busy_timeout=5000` are set on open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Upload & receipt creation
|
||||||
|
|
||||||
|
The upload form (`GET /{$}`) and its handler (`POST /upload`) are the core flow.
|
||||||
|
|
||||||
|
### 5.1 Fields & validation
|
||||||
|
A submission MUST include: an **amount**, a **date** (`YYYY-MM-DD`), a **category**,
|
||||||
|
and a **receipt file**. The **who** (person) and **tags** are optional, as are
|
||||||
|
additional attachment files.
|
||||||
|
|
||||||
|
- **Amount** is parsed to integer cents; accepts an optional `$`, spaces, and
|
||||||
|
thousands separators; rejects empty, non-numeric, more than two decimals, zero,
|
||||||
|
and negatives.
|
||||||
|
- **Category** MUST reference an existing category; **who**, if provided, MUST
|
||||||
|
reference an existing person.
|
||||||
|
- **File** MUST be detected as `image/*` or `application/pdf` and be within
|
||||||
|
`MAX_UPLOAD_MB`. Same allowlist and cap apply to each attachment.
|
||||||
|
- On any validation error the form re-renders with messages and **nothing is
|
||||||
|
written**; the user's entered values, including the tag selection, are preserved.
|
||||||
|
|
||||||
|
### 5.2 Image orientation normalization
|
||||||
|
On upload, a JPEG carrying an EXIF Orientation tag of 2..8 is rotated/flipped
|
||||||
|
upright, re-encoded (JPEG q≈90), and the tag dropped, so the stored image is
|
||||||
|
upright everywhere. Images with no tag, an upright tag (1), non-JPEG images, PDFs,
|
||||||
|
or anything that fails to decode pass through **byte-for-byte**. This applies to
|
||||||
|
the receipt, every attachment, and the image sent to the classifier. (No
|
||||||
|
content-based guessing; new uploads only.)
|
||||||
|
|
||||||
|
### 5.3 AI auto-fill (classification)
|
||||||
|
When `CLAUDE_API_KEY` is set, attaching a file triggers `POST /classify`, which
|
||||||
|
sends the (orientation-normalized) image to the configured model and returns
|
||||||
|
suggested amount, date, category, and who to **pre-fill** the form. It mutates no
|
||||||
|
state — the user reviews and submits normally.
|
||||||
|
|
||||||
|
- Classification is **forced tool-use**, so the result is always structured. The
|
||||||
|
**person** is constrained to the canonical labels (kept only on exact match);
|
||||||
|
the **category** is constrained to the configured set, falling back to the last
|
||||||
|
(most general) category if the model returns something off-list. Date/amount are
|
||||||
|
dropped if null-ish.
|
||||||
|
- The user can tick **Skip AI** to suppress the call and fill fields by hand. The
|
||||||
|
footnote always shows which model runs (or that auto-fill is off).
|
||||||
|
- After a successful classification the per-call cost is shown in cents (¢),
|
||||||
|
computed locally from the response's token usage and a hardcoded per-model rate
|
||||||
|
table. An unknown model id shows token usage but omits the ¢ figure (never a
|
||||||
|
guess).
|
||||||
|
- The active **correction notes** (§10) are appended to the classifier prompt. The
|
||||||
|
suggestion the model returns is round-tripped through the form and **recorded**
|
||||||
|
against the saved receipt, so misreads can be reviewed later (§10). Skip-AI and
|
||||||
|
disabled-classification uploads record nothing.
|
||||||
|
|
||||||
|
### 5.4 Duplicate warning
|
||||||
|
Whenever date and amount are both known (edit: those are mandatory fields aren't they?), the form calls `GET /duplicates` and warns
|
||||||
|
if a non-deleted receipt already has the **same `receipt_date` and `amount_cents`**.
|
||||||
|
The warning lists each match (amount, date, category, who, uploader, upload time,
|
||||||
|
filename) and **disables submit** until the user ticks "Add it anyway". No match →
|
||||||
|
submit proceeds normally.
|
||||||
|
|
||||||
|
### 5.5 Attachments
|
||||||
|
The form accepts optional extra files in the same submission (a second page, an
|
||||||
|
EOB, an itemized list). They inherit the parent receipt's identity, carry no
|
||||||
|
metadata of their own, and are saved after the receipt row exists. AI runs only on
|
||||||
|
the primary receipt, never on attachments.
|
||||||
|
|
||||||
|
### 5.6 Tags
|
||||||
|
The form has an **"Add tags"** control after **Who** that opens an in-page modal
|
||||||
|
with an alphabetical chip mosaic. The user taps chips to select/deselect and can
|
||||||
|
create a new tag inline (auto-selected for this receipt). Selected tags submit with
|
||||||
|
the form.
|
||||||
|
|
||||||
|
- The tag catalog is **shared/global** and **free-form**.
|
||||||
|
- A new tag is written to the catalog **only when the receipt is actually saved**
|
||||||
|
(create-if-missing by label), so abandoned uploads add nothing. Labels are
|
||||||
|
trimmed and matched/deduped **case-insensitively** (first-seen casing kept).
|
||||||
|
- A receipt's tags are shown on the confirmation page and in the recent lists.
|
||||||
|
- The classifier does **not** suggest tags — tagging is a manual act.
|
||||||
|
|
||||||
|
### 5.7 Storage (dual-write & file layout)
|
||||||
|
Each receipt and attachment is written in **both** places:
|
||||||
|
1. **Filesystem**, under the storage root with a browsable, dated, amount-tagged
|
||||||
|
name:
|
||||||
|
- receipts: `<STORAGE_DIR>/receipts/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>`
|
||||||
|
- attachments: `<STORAGE_DIR>/attachments/<YYYY>/<MM>_<DD>_<dollars>.<cents>_att<ext>`
|
||||||
|
- Year/month/day and amount come from the **receipt** date/amount (attachments
|
||||||
|
use their parent's), so an expense's files sort together. Path components
|
||||||
|
derive only from date + amount (no user filename → no path-traversal surface).
|
||||||
|
- Name collisions get a numeric suffix on the stem (`_1`, `_2`, …) via exclusive
|
||||||
|
create (`O_CREATE|O_EXCL`), so concurrent uploads never overwrite.
|
||||||
|
2. **DB blob** (`image_data`), so the single `.db` file is a complete, portable
|
||||||
|
dataset (metadata + all images).
|
||||||
|
|
||||||
|
`original_filename` and `mime_type` are kept as metadata. The file is written
|
||||||
|
before the DB row, so a crash in between can leave a harmless orphan file (never a
|
||||||
|
row missing its data); orphans are not auto-cleaned. The DB blob is the source of
|
||||||
|
truth for serving.
|
||||||
|
|
||||||
|
After a successful save, a confirmation page shows category, who, amount, date,
|
||||||
|
filename, attachment count, and tags, with links to add another / Manage / Export.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Viewing & serving files
|
||||||
|
|
||||||
|
- `GET /receipt/{id}/file` and `GET /attachment/{id}/file` serve the stored bytes
|
||||||
|
**from the DB blob** (so serving works even if the on-disk copy is gone), with
|
||||||
|
the original MIME type, `Content-Disposition: inline`, and
|
||||||
|
`X-Content-Type-Options: nosniff` (so the browser won't sniff user bytes past the
|
||||||
|
declared, upload-time-allowlisted type).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Recent lists
|
||||||
|
|
||||||
|
Two read-only listings of non-deleted receipts, 10 per page with a "Load next 10"
|
||||||
|
control (offset paging):
|
||||||
|
|
||||||
|
- `GET /recent` — ordered by **upload date** (`uploaded_at` desc).
|
||||||
|
- `GET /recent/receipts` — ordered by **receipt date** (`receipt_date` desc).
|
||||||
|
|
||||||
|
Each row shows: amount (linking to the file), receipt date, category, the **who**
|
||||||
|
abbreviated to initials + last name (e.g. "Jean-Michel Tremblay" → "JM. Tremblay";
|
||||||
|
each hyphenated first-name part is initialed), the upload date, paperclip links for
|
||||||
|
any attachments, and the receipt's tag chips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tally
|
||||||
|
|
||||||
|
`GET /tally` — a read-only person × year matrix of summed amounts (dollars):
|
||||||
|
|
||||||
|
- One row per person, one column per year that has data; receipts with no who are
|
||||||
|
grouped under **"Unassigned"** (sorted last).
|
||||||
|
- Right-margin column: total per person across years. Bottom-margin row: grand
|
||||||
|
total per year. Bottom-right: overall grand total.
|
||||||
|
- Excludes soft-deleted rows. Years are bucketed by the **receipt** date.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Manage lists
|
||||||
|
|
||||||
|
`GET /manage` lets authorized users curate the lookup lists:
|
||||||
|
|
||||||
|
- **Categories** and **People**: rename existing entries (`POST
|
||||||
|
/manage/categories/rename`, `/manage/people/rename`) and add new ones (`POST
|
||||||
|
/manage/categories`, `/manage/people`). Renames propagate everywhere because
|
||||||
|
receipts reference these by id.
|
||||||
|
- On startup, stray partial-name people (e.g. a leftover "Jude") are merged into the
|
||||||
|
unambiguous canonical person ("Jude Tremblay"), reassigning their receipts first,
|
||||||
|
so no receipt loses its who. Seeding is idempotent.
|
||||||
|
- Tags are **not** managed here (see §15 gaps).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. AI classifier notes & misread review
|
||||||
|
|
||||||
|
`GET /ai` — a tab for improving classification over time.
|
||||||
|
|
||||||
|
- **Correction notes:** a single global list of free-text rules, appended to the
|
||||||
|
classifier prompt (§5.3). Managed inline — add (`POST /ai/notes`), edit (`POST
|
||||||
|
/ai/notes/edit`), delete (`POST /ai/notes/delete`). The table is **temporal**: an
|
||||||
|
edit soft-deletes the old row and inserts a new one, so the notes active when any
|
||||||
|
past receipt was classified are recoverable. Seeded once from a hardcoded default
|
||||||
|
list (documented in the README; no PII) only when the table is empty, so a deleted
|
||||||
|
default does not return. Read live on every classify call (no restart).
|
||||||
|
- **Read-only prompt view:** renders the exact system prompt that would be sent today
|
||||||
|
(static scaffolding + injected people/categories/today + active notes), when
|
||||||
|
classification is enabled.
|
||||||
|
- **Misread review:** every AI-run upload stores the suggestion blob + model. A
|
||||||
|
**miss** is *derived* (never stored) — any of the four AI fields (amount, date,
|
||||||
|
category, who) differing from the receipt's final value, where AI-null-then-filled
|
||||||
|
counts as a miss. The review queue lists unreviewed misses; `GET /ai/review/{id}`
|
||||||
|
shows the receipt image, the AI-guess-vs-entered per field, and the notes added
|
||||||
|
*since* that classification. The user ticks which notes fixed it (`POST
|
||||||
|
/ai/review/{id}`) → resolution `fixed` and `miss_fixes` links; ticking none closes
|
||||||
|
it `unresolved`. Either way the row is marked reviewed.
|
||||||
|
|
||||||
|
Notes live only in the DB (private, not in git; included in `/export/db` and
|
||||||
|
backups). The stored suggestion blob is client-supplied diagnostic data, not ground
|
||||||
|
truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Export
|
||||||
|
|
||||||
|
- `GET /export/db` — downloads a consistent point-in-time copy of the SQLite
|
||||||
|
database produced via `VACUUM INTO` (the live file is never locked/served
|
||||||
|
directly). Because images are stored as blobs, this single file is the **complete
|
||||||
|
dataset**. `Content-Type: application/octet-stream`, filename
|
||||||
|
`hsa-export-YYYY-MM-DD.db`.
|
||||||
|
- `GET /export/db?blobs=false` — a **metadata-only** copy with image blobs stripped
|
||||||
|
(≈99% smaller); filename `hsa-export-YYYY-MM-DD-metadata.db`.
|
||||||
|
- Read-only; mutates no app state. Amounts stay integer cents in the export.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Scheduled backups
|
||||||
|
|
||||||
|
A background goroutine writes **metadata-only** snapshots (blobs stripped from both
|
||||||
|
receipts and attachments) into `BACKUP_DIR`:
|
||||||
|
|
||||||
|
- Frequency `BACKUP_INTERVAL` (default weekly; `0` disables), retaining the most
|
||||||
|
recent `BACKUP_KEEP` (default 8).
|
||||||
|
- Files are named `hsa_sqlite_backup_<YYYY_MM_DD>.db` — **one per day**; a same-day
|
||||||
|
re-run is a no-op, so frequent restarts never spam the directory.
|
||||||
|
- Runs once at startup (if the latest backup is due), then on the interval. Failures
|
||||||
|
are logged, never fatal. Images are recoverable from the on-disk files and the
|
||||||
|
full `/export/db`; the daily backup recovers metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Durability & crash recovery
|
||||||
|
|
||||||
|
- WAL mode makes the DB self-healing: an interrupted write is rolled forward if
|
||||||
|
committed, discarded otherwise, bringing the DB up at its last committed state.
|
||||||
|
- On open the app runs `PRAGMA quick_check`; a crash-interrupted write passes, but
|
||||||
|
genuine corruption fails fast and the app **refuses to start**, pointing the
|
||||||
|
operator at `BACKUP_DIR` / a `.db` export to restore.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Deployment & operations
|
||||||
|
|
||||||
|
- The app builds as a fully static binary (`CGO_ENABLED=0`; the SQLite driver is
|
||||||
|
pure Go), runnable on any `linux/<arch>`.
|
||||||
|
- It runs as a **user systemd service** behind a reverse proxy. Tagged releases are
|
||||||
|
built and deployed by Forgejo Actions: a release tag `X.Y.Z` is built, tested,
|
||||||
|
staged into `~/hsa-app/releases/hsa-app-V<tag>/`, then **activated** (symlink swap
|
||||||
|
+ service restart); pre-release tags (e.g. `0.0.0a2`) are built and staged only.
|
||||||
|
See [deploy/INSTALL.md](deploy/INSTALL.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Known gaps / not yet implemented
|
||||||
|
|
||||||
|
These are intentionally absent today (the schema or storage layer may support some;
|
||||||
|
the UI/endpoint does not):
|
||||||
|
|
||||||
|
- **Deleting receipts:** `deleted_at` and a `SoftDelete` storage method exist and
|
||||||
|
all listings/tally already exclude deleted rows, but **no route or UI triggers a
|
||||||
|
delete** — there is currently no way to delete a receipt from the app.
|
||||||
|
- **Editing** existing receipts (fix by deleting and re-adding — once delete exists).
|
||||||
|
- **Adding attachments or tags to an already-saved receipt** (no edit/detail page).
|
||||||
|
- **Duplicate same-person highlighting:** the warning matches on date + amount only;
|
||||||
|
it does not yet weight or highlight a same-person match differently.
|
||||||
|
- **Tag features:** no tag-based filtering/search, no tally-by-tag, and no
|
||||||
|
editing/merging/deleting tags in Manage (free-form tags will accumulate; a cleanup
|
||||||
|
surface is future work).
|
||||||
|
- **Archive/zip export** (`/export/archive`): not implemented; only `/export/db`.
|
||||||
|
- **HEIC** images are not decodable in pure Go; browser camera uploads arrive as
|
||||||
|
JPEG in practice.
|
||||||
|
|
||||||
|
Related future work now that AI notes + review exist (§10): per-category/vendor-
|
||||||
|
scoped notes, automatic re-classification of past misses, editing the static prompt
|
||||||
|
scaffolding, and accuracy-rate stats. See [DESIGN.md](DESIGN.md) item 15.
|
||||||
110
deploy/AUTH.md
Normal file
110
deploy/AUTH.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Authentication — Authelia (OIDC)
|
||||||
|
|
||||||
|
The app is an **OIDC Relying Party**: it runs the standard authorization-code + PKCE
|
||||||
|
flow against Authelia directly. It is **not** behind Authelia's forward-auth /
|
||||||
|
`access_control` (note `hsa.maisym.com` is intentionally absent from those rules).
|
||||||
|
So Authelia **authenticates** the user (password + WebAuthn); the **app**
|
||||||
|
**authorizes** them via the `hsa-users` group claim.
|
||||||
|
|
||||||
|
## Flow — every redirect & call, and what it carries
|
||||||
|
|
||||||
|
Two cookies are in play: **`hsa_login`** (short-lived, holds the PKCE verifier +
|
||||||
|
state + nonce during the login round-trip) and **`hsa_session`** (the 12h
|
||||||
|
authenticated session). Front-channel = via the browser (302 redirects);
|
||||||
|
back-channel = direct server-to-server calls the browser never sees.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor Browser
|
||||||
|
participant App as App (hsa.maisym.com)
|
||||||
|
participant Authelia as Authelia (auth.maisym.com)
|
||||||
|
|
||||||
|
Note over App,Authelia: at startup, the App discovers Authelia via<br/>GET /.well-known/openid-configuration and JWKS<br/>(learns its endpoints and signing keys)
|
||||||
|
|
||||||
|
Browser->>App: GET / (protected page, no hsa_session)
|
||||||
|
App-->>Browser: 302 to /login
|
||||||
|
|
||||||
|
Browser->>App: GET /login
|
||||||
|
Note right of App: make PKCE verifier, state, nonce<br/>seal them into the hsa_login cookie (10m)
|
||||||
|
App-->>Browser: 302 to Authelia /authorize, Set-Cookie hsa_login<br/>params client_id, redirect_uri, response_type=code,<br/>scope openid profile email groups, state, nonce,<br/>code_challenge S256 of verifier, method S256
|
||||||
|
|
||||||
|
Browser->>Authelia: GET /authorize with those params
|
||||||
|
Note over Browser,Authelia: password and WebAuthn (2FA)
|
||||||
|
Authelia-->>Browser: 302 to /callback with code and state
|
||||||
|
|
||||||
|
Browser->>App: GET /callback (code, state, Cookie hsa_login)
|
||||||
|
Note right of App: decode hsa_login and check<br/>returned state equals stored state (CSRF)
|
||||||
|
App->>Authelia: POST /token back-channel<br/>code, code_verifier, redirect_uri,<br/>grant_type authorization_code,<br/>Authorization Basic client_id and client_secret
|
||||||
|
Authelia-->>App: access_token and id_token (JWT)
|
||||||
|
Note right of App: verify id_token signature via JWKS<br/>and check nonce equals stored nonce
|
||||||
|
App->>Authelia: GET /userinfo back-channel<br/>Authorization Bearer access_token
|
||||||
|
Authelia-->>App: email, preferred_username, groups
|
||||||
|
Note right of App: require hsa-users in groups, else 403<br/>seal Session subject, groups, issuedAt<br/>into hsa_session cookie (12h, AES-256-GCM)
|
||||||
|
App-->>Browser: 302 to /, Set-Cookie hsa_session, clear hsa_login
|
||||||
|
|
||||||
|
Browser->>App: GET / (Cookie hsa_session)
|
||||||
|
Note right of App: decrypt and verify hsa_session,<br/>reject if older than the 12h TTL
|
||||||
|
App-->>Browser: 200, the app
|
||||||
|
```
|
||||||
|
|
||||||
|
## The contract — what must agree on both sides
|
||||||
|
|
||||||
|
| Authelia `configuration.yml` | App `.env` | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| issuer `https://auth.maisym.com` | `ISSUER_URL` | app discovers `ISSUER_URL/.well-known/openid-configuration` |
|
||||||
|
| `client_id: hsa-tracker` | `OIDC_CLIENT_ID` | exact match |
|
||||||
|
| `client_secret` (**argon2id hash**) | `OIDC_CLIENT_SECRET` (**plaintext**) | Authelia stores the hash; the app holds the plaintext that hashes to it |
|
||||||
|
| `redirect_uris` | `REDIRECT_URL` | must match character-for-character (`https://hsa.maisym.com/callback`) |
|
||||||
|
| `scopes: [openid, profile, email, groups]` | — | the **`groups`** scope delivers the claim authz depends on |
|
||||||
|
| `require_pkce: true`, `pkce_challenge_method: S256` | — | the app always uses PKCE S256 |
|
||||||
|
| `token_endpoint_auth_method: client_secret_basic` | — | matches the Go OAuth2 client default |
|
||||||
|
| `userinfo_signed_response_alg: 'none'` | — | the app reads UserInfo as plain JSON |
|
||||||
|
| — | `REQUIRED_GROUP=hsa-users` | the group the app demands; defined per-user in `users_database.yml` |
|
||||||
|
|
||||||
|
## The Authelia client block (this deployment)
|
||||||
|
|
||||||
|
In `identity_providers.oidc.clients` (secret redacted):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- client_id: 'hsa-tracker'
|
||||||
|
client_name: 'HSA Receipt Tracker'
|
||||||
|
client_secret: '$argon2id$v=19$m=65536,t=3,p=4$argon2xxxxxxxxxxx' # hash; plaintext lives in the app .env
|
||||||
|
public: false
|
||||||
|
authorization_policy: 'two_factor' # users must pass WebAuthn
|
||||||
|
require_pkce: true
|
||||||
|
pkce_challenge_method: 'S256'
|
||||||
|
redirect_uris:
|
||||||
|
- 'https://hsa.maisym.com/callback'
|
||||||
|
- 'http://localhost:8080/callback' # local dev
|
||||||
|
scopes: ['openid', 'profile', 'email', 'groups']
|
||||||
|
response_types: ['code']
|
||||||
|
grant_types: ['authorization_code']
|
||||||
|
token_endpoint_auth_method: 'client_secret_basic'
|
||||||
|
userinfo_signed_response_alg: 'none'
|
||||||
|
```
|
||||||
|
|
||||||
|
## The three things that actually bite
|
||||||
|
|
||||||
|
1. **client_secret is a hash on the Authelia side, plaintext on the app side.** To
|
||||||
|
rotate: `authelia crypto hash generate argon2 --password '<plaintext>'`, put the
|
||||||
|
resulting `$argon2id$...` **hash** in `configuration.yml` and the **plaintext** in
|
||||||
|
the app's `OIDC_CLIENT_SECRET`. A mismatch fails the token exchange.
|
||||||
|
2. **redirect_uri must match exactly** (scheme, host, path, no trailing slash) or
|
||||||
|
Authelia refuses the callback.
|
||||||
|
3. **The `groups` scope is load-bearing.** Drop it and the app gets no `groups`
|
||||||
|
claim → no `hsa-users` → **403 for everyone**.
|
||||||
|
|
||||||
|
## Who can log in / how to revoke
|
||||||
|
|
||||||
|
Access requires an Authelia account that passes 2FA **and** is in `hsa-users`
|
||||||
|
(`users_database.yml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
users:
|
||||||
|
jm: { groups: ['admins', 'hsa-users'], ... } # password: '$argon2id$...argon2xxxxxxxxxxx'
|
||||||
|
lynna: { groups: ['hsa-users'], ... } # password: '$argon2id$...argon2xxxxxxxxxxx'
|
||||||
|
```
|
||||||
|
|
||||||
|
To revoke someone: remove them from the `hsa-users` group (or set `disabled: true`).
|
||||||
|
An already-issued app session still lasts up to its 12h server-side TTL.
|
||||||
77
deploy/CICD.md
Normal file
77
deploy/CICD.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# CI/CD — what git actions trigger what
|
||||||
|
|
||||||
|
The pipeline is a single Forgejo Actions workflow,
|
||||||
|
[.forgejo/workflows/build.yml](../.forgejo/workflows/build.yml), triggered on every
|
||||||
|
push (`on: [push]`, which covers both branch and tag pushes). This document explains
|
||||||
|
which git action produces which outcome. For one-time host setup, the on-disk
|
||||||
|
layout, and manual deploy/rollback, see [INSTALL.md](INSTALL.md).
|
||||||
|
|
||||||
|
## Trigger behavior at a glance
|
||||||
|
|
||||||
|
| You push… | Build + test | Stage binary to host | Activate (go live) |
|
||||||
|
|---|:---:|:---:|:---:|
|
||||||
|
| any branch (no tag) | ✅ | — | — |
|
||||||
|
| a **release** tag `X.Y.Z` (e.g. `1.4.0`) | ✅ | ✅ | ✅ |
|
||||||
|
| a **pre-release** tag (anything else, e.g. `0.0.0a3`) | ✅ | ✅ | — |
|
||||||
|
|
||||||
|
- **Branch push** → builds and runs the full test suite, uploads the `hsa` binary as
|
||||||
|
a run artifact. Nothing touches the server. This is your PR / pre-merge gate.
|
||||||
|
- **Release tag** `X.Y.Z` → builds, tests, copies the binary to the host, and makes
|
||||||
|
it live (symlink swap + service restart).
|
||||||
|
- **Pre-release tag** → builds, tests, and copies the binary to the host, but does
|
||||||
|
**not** activate it. Use this to park a build on the server (QA, manual
|
||||||
|
activation) without flipping production.
|
||||||
|
|
||||||
|
The release-vs-pre-release decision is purely the tag name: the **Activate** step
|
||||||
|
runs only when the tag matches the regex `^[0-9]+\.[0-9]+\.[0-9]+$`. Note this means
|
||||||
|
a `v`-prefixed tag (`v1.4.0`) would build + stage but **not** activate — tag with
|
||||||
|
**bare numbers** (`1.4.0`).
|
||||||
|
|
||||||
|
## What each step does
|
||||||
|
|
||||||
|
1. **Checkout** — `git clone` of the pushed branch/tag (the runner doesn't use a
|
||||||
|
checkout action).
|
||||||
|
2. **Test** — `go test ./...` inside a `golang:1.26` container.
|
||||||
|
3. **Build** — static binary: `CGO_ENABLED=0 go build -buildvcs=false -ldflags "-s -w"`
|
||||||
|
(pure-Go SQLite, so no C toolchain; `-buildvcs=false` avoids the container's
|
||||||
|
dubious-ownership git error).
|
||||||
|
4. **Upload binary** — the `hsa` binary as a downloadable run artifact.
|
||||||
|
5. **Stage release** *(tags only)* — `scp` the binary to
|
||||||
|
`~/hsa-app/releases/hsa-app-V<tag>/hsa` on the host and `chmod +x` it.
|
||||||
|
6. **Activate release** *(release tags `X.Y.Z` only)* — `ln -sfn` the
|
||||||
|
`~/hsa-app/hsa` symlink to the new release and `systemctl --user restart hsa_app`.
|
||||||
|
Pre-release tags log "staged but not activated" and stop here.
|
||||||
|
|
||||||
|
## Cutting a release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. land your change on main (push branch -> CI builds/tests -> merge)
|
||||||
|
# 2. tag and push:
|
||||||
|
git tag -a 1.4.0 -m "1.4.0 — <summary>"
|
||||||
|
git push origin 1.4.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The tag push runs the whole pipeline through Activate. Watch it under the repo's
|
||||||
|
**Actions** tab; the **Activate release** step is the one that goes live. Roll back
|
||||||
|
by activating an older still-staged release (see [INSTALL.md](INSTALL.md)).
|
||||||
|
|
||||||
|
## Runner requirements
|
||||||
|
|
||||||
|
- One Forgejo `act_runner` registered with the **`shell`** label (`runs-on: shell`).
|
||||||
|
The job runs directly on the host, which therefore must have the **docker CLI**
|
||||||
|
available (the Test/Build steps run inside `golang:1.26` via `docker run`).
|
||||||
|
- The deploy steps `ssh`/`scp` from the runner host to the app host (they may be the
|
||||||
|
same machine). The app must run as a **user systemd service** named `hsa_app`,
|
||||||
|
reachable via `systemctl --user` — which requires `loginctl enable-linger` for the
|
||||||
|
deploy user (see [INSTALL.md](INSTALL.md)).
|
||||||
|
|
||||||
|
## Required repo configuration (Settings → Actions)
|
||||||
|
|
||||||
|
| Name | Kind | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `FORGEJO_SSH` | **Secret** | private SSH key authorized on the app host |
|
||||||
|
| `HSA_APP_HOST` | **Variable** | app host name / IP |
|
||||||
|
| `HSA_APP_USER` | **Variable** | SSH user that owns `~/hsa-app` and the user service |
|
||||||
|
|
||||||
|
If these are missing, branch pushes still build/test, but the Stage/Activate steps
|
||||||
|
fail on tags.
|
||||||
|
|
@ -13,6 +13,9 @@ The symlink decouples "what's on disk" from "what's running," so activating a
|
||||||
staged pre-release or rolling back is just a symlink repoint + restart (see
|
staged pre-release or rolling back is just a symlink repoint + restart (see
|
||||||
below).
|
below).
|
||||||
|
|
||||||
|
For the pipeline side — which git pushes/tags trigger which steps, the runner
|
||||||
|
requirements, and the required repo secrets/variables — see [CICD.md](CICD.md).
|
||||||
|
|
||||||
## Layout on the host
|
## Layout on the host
|
||||||
|
|
||||||
Mutable state (DB, env, config) lives at the top of `~/hsa-app/` and survives
|
Mutable state (DB, env, config) lives at the top of `~/hsa-app/` and survives
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -4,7 +4,9 @@ go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0
|
github.com/coreos/go-oidc/v3 v3.18.0
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
|
modernc.org/sqlite v1.52.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -14,9 +16,9 @@ require (
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.52.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
35
go.sum
35
go.sum
|
|
@ -1,27 +1,62 @@
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
||||||
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,12 @@ func New(apiKey, model string, persons []config.Person, categories []config.Cate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PromptPreview renders the exact system prompt that would be sent today with the
|
||||||
|
// given notes, for the read-only view on the AI tab.
|
||||||
|
func (c *Classifier) PromptPreview(today time.Time, notes []string) string {
|
||||||
|
return BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02"), notes)
|
||||||
|
}
|
||||||
|
|
||||||
const toolName = "record_receipt"
|
const toolName = "record_receipt"
|
||||||
|
|
||||||
// --- request / response shapes ---
|
// --- request / response shapes ---
|
||||||
|
|
@ -139,8 +145,8 @@ type toolInput struct {
|
||||||
// Classify sends the receipt to the model and returns its normalized reading.
|
// Classify sends the receipt to the model and returns its normalized reading.
|
||||||
// today is used so the date heuristics ("closest to today, never future") are
|
// today is used so the date heuristics ("closest to today, never future") are
|
||||||
// testable; pass time.Now().
|
// testable; pass time.Now().
|
||||||
func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte, mimeType string) (Suggestion, error) {
|
func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte, mimeType string, notes []string) (Suggestion, error) {
|
||||||
system := BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02"))
|
system := BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02"), notes)
|
||||||
|
|
||||||
imgBlock := apiBlock{
|
imgBlock := apiBlock{
|
||||||
Type: "image",
|
Type: "image",
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ func TestClassifyHappyPath(t *testing.T) {
|
||||||
"raw_amount": "$42.50",
|
"raw_amount": "$42.50",
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg")
|
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Classify: %v", err)
|
t.Fatalf("Classify: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +99,7 @@ func TestClassifyNullsAndFallback(t *testing.T) {
|
||||||
"raw_amount": "",
|
"raw_amount": "",
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg")
|
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Classify: %v", err)
|
t.Fatalf("Classify: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +122,7 @@ func TestClassifyRejectsNonCanonicalPerson(t *testing.T) {
|
||||||
"person": "Dr. Emily Smith", // a provider, not a configured patient
|
"person": "Dr. Emily Smith", // a provider, not a configured patient
|
||||||
"category": "Medical",
|
"category": "Medical",
|
||||||
})
|
})
|
||||||
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg")
|
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Classify: %v", err)
|
t.Fatalf("Classify: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +148,7 @@ func TestClassifyIntegration(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("set HSA_CLASSIFY_IMG to a receipt image: %v", err)
|
t.Skipf("set HSA_CLASSIFY_IMG to a receipt image: %v", err)
|
||||||
}
|
}
|
||||||
got, err := c.Classify(context.Background(), time.Now(), img, "image/jpeg")
|
got, err := c.Classify(context.Background(), time.Now(), img, "image/jpeg", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Classify: %v", err)
|
t.Fatalf("Classify: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,9 @@ func uniqueCompound(p config.Person, all []config.Person) bool {
|
||||||
// catalog and today's date (YYYY-MM-DD). The catalog's canonical labels are the only
|
// catalog and today's date (YYYY-MM-DD). The catalog's canonical labels are the only
|
||||||
// allowed outputs; variants and examples are presented as illustrations, never as
|
// allowed outputs; variants and examples are presented as illustrations, never as
|
||||||
// data to extract.
|
// data to extract.
|
||||||
func BuildSystemPrompt(persons []config.Person, categories []config.Category, today string) string {
|
// notes are user-authored corrections appended verbatim as a final section; pass nil
|
||||||
|
// for none.
|
||||||
|
func BuildSystemPrompt(persons []config.Person, categories []config.Category, today string, notes []string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
b.WriteString("You extract data from a US health-care receipt for HSA reimbursement. ")
|
b.WriteString("You extract data from a US health-care receipt for HSA reimbursement. ")
|
||||||
|
|
@ -178,6 +180,18 @@ func BuildSystemPrompt(persons []config.Person, categories []config.Category, to
|
||||||
b.WriteString("RAW — also pass back the literal text you read for the name, date, and amount (raw_name, raw_date, raw_amount), exactly as printed, for auditing. Use \"\" if nothing was found.\n")
|
b.WriteString("RAW — also pass back the literal text you read for the name, date, and amount (raw_name, raw_date, raw_amount), exactly as printed, for auditing. Use \"\" if nothing was found.\n")
|
||||||
b.WriteString("If the image holds more than one receipt, read the primary (largest/topmost) one.\n")
|
b.WriteString("If the image holds more than one receipt, read the primary (largest/topmost) one.\n")
|
||||||
|
|
||||||
|
// User corrections learned from past misreads (see the AI tab). Appended last so
|
||||||
|
// they take precedence over the general guidance above.
|
||||||
|
if len(notes) > 0 {
|
||||||
|
b.WriteString("\nADDITIONAL CORRECTIONS — learned from past mistakes on these specific receipts; apply them:\n")
|
||||||
|
for _, n := range notes {
|
||||||
|
n = strings.TrimSpace(n)
|
||||||
|
if n != "" {
|
||||||
|
fmt.Fprintf(&b, " - %s\n", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ func TestBuildSystemPrompt(t *testing.T) {
|
||||||
{Name: "Pharmacy", Examples: []string{"CVS", "Rx"}},
|
{Name: "Pharmacy", Examples: []string{"CVS", "Rx"}},
|
||||||
{Name: "Other"},
|
{Name: "Other"},
|
||||||
}
|
}
|
||||||
p := BuildSystemPrompt(persons, categories, "2026-06-17")
|
p := BuildSystemPrompt(persons, categories, "2026-06-17", []string{"Treat handwritten totals as authoritative."})
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Jean-Michel Tremblay", // canonical label
|
"Jean-Michel Tremblay", // canonical label
|
||||||
|
|
|
||||||
130
internal/storage/ai_notes.go
Normal file
130
internal/storage/ai_notes.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note is one classifier correction note. Notes are global free-text lines appended
|
||||||
|
// to the classifier prompt. The table is temporal: an edit soft-deletes the old row
|
||||||
|
// and inserts a new one, so the set of notes active at any past time is recoverable.
|
||||||
|
type Note struct {
|
||||||
|
ID int64
|
||||||
|
Text string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultNotes seed the ai_notes table on first run (when it is completely empty).
|
||||||
|
// They carry no PII and are also documented in the README; users curate from here.
|
||||||
|
var defaultNotes = []string{
|
||||||
|
`Amounts that use a comma as the decimal separator (e.g. "12,50") mean 12.50, not 1250.`,
|
||||||
|
`When both a service/visit date and a separate statement, print, or due date appear, use the service date.`,
|
||||||
|
`"Patient Pay", "You Paid", "Amount Due", and "Patient Responsibility" are the amount actually paid — prefer them over subtotals or insurance-covered amounts.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedNotesIfEmpty inserts the default notes only when the table has no rows at all,
|
||||||
|
// so a deliberately-deleted default does not resurrect on the next restart.
|
||||||
|
func seedNotesIfEmpty(db *sql.DB) error {
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM ai_notes`).Scan(&n); err != nil {
|
||||||
|
return fmt.Errorf("count ai_notes: %w", err)
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(rfc3339)
|
||||||
|
for _, t := range defaultNotes {
|
||||||
|
if _, err := db.Exec(`INSERT INTO ai_notes(text, created_at) VALUES (?, ?)`, t, now); err != nil {
|
||||||
|
return fmt.Errorf("seed ai_notes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveNotes returns the live notes (not soft-deleted), oldest first — the set
|
||||||
|
// appended to the classifier prompt.
|
||||||
|
func (s *Store) ListActiveNotes() ([]Note, error) {
|
||||||
|
return s.queryNotes(`SELECT id, text, created_at FROM ai_notes
|
||||||
|
WHERE deleted_at IS NULL ORDER BY created_at, id`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotesCreatedAfter returns the live notes created strictly after t — the
|
||||||
|
// "rules added since this failure" shown when reviewing a miss.
|
||||||
|
func (s *Store) NotesCreatedAfter(t time.Time) ([]Note, error) {
|
||||||
|
return s.queryNotes(`SELECT id, text, created_at FROM ai_notes
|
||||||
|
WHERE deleted_at IS NULL AND created_at > ? ORDER BY created_at, id`,
|
||||||
|
t.UTC().Format(rfc3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) queryNotes(q string, args ...any) ([]Note, error) {
|
||||||
|
rows, err := s.db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query notes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Note
|
||||||
|
for rows.Next() {
|
||||||
|
var n Note
|
||||||
|
var created string
|
||||||
|
if err := rows.Scan(&n.ID, &n.Text, &created); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan note: %w", err)
|
||||||
|
}
|
||||||
|
n.CreatedAt, _ = time.Parse(rfc3339, created)
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNote inserts a new active note and returns its id.
|
||||||
|
func (s *Store) AddNote(text string) (int64, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return 0, fmt.Errorf("note cannot be empty")
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec(`INSERT INTO ai_notes(text, created_at) VALUES (?, ?)`,
|
||||||
|
text, time.Now().UTC().Format(rfc3339))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("add note: %w", err)
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNote soft-deletes a note (preserving history).
|
||||||
|
func (s *Store) DeleteNote(id int64) error {
|
||||||
|
_, err := s.db.Exec(`UPDATE ai_notes SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
time.Now().UTC().Format(rfc3339), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete note: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditNote changes a note's text by soft-deleting the old row and inserting a new
|
||||||
|
// one, so the temporal history (what was active when) is preserved. Returns the new id.
|
||||||
|
func (s *Store) EditNote(id int64, text string) (int64, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return 0, fmt.Errorf("note cannot be empty")
|
||||||
|
}
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("begin edit note: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(rfc3339)
|
||||||
|
if _, err := tx.Exec(`UPDATE ai_notes SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`, now, id); err != nil {
|
||||||
|
return 0, fmt.Errorf("retire old note: %w", err)
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(`INSERT INTO ai_notes(text, created_at) VALUES (?, ?)`, text, now)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert edited note: %w", err)
|
||||||
|
}
|
||||||
|
newID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return newID, tx.Commit()
|
||||||
|
}
|
||||||
106
internal/storage/ai_notes_test.go
Normal file
106
internal/storage/ai_notes_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func notesTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
s, err := Open(filepath.Join(t.TempDir(), "notes.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotes_SeededOnFirstOpen(t *testing.T) {
|
||||||
|
s := notesTestStore(t)
|
||||||
|
notes, err := s.ListActiveNotes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(notes) != len(defaultNotes) {
|
||||||
|
t.Fatalf("seeded %d notes, want %d", len(notes), len(defaultNotes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotes_DeletedDefaultDoesNotResurrect(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "n.db")
|
||||||
|
|
||||||
|
s, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all, _ := s.ListActiveNotes()
|
||||||
|
if err := s.DeleteNote(all[0].ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
|
||||||
|
// Reopen: seeding must NOT run again (table is non-empty thanks to history).
|
||||||
|
s2, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s2.Close()
|
||||||
|
notes, _ := s2.ListActiveNotes()
|
||||||
|
if len(notes) != len(defaultNotes)-1 {
|
||||||
|
t.Errorf("after delete+reopen got %d active notes, want %d", len(notes), len(defaultNotes)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotes_EditKeepsHistory(t *testing.T) {
|
||||||
|
s := notesTestStore(t)
|
||||||
|
id, err := s.AddNote("original")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newID, err := s.EditNote(id, "revised")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if newID == id {
|
||||||
|
t.Error("edit should create a new row id")
|
||||||
|
}
|
||||||
|
active, _ := s.ListActiveNotes()
|
||||||
|
var texts []string
|
||||||
|
for _, n := range active {
|
||||||
|
texts = append(texts, n.Text)
|
||||||
|
}
|
||||||
|
if contains(texts, "original") {
|
||||||
|
t.Error("old text should no longer be active")
|
||||||
|
}
|
||||||
|
if !contains(texts, "revised") {
|
||||||
|
t.Error("revised text should be active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotes_CreatedAfter(t *testing.T) {
|
||||||
|
s := notesTestStore(t)
|
||||||
|
cutoff := time.Now().UTC()
|
||||||
|
// created_at has second resolution (RFC3339); make sure the new note sorts after.
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
if _, err := s.AddNote("late note"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := s.NotesCreatedAfter(cutoff)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(after) != 1 || after[0].Text != "late note" {
|
||||||
|
t.Fatalf("NotesCreatedAfter = %v, want just the late note", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(ss []string, want string) bool {
|
||||||
|
for _, s := range ss {
|
||||||
|
if s == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
123
internal/storage/classifications.go
Normal file
123
internal/storage/classifications.go
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClassificationRow is a stored AI classification of a receipt, joined with the
|
||||||
|
// receipt's final field values so the caller can derive which fields the user
|
||||||
|
// overrode (a "miss"). The response blob is opaque diagnostic data here — the web
|
||||||
|
// layer parses it; storage never interprets it.
|
||||||
|
type ClassificationRow struct {
|
||||||
|
ReceiptID string
|
||||||
|
Model string
|
||||||
|
ResponseJSON string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Reviewed bool
|
||||||
|
ReviewedAt *time.Time
|
||||||
|
Resolution string
|
||||||
|
|
||||||
|
// Final receipt values, for deriving overrides and display.
|
||||||
|
FinalAmountCents int64
|
||||||
|
FinalReceiptDate time.Time
|
||||||
|
FinalCategoryID int64
|
||||||
|
FinalPersonID *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertClassification records the AI suggestion (its response blob + model) for a
|
||||||
|
// receipt. One row per AI-run upload; skip-AI/disabled uploads insert nothing.
|
||||||
|
func (s *Store) InsertClassification(receiptID, model, responseJSON string, createdAt time.Time) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO classifications(receipt_id, model, response_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
receiptID, model, responseJSON, createdAt.UTC().Format(rfc3339))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert classification: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const classificationSelect = `
|
||||||
|
SELECT c.receipt_id, c.model, c.response_json, c.created_at, c.reviewed, c.reviewed_at, c.resolution,
|
||||||
|
r.amount_cents, r.receipt_date, r.category_id, r.person_id
|
||||||
|
FROM classifications c
|
||||||
|
JOIN receipts r ON r.id = c.receipt_id`
|
||||||
|
|
||||||
|
// ListUnreviewedClassifications returns not-yet-reviewed classifications of live
|
||||||
|
// receipts, newest first. Whether each is an actual "miss" is derived by the caller
|
||||||
|
// from the blob vs. the final fields.
|
||||||
|
func (s *Store) ListUnreviewedClassifications() ([]ClassificationRow, error) {
|
||||||
|
rows, err := s.db.Query(classificationSelect +
|
||||||
|
` WHERE c.reviewed = 0 AND r.deleted_at IS NULL ORDER BY c.created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list classifications: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ClassificationRow
|
||||||
|
for rows.Next() {
|
||||||
|
c, err := scanClassification(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassification returns the classification for one receipt.
|
||||||
|
func (s *Store) GetClassification(receiptID string) (ClassificationRow, error) {
|
||||||
|
return scanClassification(s.db.QueryRow(classificationSelect+` WHERE c.receipt_id = ?`, receiptID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanClassification(sc rowScanner) (ClassificationRow, error) {
|
||||||
|
var c ClassificationRow
|
||||||
|
var createdAt, receiptDate string
|
||||||
|
var reviewed int64
|
||||||
|
var reviewedAt, resolution sql.NullString
|
||||||
|
var personID sql.NullInt64
|
||||||
|
if err := sc.Scan(&c.ReceiptID, &c.Model, &c.ResponseJSON, &createdAt, &reviewed, &reviewedAt, &resolution,
|
||||||
|
&c.FinalAmountCents, &receiptDate, &c.FinalCategoryID, &personID); err != nil {
|
||||||
|
return ClassificationRow{}, fmt.Errorf("scan classification: %w", err)
|
||||||
|
}
|
||||||
|
c.CreatedAt, _ = time.Parse(rfc3339, createdAt)
|
||||||
|
c.FinalReceiptDate, _ = time.Parse(rfc3339, receiptDate)
|
||||||
|
c.Reviewed = reviewed != 0
|
||||||
|
if reviewedAt.Valid {
|
||||||
|
if t, err := time.Parse(rfc3339, reviewedAt.String); err == nil {
|
||||||
|
c.ReviewedAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resolution.Valid {
|
||||||
|
c.Resolution = resolution.String
|
||||||
|
}
|
||||||
|
if personID.Valid {
|
||||||
|
c.FinalPersonID = &personID.Int64
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkClassificationReviewed flags a classification reviewed with the given
|
||||||
|
// resolution and links the notes the user credited with fixing it.
|
||||||
|
func (s *Store) MarkClassificationReviewed(receiptID, resolution string, fixNoteIDs []int64) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin review: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE classifications SET reviewed = 1, reviewed_at = ?, resolution = ? WHERE receipt_id = ?`,
|
||||||
|
time.Now().UTC().Format(rfc3339), resolution, receiptID); err != nil {
|
||||||
|
return fmt.Errorf("mark reviewed: %w", err)
|
||||||
|
}
|
||||||
|
for _, nid := range fixNoteIDs {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT OR IGNORE INTO miss_fixes(receipt_id, note_id) VALUES (?, ?)`,
|
||||||
|
receiptID, nid); err != nil {
|
||||||
|
return fmt.Errorf("link fix note: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
69
internal/storage/classifications_test.go
Normal file
69
internal/storage/classifications_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func classifyTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
s, err := Open(filepath.Join(t.TempDir(), "c.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifications_InsertListReview(t *testing.T) {
|
||||||
|
s := classifyTestStore(t)
|
||||||
|
insertReceipt(t, s, "r1")
|
||||||
|
|
||||||
|
if err := s.InsertClassification("r1", "haiku", `{"model":"haiku"}`, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.ListUnreviewedClassifications()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].ReceiptID != "r1" || rows[0].Model != "haiku" {
|
||||||
|
t.Fatalf("ListUnreviewedClassifications = %+v", rows)
|
||||||
|
}
|
||||||
|
if rows[0].FinalAmountCents != 100 { // insertReceipt sets 100 cents
|
||||||
|
t.Errorf("joined final amount = %d, want 100", rows[0].FinalAmountCents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review it, crediting a note as the fix.
|
||||||
|
noteID, _ := s.AddNote("the fix")
|
||||||
|
if err := s.MarkClassificationReviewed("r1", "fixed", []int64{noteID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows, _ = s.ListUnreviewedClassifications()
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Errorf("reviewed row still in unreviewed queue: %+v", rows)
|
||||||
|
}
|
||||||
|
got, err := s.GetClassification("r1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.Reviewed || got.Resolution != "fixed" || got.ReviewedAt == nil {
|
||||||
|
t.Errorf("review state not persisted: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifications_DeletedReceiptDropsFromQueue(t *testing.T) {
|
||||||
|
s := classifyTestStore(t)
|
||||||
|
insertReceipt(t, s, "r1")
|
||||||
|
if err := s.InsertClassification("r1", "m", `{}`, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.SoftDelete("r1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows, _ := s.ListUnreviewedClassifications()
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Errorf("soft-deleted receipt should not appear in review queue: %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,37 @@ CREATE TABLE IF NOT EXISTS attachments (
|
||||||
deleted_at TEXT
|
deleted_at TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachments_receipt ON attachments(receipt_id, deleted_at);
|
CREATE INDEX IF NOT EXISTS idx_attachments_receipt ON attachments(receipt_id, deleted_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
label TEXT NOT NULL COLLATE NOCASE UNIQUE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS receipt_tags (
|
||||||
|
receipt_id TEXT NOT NULL REFERENCES receipts(id),
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id),
|
||||||
|
PRIMARY KEY (receipt_id, tag_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_receipt_tags_tag ON receipt_tags(tag_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS classifications (
|
||||||
|
receipt_id TEXT PRIMARY KEY REFERENCES receipts(id),
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
response_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
reviewed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reviewed_at TEXT,
|
||||||
|
resolution TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_classifications_reviewed ON classifications(reviewed);
|
||||||
|
CREATE TABLE IF NOT EXISTS miss_fixes (
|
||||||
|
receipt_id TEXT NOT NULL REFERENCES receipts(id),
|
||||||
|
note_id INTEGER NOT NULL REFERENCES ai_notes(id),
|
||||||
|
PRIMARY KEY (receipt_id, note_id)
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
// Open opens (creating if needed) the SQLite database at path, applies the schema,
|
// Open opens (creating if needed) the SQLite database at path, applies the schema,
|
||||||
|
|
@ -97,6 +128,10 @@ func Open(path string) (*Store, error) {
|
||||||
return nil, fmt.Errorf("seed categories: %w", err)
|
return nil, fmt.Errorf("seed categories: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := seedNotesIfEmpty(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &Store{db: db}, nil
|
return &Store{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
90
internal/storage/tags.go
Normal file
90
internal/storage/tags.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListTags returns all tags ordered alphabetically (case-insensitive), same shape
|
||||||
|
// as categories/people.
|
||||||
|
func (s *Store) ListTags() ([]Lookup, error) { return s.listLookup("tags") }
|
||||||
|
|
||||||
|
// SetReceiptTags links the given tag labels to a receipt, creating any tag that
|
||||||
|
// does not yet exist (matched case-insensitively via the column's NOCASE
|
||||||
|
// collation). Labels are trimmed; blanks and case-insensitive duplicates are
|
||||||
|
// collapsed. It first clears the receipt's existing links, so it applies the full
|
||||||
|
// desired set idempotently. Runs in one transaction so a receipt's tag set is
|
||||||
|
// applied atomically.
|
||||||
|
func (s *Store) SetReceiptTags(receiptID string, labels []string) error {
|
||||||
|
clean := normalizeLabels(labels)
|
||||||
|
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin set tags: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM receipt_tags WHERE receipt_id = ?`, receiptID); err != nil {
|
||||||
|
return fmt.Errorf("clear receipt tags: %w", err)
|
||||||
|
}
|
||||||
|
for _, label := range clean {
|
||||||
|
// Create-if-missing: NOCASE-unique label means a differently-cased dup is
|
||||||
|
// ignored, and the SELECT then resolves to the existing row.
|
||||||
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO tags(label) VALUES (?)`, label); err != nil {
|
||||||
|
return fmt.Errorf("upsert tag %q: %w", label, err)
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
if err := tx.QueryRow(`SELECT id FROM tags WHERE label = ?`, label).Scan(&id); err != nil {
|
||||||
|
return fmt.Errorf("find tag %q: %w", label, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT OR IGNORE INTO receipt_tags(receipt_id, tag_id) VALUES (?, ?)`,
|
||||||
|
receiptID, id); err != nil {
|
||||||
|
return fmt.Errorf("link tag %q: %w", label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReceiptTags returns a receipt's tag labels, alphabetical (case-insensitive).
|
||||||
|
func (s *Store) ListReceiptTags(receiptID string) ([]string, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT t.label FROM receipt_tags rt
|
||||||
|
JOIN tags t ON t.id = rt.tag_id
|
||||||
|
WHERE rt.receipt_id = ?
|
||||||
|
ORDER BY t.label COLLATE NOCASE`, receiptID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list receipt tags: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var l string
|
||||||
|
if err := rows.Scan(&l); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan receipt tag: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeLabels trims each label, drops blanks, and removes case-insensitive
|
||||||
|
// duplicates while preserving the first-seen casing and order.
|
||||||
|
func normalizeLabels(labels []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, l := range labels {
|
||||||
|
l = strings.TrimSpace(l)
|
||||||
|
if l == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(l)
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
107
internal/storage/tags_test.go
Normal file
107
internal/storage/tags_test.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maisym.com/hsa/internal/receipt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tagTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
s, err := Open(filepath.Join(t.TempDir(), "tags.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertReceipt(t *testing.T, s *Store, id string) {
|
||||||
|
t.Helper()
|
||||||
|
cats, err := s.ListCategories()
|
||||||
|
if err != nil || len(cats) == 0 {
|
||||||
|
t.Fatalf("categories: %v", err)
|
||||||
|
}
|
||||||
|
rec := receipt.Receipt{
|
||||||
|
ID: id, UploadedBy: "jm", UploadedAt: time.Now(), ReceiptDate: time.Now(),
|
||||||
|
AmountCents: 100, CategoryID: cats[0].ID, FilePath: "x", ImageData: []byte("x"),
|
||||||
|
FileSizeBytes: 1, OriginalFilename: "x.png", MimeType: "image/png",
|
||||||
|
}
|
||||||
|
if err := s.Insert(rec); err != nil {
|
||||||
|
t.Fatalf("insert receipt: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetReceiptTags_CreatesLinksAndDedups(t *testing.T) {
|
||||||
|
s := tagTestStore(t)
|
||||||
|
insertReceipt(t, s, "r1")
|
||||||
|
|
||||||
|
// Mixed casing + blanks + a dup that differs only by case must collapse to two.
|
||||||
|
if err := s.SetReceiptTags("r1", []string{" Dental ", "tax-2026", "DENTAL", ""}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.ListReceiptTags("r1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Alphabetical (case-insensitive); first-seen casing "Dental" is preserved.
|
||||||
|
want := []string{"Dental", "tax-2026"}
|
||||||
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("ListReceiptTags = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := s.ListTags()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tags) != 2 {
|
||||||
|
t.Errorf("catalog has %d tags, want 2 (no case-dup): %v", len(tags), tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetReceiptTags_ReusesExistingTagAcrossReceipts(t *testing.T) {
|
||||||
|
s := tagTestStore(t)
|
||||||
|
insertReceipt(t, s, "r1")
|
||||||
|
insertReceipt(t, s, "r2")
|
||||||
|
|
||||||
|
if err := s.SetReceiptTags("r1", []string{"Vision"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Same label, different casing, on another receipt: must reuse the one tag row.
|
||||||
|
if err := s.SetReceiptTags("r2", []string{"vision"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := s.ListTags()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tags) != 1 {
|
||||||
|
t.Fatalf("catalog has %d tags, want 1 shared: %v", len(tags), tags)
|
||||||
|
}
|
||||||
|
if r1, _ := s.ListReceiptTags("r1"); len(r1) != 1 {
|
||||||
|
t.Errorf("r1 tags = %v, want 1", r1)
|
||||||
|
}
|
||||||
|
if r2, _ := s.ListReceiptTags("r2"); len(r2) != 1 {
|
||||||
|
t.Errorf("r2 tags = %v, want 1", r2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetReceiptTags_ReplacesPriorSet(t *testing.T) {
|
||||||
|
s := tagTestStore(t)
|
||||||
|
insertReceipt(t, s, "r1")
|
||||||
|
|
||||||
|
if err := s.SetReceiptTags("r1", []string{"a", "b"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.SetReceiptTags("r1", []string{"b", "c"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := s.ListReceiptTags("r1")
|
||||||
|
if len(got) != 2 || got[0] != "b" || got[1] != "c" {
|
||||||
|
t.Errorf("after replace, tags = %v, want [b c]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
273
internal/web/ai.go
Normal file
273
internal/web/ai.go
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maisym.com/hsa/internal/receipt"
|
||||||
|
"maisym.com/hsa/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- AI tab: classifier correction notes + failure review ---
|
||||||
|
|
||||||
|
type aiView struct {
|
||||||
|
Notes []storage.Note
|
||||||
|
Misses []missSummary // unreviewed failures
|
||||||
|
Prompt string // live assembled system prompt, or "" when unavailable
|
||||||
|
PromptNote string // why the prompt is unavailable (classification off)
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// missSummary is one unreviewed failure in the review queue.
|
||||||
|
type missSummary struct {
|
||||||
|
ReceiptID string
|
||||||
|
When string
|
||||||
|
Amount string
|
||||||
|
Model string
|
||||||
|
Fields string // comma-joined names of the overridden fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
notes, err := s.store.ListActiveNotes()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "list notes", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := s.store.ListUnreviewedClassifications()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "list classifications", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cats, people, err := s.lookups()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "load lookups", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var misses []missSummary
|
||||||
|
for _, c := range rows {
|
||||||
|
ovs := deriveOverrides(c, cats, people)
|
||||||
|
var names []string
|
||||||
|
for _, o := range ovs {
|
||||||
|
if o.Overridden {
|
||||||
|
names = append(names, o.Field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
continue // not a miss — the AI got every field right
|
||||||
|
}
|
||||||
|
misses = append(misses, missSummary{
|
||||||
|
ReceiptID: c.ReceiptID,
|
||||||
|
When: c.CreatedAt.Local().Format(dateLayout),
|
||||||
|
Amount: dollars(c.FinalAmountCents),
|
||||||
|
Model: c.Model,
|
||||||
|
Fields: strings.Join(names, ", "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
view := aiView{Notes: notes, Misses: misses, Error: r.URL.Query().Get("error")}
|
||||||
|
if s.classifier != nil {
|
||||||
|
var texts []string
|
||||||
|
for _, n := range notes {
|
||||||
|
texts = append(texts, n.Text)
|
||||||
|
}
|
||||||
|
view.Prompt = s.classifier.PromptPreview(time.Now(), texts)
|
||||||
|
} else {
|
||||||
|
view.PromptNote = "Classification is disabled (no API key configured), so the live prompt is unavailable."
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := aiPage.ExecuteTemplate(w, "base", view); err != nil {
|
||||||
|
s.serverError(w, "render ai", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAddNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, err := s.store.AddNote(strings.TrimSpace(r.FormValue("text"))); err != nil {
|
||||||
|
redirectAI(w, r, "Could not add the note (it may be empty).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAI(w, r, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEditNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
redirectAI(w, r, "Invalid note id.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.EditNote(id, strings.TrimSpace(r.FormValue("text"))); err != nil {
|
||||||
|
redirectAI(w, r, "Could not save the note (it may be empty).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAI(w, r, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
redirectAI(w, r, "Invalid note id.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteNote(id); err != nil {
|
||||||
|
redirectAI(w, r, "Could not delete the note.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAI(w, r, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviewView is the single-failure review page.
|
||||||
|
type reviewView struct {
|
||||||
|
ReceiptID string
|
||||||
|
When string
|
||||||
|
Model string
|
||||||
|
Fields []fieldOverride
|
||||||
|
SinceNotes []storage.Note // notes added after this failure (candidate fixes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
c, err := s.store.GetClassification(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cats, people, err := s.lookups()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "load lookups", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
since, err := s.store.NotesCreatedAfter(c.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "notes since", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := reviewPage.ExecuteTemplate(w, "base", reviewView{
|
||||||
|
ReceiptID: c.ReceiptID,
|
||||||
|
When: c.CreatedAt.Local().Format("2006-01-02 15:04"),
|
||||||
|
Model: c.Model,
|
||||||
|
Fields: deriveOverrides(c, cats, people),
|
||||||
|
SinceNotes: since,
|
||||||
|
}); err != nil {
|
||||||
|
s.serverError(w, "render review", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReviewSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var fixIDs []int64
|
||||||
|
for _, v := range r.Form["fix"] {
|
||||||
|
if nid, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
fixIDs = append(fixIDs, nid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolution := "unresolved"
|
||||||
|
if len(fixIDs) > 0 {
|
||||||
|
resolution = "fixed"
|
||||||
|
}
|
||||||
|
if err := s.store.MarkClassificationReviewed(id, resolution, fixIDs); err != nil {
|
||||||
|
s.serverError(w, "mark reviewed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/ai", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectAI(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||||
|
target := "/ai"
|
||||||
|
if errMsg != "" {
|
||||||
|
target += "?error=" + url.QueryEscape(errMsg)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldOverride is one of the four AI-suggested fields, with the suggestion, the
|
||||||
|
// final value, and whether the user overrode it.
|
||||||
|
type fieldOverride struct {
|
||||||
|
Field string
|
||||||
|
Suggested string
|
||||||
|
Final string
|
||||||
|
Overridden bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveOverrides compares the stored AI suggestion blob against the receipt's final
|
||||||
|
// values to determine which of the four fields the user changed. A field the AI left
|
||||||
|
// null/empty that the user then filled counts as an override (the AI missed it).
|
||||||
|
func deriveOverrides(c storage.ClassificationRow, cats, people []storage.Lookup) []fieldOverride {
|
||||||
|
var cr classifyResponse
|
||||||
|
_ = json.Unmarshal([]byte(c.ResponseJSON), &cr)
|
||||||
|
|
||||||
|
finalAmount := dollars(c.FinalAmountCents)
|
||||||
|
finalDate := c.FinalReceiptDate.Format(dateLayout)
|
||||||
|
finalCat := labelFor(c.FinalCategoryID, cats)
|
||||||
|
finalWho := whoLabel(c.FinalPersonID, people)
|
||||||
|
|
||||||
|
return []fieldOverride{
|
||||||
|
{Field: "amount", Suggested: strPtr(cr.Amount), Final: finalAmount,
|
||||||
|
Overridden: amountOverridden(cr.Amount, c.FinalAmountCents)},
|
||||||
|
{Field: "date", Suggested: strPtr(cr.Date), Final: finalDate,
|
||||||
|
Overridden: dateOverridden(cr.Date, c.FinalReceiptDate)},
|
||||||
|
{Field: "category", Suggested: lookupOr(cr.CategoryID, cats, cr.Category), Final: finalCat,
|
||||||
|
Overridden: int64PtrOverridden(cr.CategoryID, &c.FinalCategoryID)},
|
||||||
|
{Field: "who", Suggested: lookupOr(cr.PersonID, people, cr.Person), Final: finalWho,
|
||||||
|
Overridden: int64PtrOverridden(cr.PersonID, c.FinalPersonID)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func amountOverridden(suggested *string, finalCents int64) bool {
|
||||||
|
if suggested == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cents, err := receipt.ParseAmountCents(*suggested)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return cents != finalCents
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateOverridden(suggested *string, final time.Time) bool {
|
||||||
|
if suggested == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
d, err := time.Parse(dateLayout, strings.TrimSpace(*suggested))
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return d.Year() != final.Year() || d.Month() != final.Month() || d.Day() != final.Day()
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64PtrOverridden(suggested, final *int64) bool {
|
||||||
|
if suggested == nil || final == nil {
|
||||||
|
return suggested != final // both nil → not overridden; one nil → overridden
|
||||||
|
}
|
||||||
|
return *suggested != *final
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(p *string) string {
|
||||||
|
if p == nil || strings.TrimSpace(*p) == "" {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupOr renders a suggested lookup id as its label, falling back to a label the
|
||||||
|
// blob already carried, then to "—".
|
||||||
|
func lookupOr(id *int64, set []storage.Lookup, fallback string) string {
|
||||||
|
if id != nil {
|
||||||
|
if l := labelFor(*id, set); l != "" {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(fallback) != "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
144
internal/web/ai_test.go
Normal file
144
internal/web/ai_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maisym.com/hsa/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uploadWithClassifyBlob posts a receipt plus a round-tripped AI suggestion blob.
|
||||||
|
func uploadWithClassifyBlob(t *testing.T, s *Server, amount, classifyJSON string) {
|
||||||
|
t.Helper()
|
||||||
|
body, ct := multipartUpload(t, map[string]string{
|
||||||
|
"amount": amount,
|
||||||
|
"receipt_date": "2026-06-01",
|
||||||
|
"category_id": aCategoryID(t, s),
|
||||||
|
"classify_json": classifyJSON,
|
||||||
|
}, "receipt", "r.png", fakePNG())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/upload", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
req.AddCookie(authCookie(t, s))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("upload status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAI_MissRecordedAndReviewable(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
|
||||||
|
// AI suggested $99.99; user submitted $12.34 → an amount miss.
|
||||||
|
blob := `{"amount":"99.99","date":"2026-06-01","category_id":null,"person_id":null,"model":"haiku-test"}`
|
||||||
|
uploadWithClassifyBlob(t, s, "12.34", blob)
|
||||||
|
|
||||||
|
// Stored classification exists.
|
||||||
|
rows, err := s.store.ListUnreviewedClassifications()
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("ListUnreviewedClassifications: err=%v len=%d", err, len(rows))
|
||||||
|
}
|
||||||
|
id := rows[0].ReceiptID
|
||||||
|
|
||||||
|
// /ai lists it as a miss touching "amount".
|
||||||
|
page := get(t, s, "/ai").Body.String()
|
||||||
|
if !strings.Contains(page, "haiku-test") || !strings.Contains(page, "amount") {
|
||||||
|
t.Errorf("/ai missing the amount miss: %s", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review page shows the AI guess vs the entered value.
|
||||||
|
rv := get(t, s, "/ai/review/"+id).Body.String()
|
||||||
|
if !strings.Contains(rv, "99.99") || !strings.Contains(rv, "12.34") {
|
||||||
|
t.Errorf("review page missing guess/final: %s", rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submitting the review (no fix ticked) closes it as unresolved and clears the queue.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ai/review/"+id, strings.NewReader(url.Values{}.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(authCookie(t, s))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("review submit status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
left, _ := s.store.ListUnreviewedClassifications()
|
||||||
|
if len(left) != 0 {
|
||||||
|
t.Errorf("queue not cleared after review: %d left", len(left))
|
||||||
|
}
|
||||||
|
if c, _ := s.store.GetClassification(id); c.Resolution != "unresolved" {
|
||||||
|
t.Errorf("resolution = %q, want unresolved", c.Resolution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAI_NoMissWhenSuggestionMatches(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
cat := aCategoryID(t, s)
|
||||||
|
// AI nailed every field the user submitted → not a miss.
|
||||||
|
blob := `{"amount":"12.34","date":"2026-06-01","category_id":` + cat + `,"person_id":null,"model":"m"}`
|
||||||
|
uploadWithClassifyBlob(t, s, "12.34", blob)
|
||||||
|
|
||||||
|
page := get(t, s, "/ai").Body.String()
|
||||||
|
if !strings.Contains(page, "No misreads to review") {
|
||||||
|
t.Errorf("expected no-misreads message, got: %s", page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth_StaleSessionRedirects(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
// A session issued well beyond the TTL must be rejected server-side even though
|
||||||
|
// the cookie value itself is a valid (untampered) sealed token.
|
||||||
|
old := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"},
|
||||||
|
IssuedAt: time.Now().Add(-13 * time.Hour)}
|
||||||
|
v, err := auth.EncodeSession(old, s.cfg.SessionKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: v})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
|
||||||
|
t.Errorf("stale session: status=%d location=%q, want 302 -> /login", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAI_NotesCRUD(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
|
||||||
|
post := func(path string, form url.Values) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(authCookie(t, s))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("%s status=%d", path, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/ai/notes", url.Values{"text": {"European dates are DD/MM"}})
|
||||||
|
notes, _ := s.store.ListActiveNotes()
|
||||||
|
var added int64
|
||||||
|
found := false
|
||||||
|
for _, n := range notes {
|
||||||
|
if n.Text == "European dates are DD/MM" {
|
||||||
|
added, found = n.ID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("note not added")
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/ai/notes/delete", url.Values{"id": {strconv.FormatInt(added, 10)}})
|
||||||
|
notes, _ = s.store.ListActiveNotes()
|
||||||
|
for _, n := range notes {
|
||||||
|
if n.ID == added {
|
||||||
|
t.Error("note not deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,13 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Server-side expiry: a cookie's MaxAge is client-enforced, so also reject a
|
||||||
|
// session older than its TTL — a captured cookie value can't be replayed forever.
|
||||||
|
if time.Since(sess.IssuedAt) > sessionTTL {
|
||||||
|
s.clearCookie(w, sessionCookie)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx := context.WithValue(r.Context(), sessionKey, sess)
|
ctx := context.WithValue(r.Context(), sessionKey, sess)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
113
internal/web/orientation.go
Normal file
113
internal/web/orientation.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizeOrientation returns image bytes that are physically upright.
|
||||||
|
//
|
||||||
|
// Phone cameras store a photo in the sensor's native orientation plus an EXIF
|
||||||
|
// "Orientation" tag telling viewers to rotate it. We bake that rotation into the
|
||||||
|
// pixels (and drop the tag) so the stored receipt is upright in every consumer —
|
||||||
|
// the browser, downloads, the AI classifier, a future PDF export — not just ones
|
||||||
|
// that happen to honor EXIF.
|
||||||
|
//
|
||||||
|
// It only acts when the orientation is actually KNOWN: a JPEG carrying an EXIF
|
||||||
|
// Orientation tag of 2..8. With no tag (the common case — there's no way to know
|
||||||
|
// which way is up) or an already-upright tag of 1, the original bytes are returned
|
||||||
|
// verbatim, so we neither guess nor needlessly recompress. PDFs and non-JPEG
|
||||||
|
// images pass through untouched, as does anything that fails to decode.
|
||||||
|
func normalizeOrientation(data []byte, mime string) []byte {
|
||||||
|
if mime != "image/jpeg" {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
switch exifOrientation(data) {
|
||||||
|
case 0, 1:
|
||||||
|
return data // no metadata to act on, or already upright
|
||||||
|
}
|
||||||
|
// imaging.AutoOrientation reads the EXIF tag and rotates/flips to upright.
|
||||||
|
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
||||||
|
if err != nil {
|
||||||
|
return data // undecodable — keep the original rather than lose it
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(90)); err != nil {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// exifOrientation returns the EXIF Orientation tag (1..8) of a JPEG, or 0 when the
|
||||||
|
// image has no such tag. It is a minimal, allocation-free scan: it walks the JPEG
|
||||||
|
// segment markers to the APP1 (Exif) block and reads tag 0x0112 from IFD0.
|
||||||
|
func exifOrientation(data []byte) int {
|
||||||
|
if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 { // SOI
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
i := 2
|
||||||
|
for i+4 <= len(data) {
|
||||||
|
if data[i] != 0xFF {
|
||||||
|
return 0 // not at a marker boundary — give up
|
||||||
|
}
|
||||||
|
marker := data[i+1]
|
||||||
|
if marker == 0xDA || marker == 0xD9 { // SOS (image data begins) or EOI
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
segLen := int(data[i+2])<<8 | int(data[i+3])
|
||||||
|
if segLen < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
segStart, segEnd := i+4, i+2+segLen
|
||||||
|
if segEnd > len(data) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if marker == 0xE1 { // APP1
|
||||||
|
if o, ok := orientationFromAPP1(data[segStart:segEnd]); ok {
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = segEnd
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// orientationFromAPP1 reads the Orientation tag out of an APP1 segment's payload
|
||||||
|
// (the "Exif\0\0" header followed by a TIFF block). Returns (0, false) for any
|
||||||
|
// malformed or non-Exif segment.
|
||||||
|
func orientationFromAPP1(seg []byte) (int, bool) {
|
||||||
|
const hdr = "Exif\x00\x00"
|
||||||
|
if len(seg) < len(hdr)+8 || string(seg[:len(hdr)]) != hdr {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
tiff := seg[len(hdr):]
|
||||||
|
var bo binary.ByteOrder
|
||||||
|
switch {
|
||||||
|
case tiff[0] == 'I' && tiff[1] == 'I':
|
||||||
|
bo = binary.LittleEndian
|
||||||
|
case tiff[0] == 'M' && tiff[1] == 'M':
|
||||||
|
bo = binary.BigEndian
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
ifdOff := int(bo.Uint32(tiff[4:8]))
|
||||||
|
if ifdOff < 8 || ifdOff+2 > len(tiff) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
count := int(bo.Uint16(tiff[ifdOff : ifdOff+2]))
|
||||||
|
for k, entry := 0, ifdOff+2; k < count; k, entry = k+1, entry+12 {
|
||||||
|
if entry+12 > len(tiff) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if bo.Uint16(tiff[entry:entry+2]) == 0x0112 { // Orientation
|
||||||
|
val := int(bo.Uint16(tiff[entry+8 : entry+10]))
|
||||||
|
if val >= 1 && val <= 8 {
|
||||||
|
return val, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
115
internal/web/orientation_test.go
Normal file
115
internal/web/orientation_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jpegBytes encodes a w×h test image as JPEG (no EXIF).
|
||||||
|
func jpegBytes(t *testing.T, w, h int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
img.Set(x, y, color.RGBA{uint8(x * 30), uint8(y * 30), 100, 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||||
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// withOrientation splices a minimal big-endian EXIF APP1 segment carrying the
|
||||||
|
// given Orientation value into a JPEG (right after the SOI marker).
|
||||||
|
func withOrientation(t *testing.T, jpg []byte, orientation uint16) []byte {
|
||||||
|
t.Helper()
|
||||||
|
tiff := new(bytes.Buffer)
|
||||||
|
tiff.WriteString("MM") // big-endian
|
||||||
|
binary.Write(tiff, binary.BigEndian, uint16(0x002A)) // magic
|
||||||
|
binary.Write(tiff, binary.BigEndian, uint32(8)) // IFD0 at offset 8
|
||||||
|
binary.Write(tiff, binary.BigEndian, uint16(1)) // one entry
|
||||||
|
binary.Write(tiff, binary.BigEndian, uint16(0x0112)) // tag: Orientation
|
||||||
|
binary.Write(tiff, binary.BigEndian, uint16(3)) // type: SHORT
|
||||||
|
binary.Write(tiff, binary.BigEndian, uint32(1)) // count
|
||||||
|
binary.Write(tiff, binary.BigEndian, orientation) // value (in high 2 bytes)
|
||||||
|
binary.Write(tiff, binary.BigEndian, uint16(0)) // value padding
|
||||||
|
binary.Write(tiff, binary.BigEndian, uint32(0)) // next IFD: none
|
||||||
|
|
||||||
|
payload := append([]byte("Exif\x00\x00"), tiff.Bytes()...)
|
||||||
|
app1 := new(bytes.Buffer)
|
||||||
|
app1.Write([]byte{0xFF, 0xE1})
|
||||||
|
binary.Write(app1, binary.BigEndian, uint16(len(payload)+2)) // segment length
|
||||||
|
app1.Write(payload)
|
||||||
|
|
||||||
|
out := make([]byte, 0, len(jpg)+app1.Len())
|
||||||
|
out = append(out, jpg[:2]...) // SOI
|
||||||
|
out = append(out, app1.Bytes()...)
|
||||||
|
out = append(out, jpg[2:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExifOrientation(t *testing.T) {
|
||||||
|
plain := jpegBytes(t, 4, 2)
|
||||||
|
if got := exifOrientation(plain); got != 0 {
|
||||||
|
t.Errorf("plain JPEG: orientation = %d, want 0 (no tag)", got)
|
||||||
|
}
|
||||||
|
for _, o := range []uint16{1, 3, 6, 8} {
|
||||||
|
tagged := withOrientation(t, plain, o)
|
||||||
|
if got := exifOrientation(tagged); got != int(o) {
|
||||||
|
t.Errorf("tagged orientation %d: got %d", o, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := exifOrientation([]byte("not a jpeg")); got != 0 {
|
||||||
|
t.Errorf("non-JPEG: orientation = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeOrientationPassThrough(t *testing.T) {
|
||||||
|
plain := jpegBytes(t, 4, 2)
|
||||||
|
|
||||||
|
// Non-JPEG mime → untouched.
|
||||||
|
png := []byte("fake png bytes")
|
||||||
|
if got := normalizeOrientation(png, "image/png"); !bytes.Equal(got, png) {
|
||||||
|
t.Error("png should pass through unchanged")
|
||||||
|
}
|
||||||
|
// JPEG without EXIF → untouched (can't know, don't guess or recompress).
|
||||||
|
if got := normalizeOrientation(plain, "image/jpeg"); !bytes.Equal(got, plain) {
|
||||||
|
t.Error("EXIF-less JPEG should pass through byte-for-byte")
|
||||||
|
}
|
||||||
|
// JPEG already upright (orientation 1) → untouched.
|
||||||
|
up := withOrientation(t, plain, 1)
|
||||||
|
if got := normalizeOrientation(up, "image/jpeg"); !bytes.Equal(got, up) {
|
||||||
|
t.Error("orientation-1 JPEG should pass through byte-for-byte")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeOrientationRotates(t *testing.T) {
|
||||||
|
// Stored 4×2 (wide) image tagged orientation 6 ("rotate 90° CW to view") must
|
||||||
|
// come out upright — i.e. tall (2×4) — with the EXIF tag stripped.
|
||||||
|
wide := jpegBytes(t, 4, 2)
|
||||||
|
tagged := withOrientation(t, wide, 6)
|
||||||
|
|
||||||
|
out := normalizeOrientation(tagged, "image/jpeg")
|
||||||
|
if bytes.Equal(out, tagged) {
|
||||||
|
t.Fatal("orientation-6 JPEG should have been re-encoded upright")
|
||||||
|
}
|
||||||
|
if got := exifOrientation(out); got != 0 {
|
||||||
|
t.Errorf("output still carries orientation tag %d; want stripped (0)", got)
|
||||||
|
}
|
||||||
|
img, err := imaging.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode output: %v", err)
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
if b.Dx() != 2 || b.Dy() != 4 {
|
||||||
|
t.Errorf("output dims = %dx%d, want 2x4 (rotated upright)", b.Dx(), b.Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,8 +62,17 @@ func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "only images and PDFs are allowed", http.StatusUnsupportedMediaType)
|
http.Error(w, "only images and PDFs are allowed", http.StatusUnsupportedMediaType)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
data = normalizeOrientation(data, mimeType) // upright image reads better for the AI
|
||||||
|
|
||||||
sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType)
|
// Append the user's correction notes to the prompt (best-effort).
|
||||||
|
var notes []string
|
||||||
|
if ns, err := s.store.ListActiveNotes(); err == nil {
|
||||||
|
for _, n := range ns {
|
||||||
|
notes = append(notes, n.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType, notes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.serverError(w, "classify receipt", err)
|
s.serverError(w, "classify receipt", err)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,65 @@ a.button {
|
||||||
a.button.green { background: #137333; }
|
a.button.green { background: #137333; }
|
||||||
a.button.blue { background: #1a73e8; }
|
a.button.blue { background: #1a73e8; }
|
||||||
|
|
||||||
|
/* Tag picker: trigger button, summary, chip mosaic, and the modal it opens. */
|
||||||
|
button.tags-btn {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: .5rem .8rem;
|
||||||
|
border: 1px solid #1a73e8;
|
||||||
|
background: #fff;
|
||||||
|
color: #1a73e8;
|
||||||
|
border-radius: .4rem;
|
||||||
|
}
|
||||||
|
.tags-summary { margin-left: .5rem; font-size: .9rem; color: #555; }
|
||||||
|
#tags-hidden { display: none; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(0, 0, 0, .45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.modal[hidden] { display: none; }
|
||||||
|
.modal-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: .5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 26rem;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal-card h2 { margin-top: 0; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: .4rem; margin: .5rem 0 1rem; }
|
||||||
|
.chip {
|
||||||
|
padding: .35rem .7rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #f1f3f4;
|
||||||
|
color: #333;
|
||||||
|
font-size: .9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip.sel { background: #137333; border-color: #137333; color: #fff; }
|
||||||
|
.newtag { display: flex; gap: .4rem; margin-bottom: 1rem; }
|
||||||
|
.newtag input { flex: 1; }
|
||||||
|
|
||||||
|
/* Tag chips shown read-only in the recent list. */
|
||||||
|
ul.recent .tags { margin-left: .15rem; }
|
||||||
|
ul.recent .tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: .72rem;
|
||||||
|
background: #e8f0fe;
|
||||||
|
color: #1a56c4;
|
||||||
|
border-radius: .8rem;
|
||||||
|
padding: .02rem .4rem;
|
||||||
|
margin-left: .2rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Inline rename/add rows on the manage page. */
|
/* Inline rename/add rows on the manage page. */
|
||||||
form.row {
|
form.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -143,3 +202,32 @@ form.row {
|
||||||
form.row input { flex: 1; padding: .4rem; }
|
form.row input { flex: 1; padding: .4rem; }
|
||||||
form.row button { padding: .4rem .7rem; }
|
form.row button { padding: .4rem .7rem; }
|
||||||
.add input { border: 1px solid #137333; }
|
.add input { border: 1px solid #137333; }
|
||||||
|
button.danger { background: #fce8e6; border: 1px solid #d93025; color: #a50e0e; }
|
||||||
|
|
||||||
|
/* AI correction notes: full-width, wrapping, auto-growing editors. */
|
||||||
|
form.note { margin: .75rem 0 1rem; }
|
||||||
|
form.note textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font: inherit;
|
||||||
|
padding: .5rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 3rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
form.note.add textarea { border: 1px solid #137333; }
|
||||||
|
form.note .note-actions { display: flex; gap: .4rem; margin-top: .35rem; }
|
||||||
|
form.note .note-actions button { padding: .4rem .9rem; }
|
||||||
|
|
||||||
|
/* AI tab: read-only prompt + miss-review table. */
|
||||||
|
pre.prompt {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: #f1f3f4;
|
||||||
|
padding: .6rem;
|
||||||
|
border-radius: .4rem;
|
||||||
|
font-size: .78rem;
|
||||||
|
max-height: 28rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
table.tally tr.miss th, table.tally tr.miss td { background: #fef7e0; }
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,6 @@ var (
|
||||||
exportPage = parsePage("export.html")
|
exportPage = parsePage("export.html")
|
||||||
tallyPage = parsePage("tally.html")
|
tallyPage = parsePage("tally.html")
|
||||||
recentPage = parsePage("recent.html")
|
recentPage = parsePage("recent.html")
|
||||||
|
aiPage = parsePage("ai.html")
|
||||||
|
reviewPage = parsePage("ai_review.html")
|
||||||
)
|
)
|
||||||
|
|
|
||||||
60
internal/web/templates/ai.html
Normal file
60
internal/web/templates/ai.html
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{{define "title"}}AI{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<header>
|
||||||
|
<span><a href="/">← Add receipt</a></span>
|
||||||
|
<span><a href="/recent">Recent</a> · <a href="/tally">Tally</a> · <a href="/manage">Manage</a></span>
|
||||||
|
</header>
|
||||||
|
<h1>AI classifier</h1>
|
||||||
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
|
||||||
|
<h2>Correction notes</h2>
|
||||||
|
<p><small>Free-text rules appended to the classifier prompt to fix recurring misreads. Editing a note keeps the old version in history.</small></p>
|
||||||
|
{{range .Notes}}
|
||||||
|
<form class="note" method="post" action="/ai/notes/edit">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<textarea name="text" rows="2">{{.Text}}</textarea>
|
||||||
|
<div class="note-actions">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="submit" formaction="/ai/notes/delete" class="danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form class="note add" method="post" action="/ai/notes">
|
||||||
|
<textarea name="text" rows="2" placeholder="New correction note…"></textarea>
|
||||||
|
<div class="note-actions"><button type="submit">Add note</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Grow each note's textarea to fit its full text, so long notes are readable.
|
||||||
|
document.querySelectorAll("form.note textarea").forEach(function (ta) {
|
||||||
|
function fit() { ta.style.height = "auto"; ta.style.height = ta.scrollHeight + "px"; }
|
||||||
|
ta.addEventListener("input", fit);
|
||||||
|
fit();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2>Review misreads {{if .Misses}}({{len .Misses}}){{end}}</h2>
|
||||||
|
{{if not .Misses}}
|
||||||
|
<p><small>No misreads to review — the AI matched what you submitted (or nothing has been classified yet).</small></p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="recent">
|
||||||
|
{{range .Misses}}
|
||||||
|
<li>
|
||||||
|
<a class="amt" href="/ai/review/{{.ReceiptID}}">${{.Amount}}</a>
|
||||||
|
<span class="meta">{{.When}} · changed: {{.Fields}}</span>
|
||||||
|
<span class="when">{{.Model}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Current prompt</h2>
|
||||||
|
{{if .Prompt}}
|
||||||
|
<details>
|
||||||
|
<summary>View the exact system prompt sent to the model</summary>
|
||||||
|
<pre class="prompt">{{.Prompt}}</pre>
|
||||||
|
</details>
|
||||||
|
{{else}}
|
||||||
|
<p><small>{{.PromptNote}}</small></p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
38
internal/web/templates/ai_review.html
Normal file
38
internal/web/templates/ai_review.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{{define "title"}}Review misread{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<header>
|
||||||
|
<span><a href="/ai">← AI</a></span>
|
||||||
|
<span>classified by {{.Model}} · {{.When}}</span>
|
||||||
|
</header>
|
||||||
|
<h1>Review misread</h1>
|
||||||
|
|
||||||
|
<p><a href="/receipt/{{.ReceiptID}}/file" target="_blank">Open the receipt image ↗</a></p>
|
||||||
|
|
||||||
|
<table class="tally">
|
||||||
|
<thead><tr><th>Field</th><th>AI guessed</th><th>You entered</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Fields}}
|
||||||
|
<tr{{if .Overridden}} class="miss"{{end}}>
|
||||||
|
<th>{{.Field}}</th>
|
||||||
|
<td>{{.Suggested}}</td>
|
||||||
|
<td>{{.Final}}{{if .Overridden}} ✎{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p><small>Rows marked ✎ are where you overrode the AI.</small></p>
|
||||||
|
|
||||||
|
<form method="post" action="/ai/review/{{.ReceiptID}}">
|
||||||
|
<h2>What fixed it?</h2>
|
||||||
|
{{if .SinceNotes}}
|
||||||
|
<p><small>Notes added since this receipt was classified — tick any that address this misread:</small></p>
|
||||||
|
{{range .SinceNotes}}
|
||||||
|
<label class="check"><input type="checkbox" name="fix" value="{{.ID}}"> {{.Text}}</label>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p><small>No notes were added after this receipt was classified. Add a correction note on the <a href="/ai">AI page</a> first if one would help, then come back — or close this as unresolved.</small></p>
|
||||||
|
{{end}}
|
||||||
|
<button type="submit" class="primary">Done reviewing</button>
|
||||||
|
<p><small>Ticking one or more notes marks this as fixed; ticking none closes it as unresolved.</small></p>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<p class="ok">✓ Receipt saved</p>
|
<p class="ok">✓ Receipt saved</p>
|
||||||
<p>{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}<br>{{.Filename}}</p>
|
<p>{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}<br>{{.Filename}}</p>
|
||||||
{{if .Attachments}}<p><small>+ {{len .Attachments}} attachment(s): {{range $i, $f := .Attachments}}{{if $i}}, {{end}}{{$f}}{{end}}</small></p>{{end}}
|
{{if .Attachments}}<p><small>+ {{len .Attachments}} attachment(s): {{range $i, $f := .Attachments}}{{if $i}}, {{end}}{{$f}}{{end}}</small></p>{{end}}
|
||||||
|
{{if .Tags}}<p><small>Tags: {{range $i, $t := .Tags}}{{if $i}}, {{end}}{{$t}}{{end}}</small></p>{{end}}
|
||||||
<a class="button green" href="/">Add another</a>
|
<a class="button green" href="/">Add another</a>
|
||||||
<p style="text-align:center;margin-top:1rem"><a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></p>
|
<p style="text-align:center;margin-top:1rem"><a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header>
|
<header>
|
||||||
<span><a href="/">← Add receipt</a></span>
|
<span><a href="/">← Add receipt</a></span>
|
||||||
<span><a href="/tally">Tally</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a></span>
|
<span><a href="/tally">Tally</a> · <a href="/ai">AI</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a></span>
|
||||||
</header>
|
</header>
|
||||||
<h1>{{.Title}}</h1>
|
<h1>{{.Title}}</h1>
|
||||||
<p class="tabs">
|
<p class="tabs">
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<a class="amt" href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a>
|
<a class="amt" href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a>
|
||||||
<span class="meta">{{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}</span>
|
<span class="meta">{{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}</span>
|
||||||
{{if .Attachments}}<span class="atts">{{range .Attachments}}<a href="/attachment/{{.ID}}/file" target="_blank" title="{{.Filename}}">📎</a>{{end}}</span>{{end}}
|
{{if .Attachments}}<span class="atts">{{range .Attachments}}<a href="/attachment/{{.ID}}/file" target="_blank" title="{{.Filename}}">📎</a>{{end}}</span>{{end}}
|
||||||
|
{{if .Tags}}<span class="tags">{{range .Tags}}<span class="tag">{{.}}</span>{{end}}</span>{{end}}
|
||||||
<span class="when">{{.When}}</span>
|
<span class="when">{{.When}}</span>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header>
|
<header>
|
||||||
<span>{{.Subject}}</span>
|
<span>{{.Subject}}</span>
|
||||||
<span><a href="/tally">Tally</a> · <a href="/recent">Recent</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></span>
|
<span><a href="/tally">Tally</a> · <a href="/recent">Recent</a> · <a href="/ai">AI</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></span>
|
||||||
</header>
|
</header>
|
||||||
<h1>Add receipt</h1>
|
<h1>Add receipt</h1>
|
||||||
{{range .Errors}}<div class="err">{{.}}</div>{{end}}
|
{{range .Errors}}<div class="err">{{.}}</div>{{end}}
|
||||||
|
|
@ -30,6 +30,27 @@
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
|
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
</select>
|
</select>
|
||||||
|
<label>Tags (optional)</label>
|
||||||
|
<button type="button" class="tags-btn" id="tags-btn">Add tags</button>
|
||||||
|
<span class="tags-summary" id="tags-summary"></span>
|
||||||
|
<div id="tags-hidden">
|
||||||
|
{{range .TagChips}}{{if .Selected}}<input type="hidden" name="tags" value="{{.Label}}">{{end}}{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="tags-modal" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<h2>Tags</h2>
|
||||||
|
<div class="chips" id="chip-list">
|
||||||
|
{{range .TagChips}}<button type="button" class="chip{{if .Selected}} sel{{end}}" data-label="{{.Label}}">{{.Label}}</button>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="newtag">
|
||||||
|
<input type="text" id="newtag-input" placeholder="Create a tag" autocomplete="off">
|
||||||
|
<button type="button" id="newtag-add">Add</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="primary" id="tags-done">Done with tags</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="attachments">Additional files (optional)</label>
|
<label for="attachments">Additional files (optional)</label>
|
||||||
<input id="attachments" type="file" name="attachments" accept="image/*,application/pdf" multiple>
|
<input id="attachments" type="file" name="attachments" accept="image/*,application/pdf" multiple>
|
||||||
<div class="dup-warn" id="dup-warn" hidden>
|
<div class="dup-warn" id="dup-warn" hidden>
|
||||||
|
|
@ -37,9 +58,76 @@
|
||||||
<ul id="dup-list"></ul>
|
<ul id="dup-list"></ul>
|
||||||
<label class="check"><input type="checkbox" id="dup-ack"> Add it anyway</label>
|
<label class="check"><input type="checkbox" id="dup-ack"> Add it anyway</label>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" name="classify_json" id="classify_json">
|
||||||
<button type="submit" class="primary" id="submit-btn">Submit</button>
|
<button type="submit" class="primary" id="submit-btn">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- Tag picker: in-page modal with a chip mosaic. Selection is mirrored into
|
||||||
|
// hidden "tags" inputs so it submits with the rest of the form. ---
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById("tags-btn");
|
||||||
|
var modal = document.getElementById("tags-modal");
|
||||||
|
var chipList = document.getElementById("chip-list");
|
||||||
|
var hidden = document.getElementById("tags-hidden");
|
||||||
|
var summary = document.getElementById("tags-summary");
|
||||||
|
var newInput = document.getElementById("newtag-input");
|
||||||
|
var newAdd = document.getElementById("newtag-add");
|
||||||
|
var done = document.getElementById("tags-done");
|
||||||
|
|
||||||
|
function chips() { return Array.prototype.slice.call(chipList.querySelectorAll(".chip")); }
|
||||||
|
function sync() {
|
||||||
|
var labels = chips().filter(function (c) { return c.classList.contains("sel"); })
|
||||||
|
.map(function (c) { return c.getAttribute("data-label"); });
|
||||||
|
hidden.innerHTML = "";
|
||||||
|
labels.forEach(function (l) {
|
||||||
|
var i = document.createElement("input");
|
||||||
|
i.type = "hidden"; i.name = "tags"; i.value = l;
|
||||||
|
hidden.appendChild(i);
|
||||||
|
});
|
||||||
|
summary.textContent = labels.length ? labels.length + " selected" : "none yet";
|
||||||
|
}
|
||||||
|
function find(label) {
|
||||||
|
var lower = label.toLowerCase();
|
||||||
|
return chips().filter(function (c) {
|
||||||
|
return c.getAttribute("data-label").toLowerCase() === lower;
|
||||||
|
})[0];
|
||||||
|
}
|
||||||
|
function addNew() {
|
||||||
|
var label = newInput.value.trim();
|
||||||
|
if (!label) return;
|
||||||
|
var existing = find(label);
|
||||||
|
if (existing) {
|
||||||
|
existing.classList.add("sel");
|
||||||
|
} else {
|
||||||
|
var c = document.createElement("button");
|
||||||
|
c.type = "button"; c.className = "chip sel";
|
||||||
|
c.setAttribute("data-label", label); c.textContent = label;
|
||||||
|
chipList.appendChild(c);
|
||||||
|
}
|
||||||
|
newInput.value = "";
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
chipList.addEventListener("click", function (e) {
|
||||||
|
var chip = e.target.closest(".chip");
|
||||||
|
if (!chip) return;
|
||||||
|
chip.classList.toggle("sel");
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
newAdd.addEventListener("click", addNew);
|
||||||
|
newInput.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); addNew(); }
|
||||||
|
});
|
||||||
|
btn.addEventListener("click", function () { modal.hidden = false; });
|
||||||
|
done.addEventListener("click", function () { modal.hidden = true; });
|
||||||
|
modal.addEventListener("click", function (e) {
|
||||||
|
if (e.target === modal) modal.hidden = true; // tap the backdrop to close
|
||||||
|
});
|
||||||
|
sync(); // reflect any pre-selected chips (e.g. carried across an error re-render)
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var form = document.getElementById("upload-form");
|
var form = document.getElementById("upload-form");
|
||||||
|
|
@ -102,11 +190,16 @@
|
||||||
|
|
||||||
skip.addEventListener("change", function () {
|
skip.addEventListener("change", function () {
|
||||||
note.style.opacity = skip.checked ? "0.5" : "";
|
note.style.opacity = skip.checked ? "0.5" : "";
|
||||||
if (skip.checked) note.textContent = "AI auto-fill skipped — enter the fields by hand.";
|
if (skip.checked) {
|
||||||
else note.innerHTML = baseNote;
|
note.textContent = "AI auto-fill skipped — enter the fields by hand.";
|
||||||
|
setValue("classify_json", ""); // no AI suggestion to record
|
||||||
|
} else {
|
||||||
|
note.innerHTML = baseNote;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fileInput.addEventListener("change", function () {
|
fileInput.addEventListener("change", function () {
|
||||||
|
setValue("classify_json", ""); // a new file invalidates any prior suggestion
|
||||||
var file = fileInput.files[0];
|
var file = fileInput.files[0];
|
||||||
if (!file || skip.checked) return;
|
if (!file || skip.checked) return;
|
||||||
note.style.opacity = "";
|
note.style.opacity = "";
|
||||||
|
|
@ -125,6 +218,7 @@
|
||||||
if (d.date) setValue("receipt_date", d.date);
|
if (d.date) setValue("receipt_date", d.date);
|
||||||
if (d.category_id != null) setValue("category_id", String(d.category_id));
|
if (d.category_id != null) setValue("category_id", String(d.category_id));
|
||||||
if (d.person_id != null) setValue("person_id", String(d.person_id));
|
if (d.person_id != null) setValue("person_id", String(d.person_id));
|
||||||
|
setValue("classify_json", JSON.stringify(d)); // round-trip for misread review
|
||||||
var cost = (d.cost_cents != null) ? " · " + d.cost_cents.toFixed(2) + "¢" : "";
|
var cost = (d.cost_cents != null) ? " · " + d.cost_cents.toFixed(2) + "¢" : "";
|
||||||
note.textContent = "Pre-filled by " + (d.model || model) + cost + " — review and submit.";
|
note.textContent = "Pre-filled by " + (d.model || model) + cost + " — review and submit.";
|
||||||
// Pre-filled date+amount may match an existing receipt — re-check.
|
// Pre-filled date+amount may match an existing receipt — re-check.
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -31,6 +34,16 @@ type formView struct {
|
||||||
// shown as a footnote. ClassifyEnabled is false when no API key is configured.
|
// shown as a footnote. ClassifyEnabled is false when no API key is configured.
|
||||||
ClassifyModel string
|
ClassifyModel string
|
||||||
ClassifyEnabled bool
|
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) {
|
func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -63,12 +76,53 @@ func (s *Server) lookups() (cats, people []storage.Lookup, err error) {
|
||||||
func (s *Server) renderForm(w http.ResponseWriter, v formView) {
|
func (s *Server) renderForm(w http.ResponseWriter, v formView) {
|
||||||
v.ClassifyModel = s.cfg.ClassifyModel
|
v.ClassifyModel = s.cfg.ClassifyModel
|
||||||
v.ClassifyEnabled = s.classifier != nil
|
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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
|
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
|
||||||
s.serverError(w, "render form", err)
|
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) {
|
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
sess := sessionFrom(r.Context())
|
sess := sessionFrom(r.Context())
|
||||||
cats, people, err := s.lookups()
|
cats, people, err := s.lookups()
|
||||||
|
|
@ -139,6 +193,8 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
mimeType = detectMime(data)
|
mimeType = detectMime(data)
|
||||||
if !allowedMime(mimeType) {
|
if !allowedMime(mimeType) {
|
||||||
errs = append(errs, "Only images and PDFs are allowed.")
|
errs = append(errs, "Only images and PDFs are allowed.")
|
||||||
|
} else {
|
||||||
|
data = normalizeOrientation(data, mimeType) // bake in EXIF rotation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,8 +204,18 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
attachments, attErrs := readAttachments(r)
|
attachments, attErrs := readAttachments(r)
|
||||||
errs = append(errs, attErrs...)
|
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 {
|
if len(errs) > 0 {
|
||||||
view.Errors = errs
|
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)
|
s.renderForm(w, view)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +248,24 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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
|
// Save each attachment now that the parent receipt id exists, keyed to the
|
||||||
// receipt's date+amount so the files sort alongside it.
|
// receipt's date+amount so the files sort alongside it.
|
||||||
var attachmentNames []string
|
var attachmentNames []string
|
||||||
|
|
@ -208,6 +292,8 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
attachmentNames = append(attachmentNames, a.filename)
|
attachmentNames = append(attachmentNames, a.filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
savedTags, _ := s.store.ListReceiptTags(id)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = confirmPage.ExecuteTemplate(w, "base", confirmView{
|
_ = confirmPage.ExecuteTemplate(w, "base", confirmView{
|
||||||
Category: labelFor(categoryID, cats),
|
Category: labelFor(categoryID, cats),
|
||||||
|
|
@ -216,6 +302,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
Date: dateStr,
|
Date: dateStr,
|
||||||
Filename: header.Filename,
|
Filename: header.Filename,
|
||||||
Attachments: attachmentNames,
|
Attachments: attachmentNames,
|
||||||
|
Tags: savedTags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,6 +314,7 @@ type confirmView struct {
|
||||||
Date string
|
Date string
|
||||||
Filename string
|
Filename string
|
||||||
Attachments []string
|
Attachments []string
|
||||||
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingAttachment is a validated extra file waiting to be saved with its receipt.
|
// pendingAttachment is a validated extra file waiting to be saved with its receipt.
|
||||||
|
|
@ -264,6 +352,7 @@ func readAttachments(r *http.Request) (out []pendingAttachment, errs []string) {
|
||||||
errs = append(errs, "Attachments must be images or PDFs.")
|
errs = append(errs, "Attachments must be images or PDFs.")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
adata = normalizeOrientation(adata, amime) // bake in EXIF rotation
|
||||||
out = append(out, pendingAttachment{
|
out = append(out, pendingAttachment{
|
||||||
data: adata,
|
data: adata,
|
||||||
mimeType: amime,
|
mimeType: amime,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"maisym.com/hsa/internal/auth"
|
"maisym.com/hsa/internal/auth"
|
||||||
"maisym.com/hsa/internal/config"
|
"maisym.com/hsa/internal/config"
|
||||||
|
|
@ -39,7 +40,7 @@ func testServerWithStore(t *testing.T) *Server {
|
||||||
|
|
||||||
func authCookie(t *testing.T, s *Server) *http.Cookie {
|
func authCookie(t *testing.T, s *Server) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}}
|
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, IssuedAt: time.Now()}
|
||||||
v, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
v, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -213,6 +214,63 @@ func TestUpload_WithAttachments(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUploadForm_RendersTagPicker(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
if err := s.store.SetReceiptTags("noop", nil); err != nil { // ensure table is queryable
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := get(t, s, "/")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{`id="tags-btn"`, `id="tags-modal"`, `Done with tags`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("upload form missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpload_WithTags(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
_ = mw.WriteField("amount", "12.34")
|
||||||
|
_ = mw.WriteField("receipt_date", "2026-06-01")
|
||||||
|
_ = mw.WriteField("category_id", aCategoryID(t, s))
|
||||||
|
_ = mw.WriteField("tags", "Dental")
|
||||||
|
_ = mw.WriteField("tags", "tax-2026")
|
||||||
|
_ = mw.WriteField("tags", "dental") // case-dup of the first — must collapse
|
||||||
|
rw, _ := mw.CreateFormFile("receipt", "receipt.png")
|
||||||
|
rw.Write(fakePNG())
|
||||||
|
mw.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/upload", &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.AddCookie(authCookie(t, s))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Tags:") {
|
||||||
|
t.Errorf("confirm page missing tags listing: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _, err := s.store.ListRecent("uploaded_at", 10, 0)
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("ListRecent: %v len=%d", err, len(rows))
|
||||||
|
}
|
||||||
|
got, err := s.store.ListReceiptTags(rows[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != "Dental" || got[1] != "tax-2026" {
|
||||||
|
t.Errorf("receipt tags = %v, want [Dental tax-2026]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpload_RejectsBadAttachment(t *testing.T) {
|
func TestUpload_RejectsBadAttachment(t *testing.T) {
|
||||||
s := testServerWithStore(t)
|
s := testServerWithStore(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ type recentRow struct {
|
||||||
When string
|
When string
|
||||||
Filename string
|
Filename string
|
||||||
Attachments []attachLink
|
Attachments []attachLink
|
||||||
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachLink is a viewable attachment reference shown under a recent row.
|
// attachLink is a viewable attachment reference shown under a recent row.
|
||||||
|
|
@ -125,6 +126,11 @@ func (s *Server) renderRecent(w http.ResponseWriter, r *http.Request, orderBy, t
|
||||||
for _, a := range atts {
|
for _, a := range atts {
|
||||||
links = append(links, attachLink{ID: a.ID, Filename: a.OriginalFilename})
|
links = append(links, attachLink{ID: a.ID, Filename: a.OriginalFilename})
|
||||||
}
|
}
|
||||||
|
tags, err := s.store.ListReceiptTags(row.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "list receipt tags", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
view.Rows = append(view.Rows, recentRow{
|
view.Rows = append(view.Rows, recentRow{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Date: row.ReceiptDate.Format(dateLayout),
|
Date: row.ReceiptDate.Format(dateLayout),
|
||||||
|
|
@ -134,6 +140,7 @@ func (s *Server) renderRecent(w http.ResponseWriter, r *http.Request, orderBy, t
|
||||||
When: row.UploadedAt.Local().Format("2006-01-02"),
|
When: row.UploadedAt.Local().Format("2006-01-02"),
|
||||||
Filename: row.OriginalFilename,
|
Filename: row.OriginalFilename,
|
||||||
Attachments: links,
|
Attachments: links,
|
||||||
|
Tags: tags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if hasMore {
|
if hasMore {
|
||||||
|
|
@ -208,6 +215,7 @@ func (s *Server) handleReceiptFile(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", rec.MimeType)
|
w.Header().Set("Content-Type", rec.MimeType)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff") // don't let the browser sniff user bytes
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename))
|
||||||
http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData))
|
http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData))
|
||||||
}
|
}
|
||||||
|
|
@ -221,6 +229,7 @@ func (s *Server) handleAttachmentFile(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", a.MimeType)
|
w.Header().Set("Content-Type", a.MimeType)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff") // don't let the browser sniff user bytes
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename))
|
||||||
http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData))
|
http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ import (
|
||||||
const (
|
const (
|
||||||
sessionCookie = "hsa_session"
|
sessionCookie = "hsa_session"
|
||||||
loginCookie = "hsa_login"
|
loginCookie = "hsa_login"
|
||||||
|
|
||||||
|
// sessionTTL bounds a session both as the cookie's MaxAge and as a server-side
|
||||||
|
// age check (see requireAuth), so a leaked cookie value can't be replayed forever.
|
||||||
|
sessionTTL = 12 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server holds the wired dependencies for the HTTP handlers.
|
// Server holds the wired dependencies for the HTTP handlers.
|
||||||
|
|
@ -75,6 +79,12 @@ func (s *Server) Routes() http.Handler {
|
||||||
mux.Handle("GET /recent/receipts", s.requireAuth(http.HandlerFunc(s.handleRecentReceipts)))
|
mux.Handle("GET /recent/receipts", s.requireAuth(http.HandlerFunc(s.handleRecentReceipts)))
|
||||||
mux.Handle("GET /receipt/{id}/file", s.requireAuth(http.HandlerFunc(s.handleReceiptFile)))
|
mux.Handle("GET /receipt/{id}/file", s.requireAuth(http.HandlerFunc(s.handleReceiptFile)))
|
||||||
mux.Handle("GET /attachment/{id}/file", s.requireAuth(http.HandlerFunc(s.handleAttachmentFile)))
|
mux.Handle("GET /attachment/{id}/file", s.requireAuth(http.HandlerFunc(s.handleAttachmentFile)))
|
||||||
|
mux.Handle("GET /ai", s.requireAuth(http.HandlerFunc(s.handleAI)))
|
||||||
|
mux.Handle("POST /ai/notes", s.requireAuth(http.HandlerFunc(s.handleAddNote)))
|
||||||
|
mux.Handle("POST /ai/notes/edit", s.requireAuth(http.HandlerFunc(s.handleEditNote)))
|
||||||
|
mux.Handle("POST /ai/notes/delete", s.requireAuth(http.HandlerFunc(s.handleDeleteNote)))
|
||||||
|
mux.Handle("GET /ai/review/{id}", s.requireAuth(http.HandlerFunc(s.handleReview)))
|
||||||
|
mux.Handle("POST /ai/review/{id}", s.requireAuth(http.HandlerFunc(s.handleReviewSubmit)))
|
||||||
mux.Handle("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage)))
|
mux.Handle("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage)))
|
||||||
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
|
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
|
||||||
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))
|
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))
|
||||||
|
|
@ -218,7 +228,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
s.serverError(w, "encode session", err)
|
s.serverError(w, "encode session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setCookie(w, sessionCookie, encoded, 12*time.Hour)
|
s.setCookie(w, sessionCookie, encoded, sessionTTL)
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"maisym.com/hsa/internal/auth"
|
"maisym.com/hsa/internal/auth"
|
||||||
"maisym.com/hsa/internal/config"
|
"maisym.com/hsa/internal/config"
|
||||||
|
|
@ -64,7 +65,7 @@ func TestHome_NoSession_RedirectsToLogin(t *testing.T) {
|
||||||
|
|
||||||
func TestHome_ValidSession_ShowsUploadForm(t *testing.T) {
|
func TestHome_ValidSession_ShowsUploadForm(t *testing.T) {
|
||||||
s := testServerWithStore(t)
|
s := testServerWithStore(t)
|
||||||
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}}
|
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, IssuedAt: time.Now()}
|
||||||
cookie, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
cookie, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue