Skip to content

feat(frontend): upload expenditure receipts for real - #307

Draft
nourshoreibah wants to merge 1 commit into
feat/expenditure-receipt-uploadsfrom
feat/real-receipt-upload
Draft

feat(frontend): upload expenditure receipts for real#307
nourshoreibah wants to merge 1 commit into
feat/expenditure-receipt-uploadsfrom
feat/real-receipt-upload

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

ℹ️ Issue

The last of the frontend's simulated data. Stacked on #306base that branch, not main.

📝 Description

FileUpload animated a progress bar over a setInterval that counted through the file's size in 15 ticks, under a standing TODO: update to use real progress of uploaded file. Nothing was ever transferred: the File went into component state and was dropped on submit, so every expenditure was recorded with receipt_url NULL — despite the form refusing to submit without a receipt.

Submitting the form now:

  1. Requests a presigned PUT from GET /expenditures/upload-url (added in feat(expenditures): presigned S3 uploads for expenditure receipts #306) for the chosen project and the dropped filename.
  2. Sends the file straight to S3 with real byte progress.
  3. Passes the returned objectUrl to POST /expenditures as receiptUrl.

If the upload fails, no expenditure is recorded and the error is surfaced — previously a failure was impossible because there was no upload.

Why the upload moved to submit rather than staying on drop: the presigned URL is per project, and the project may not be chosen when the file lands; and a file dropped into a form the user then abandons would leave an orphaned object in the bucket. The user still sees real progress, now while saving. FileUpload becomes controlled — it reports the dropped file immediately (so FilePreview still appears at once) and renders whatever progress its parent passes down.

src/lib/upload.tsputWithProgress uses XMLHttpRequest because fetch exposes no upload progress. Two details worth a look:

  • It sends the content type the URL was signed with, not file.type. S3 checks Content-Type against the signature, so a mismatch is a 403.
  • It sends no Authorization header. The signature is in the URL, and an extra header would not be part of what was signed.

✔️ Verification

In apps/frontend, all green:

  • npm run typecheck — clean
  • npm run lint — clean
  • npx jest260 passed, 2 skipped, 25 suites
  • npm run build — static export succeeds

Coverage:

  • New test/lib/upload.test.ts (7) drives a stand-in XHR: the PUT's method/URL/content type, the absence of an auth header, progress reporting, ignoring non-computable progress events, and rejection on non-2xx (carrying the status), network error, and abort.
  • AddExpenseModal.test.tsx — asserts the upload URL is requested for the right project and filename, that receiptUrl reaches the POST body, that the PUT uses application/pdf, that the upload strictly precedes the POST, and that a failed upload records nothing and calls no onSuccess.
  • FileUpload.test.tsx — replaces the two fake-timer tests for the simulated upload with the controlled-progress behaviour: onChange fires immediately on drop, no progress bar appears on drop, real transferred bytes render as a percentage, and clearing progress returns to the preview.

🏕️ (Optional) Future Work / Notes

🤖 Generated with Claude Code

FileUpload animated a progress bar over a setInterval that counted through the
file's size in 15 ticks, with a standing TODO to use real progress. Nothing was
transferred: the File went into component state and was dropped on submit, so
every expenditure was recorded with receipt_url NULL despite the form refusing
to submit without a receipt.

Submitting the form now requests a presigned PUT from
GET /expenditures/upload-url, sends the file to S3 with real byte progress, and
passes the returned object URL to POST /expenditures as receiptUrl. If the
upload fails, no expenditure is recorded and the error is shown.

The upload runs on submit rather than on drop because the presigned URL is
per project -- the project may not be chosen when the file lands -- and because
a file dropped into an abandoned form would otherwise orphan an object in the
bucket. FileUpload is now controlled: it reports the dropped file immediately
and renders whatever progress its parent passes down.

putWithProgress uses XHR because fetch exposes no upload progress, and sends
the content type the URL was signed with rather than file.type, which S3 checks
against the signature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nourshoreibah

Copy link
Copy Markdown
Collaborator Author

⚠️ No CI will run on this PR while it is stacked. The workflows trigger on pull_request: branches: [main, develop], which matches the base branch — so a PR based on a feature branch gets no checks at all, including the frontend-ci / lambda-tests / terraform-plan-summary that branch protection requires.

GitHub retargets this to main automatically once the parent merges and its branch is deleted; CI runs from that point. Until then the verification in the description is local only (typecheck, lint, full jest suite, production build — all green).

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