Skip to content

Admin UX + HEIC batch (fixes #135 #136 #137 #139)#16

Merged
tx-joshg merged 4 commits into
mainfrom
fix/admin-ux-and-heic-batch
May 28, 2026
Merged

Admin UX + HEIC batch (fixes #135 #136 #137 #139)#16
tx-joshg merged 4 commits into
mainfrom
fix/admin-ux-and-heic-batch

Conversation

@tx-joshg

Copy link
Copy Markdown
Owner

Four independent bug fixes batched into one branch since they all touch admin-flow UX. One commit per fix for easy review; GitHub will squash on merge.

Fixes

#135 — HEIC rejection on upload

src/app/api/uploads/route.ts already allowed image/heic and image/heif MIME types, but src/lib/storage.ts uploadFile() re-validates against a stricter list that excludes them — so iPhone uploads threw File type image/heic not allowed. Browsers (especially Chrome) also can't render HEIC inline, so even if storage accepted them, admins couldn't preview the doc photos.

Fix: install pure-JS heic-convert (no native deps, Railway-safe) and convert HEIC/HEIF → JPEG in the upload route before reaching uploadFile(). Storage's allow-list stays unchanged.

#136 — Native window.confirm() in approve/reject

src/app/admin/submissions/[id]/page.tsx used window.confirm() for the destructive approve/reject prompts — jarring native browser dialog, inconsistent with the rest of the indigo/red Tailwind UI.

Fix: built an inline ConfirmDialog component (overlay + centered card, autofocus, Escape-to-dismiss, red-tinted for reject / indigo for approve). Repo had no shared Dialog primitive, so it's local to the page — easy to extract to src/components/ui later when more pages need it.

#137 — Approve silently fails when session expires

fetchWithAuth in src/contexts/AdminContext.tsx redirected to /admin/login on 401 with no user-facing message. If an admin's session expired mid-form, clicking Approve → fetch → 401 → silent redirect made it look like "nothing happened".

Fix:

  • AdminContext: show alert(\"Your session expired — please sign in again.\") before the redirect, gated by a module-level flag so concurrent 401s don't stack alerts.
  • submission/[id]/page.tsx: added a showMessage(text, tone) helper driving a red-tinted error banner. Non-OK responses now extract the server's { error } body; the catch surfaces Error.message. Same treatment applied to notes-save, resend, and PDF download (also bare-catch'd).

#139 — SSN PDF field-not-found warning

Logs showed I-9 PDF field not found: US Social Security Number on every submission. Inspected src/lib/i9-form.pdf with pdf-libthe bundled form (USCIS 01/20/25 edition) does have a field with exactly that name, and the existing default mapping in src/lib/i9-field-mapping.ts is correct against it.

Root cause must be an admin-uploaded PDF in production (via Admin → I-9 Form Management → i9FormFileKey) using an older USCIS edition where the SSN field is named SSN instead.

Fix: rather than guess a single replacement name (which would break the bundled form), made field resolution tolerant in src/lib/i9pdf.ts:

  1. Build a normalized lookup of every field in the loaded PDF (lowercase, strip punctuation, collapse whitespace).
  2. Try the mapped name first; if absent, fall back to known aliases.
  3. SSN aliases: SSN, Social Security Number, U.S. Social Security Number, USSocialSecurityNumber.

Default mapping unchanged. New PDF editions can be handled by adding to the alias table without breaking deployments still using older editions.

Note: I did NOT modify the SSN field name in the default mapping because the bundled src/lib/i9-form.pdf shipped in the repo verifiably uses US Social Security Number (confirmed via pdf-lib getFields() enumeration). Changing the default would break new deployments that haven't uploaded a custom form. If you can share the admin-uploaded PDF that's failing, I can add the exact field name as a direct entry. The fuzzy alias path should already cover it.

Verification

  • npx tsc --noEmit — clean
  • npm run build — exits 0, all 35 pages prerender (Prisma config error during prerender is pre-existing local-only issue: .env has SQLite URL but schema.prisma declares postgresql provider; harmless for the build itself)
  • HEIC conversion: validated by typechecking the heic-convert types pass + the dependency installed cleanly on macOS with legacy-peer-deps
  • PDF mapping: ran pdf-lib enumeration locally against bundled form to confirm all 42 default-mapping entries resolve

Notes for reviewer

  • HEIC conversion is CPU-bound and the wasm bundle is ~3MB — fine for occasional employee uploads, would want a queue for batch.
  • The alert() in AdminContext is intentional placeholder. Worth landing a proper toast primitive in a follow-up.
  • ConfirmDialog is inlined in the page file. Move to src/components/ui/ConfirmDialog.tsx when a second page needs it.

tx-joshg added 4 commits May 28, 2026 15:41
Storage layer's uploadFile() validates against an allow-list that omits
heic/heif, so iPhone uploads (the route accepted them) threw
"File type image/heic not allowed" downstream. Browsers — Chrome in
particular — also can't render HEIC inline, so even if storage allowed
the format, admins couldn't preview the doc photos.

Convert HEIC/HEIF → JPEG in the upload route with the pure-JS
heic-convert package (no native deps, safe for Railway containers).
The conversion happens after MIME validation but before uploadFile(),
so storage.ts's allow-list stays unchanged.
Production logs show "I-9 PDF field not found: US Social Security Number"
on every submission. The bundled src/lib/i9-form.pdf (USCIS 01/20/25)
does have that exact field, but admins can upload custom PDFs via I-9
Form Management, and older editions name the SSN field "SSN" — so the
hardcoded mapping silently fails to fill the field on those forms.

Make field resolution tolerant: when the mapped name isn't present in
the loaded PDF, normalize names (lowercase, strip punctuation, collapse
whitespace) and consult a small alias table for fields with known drift
between editions. SSN gets aliases for "SSN", "Social Security Number",
and "U.S. Social Security Number". Default mapping is unchanged (it
matches the bundled form exactly).
fetchWithAuth used to do window.location.href = "/admin/login" with no
user-facing message on a 401. If an admin's session expired mid-form
(e.g. while filling out a hire date before approving), clicking Approve
fired the fetch, got 401, and silently navigated away — from the user's
perspective "nothing happened" and the unsaved form was gone.

Show an alert("Your session expired — please sign in again.") before
the redirect, and gate it behind a module-level flag so multiple
in-flight requests that all 401 simultaneously don't stack N alerts.
Browser alert is intentional pending an in-app toast primitive.
…36, #137)

(#136) Replace window.confirm() in approve/reject flow with an in-app
ConfirmDialog component — overlay + centered card, red-tinted confirm
button for reject, indigo for approve, Escape to dismiss, autofocus on
the confirm button. Built inline since the repo has no shared Dialog
primitive yet; can be extracted to src/components/ui later.

(#137) Make status-save failures actually visible. The old
handleStatusSave() wrapped the PATCH in a bare try/catch that swallowed
errors into a generic "Failed to update status" with no detail and no
error styling. Now:
  - showMessage(text, tone) helper drives a red-tinted banner for errors
  - non-OK responses extract the server's { error } body when present
  - the catch surfaces the underlying Error.message
  - same treatment for notes save, resend, and PDF download

Combined with the AdminContext alert (#137), an expired session now
either shows the expiry alert (if 401) or a visible red banner with the
actual server error (if 4xx/5xx) — instead of a silent click.
@tx-joshg tx-joshg merged commit 00665aa into main May 28, 2026
5 checks passed
@tx-joshg tx-joshg deleted the fix/admin-ux-and-heic-batch branch May 28, 2026 20:50
tx-joshg added a commit that referenced this pull request May 29, 2026
The /api/uploads route forwarded `await file.arrayBuffer()` (a raw
ArrayBuffer) to heic-convert. heic-decode's isHeic() spreads the input
(`String.fromCharCode(...buf.slice(8,12))`), which requires an iterable —
an ArrayBuffer is not iterable, so HEIC uploads threw "Found non-callable
@@iterator" and 500'd. The HEIC support added in PR #16 never actually
worked on a real iPhone photo.

Root contributor: @types/heic-convert mistypes `buffer` as ArrayBufferLike,
which lured the route into passing the wrong shape.

- Add heicToJpeg() helper that normalizes any binary input to an iterable
  Buffer (casting past the incorrect d.ts).
- Route uses the helper.
- Regression coverage via node:test + a real ~1KB HEIC fixture: Buffer,
  ArrayBuffer, and Uint8Array inputs all yield a JPEG. Adds tsx + `npm test`.

Reproduced against a real HEIC; fix verified end-to-end (HEIC -> JPEG).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant