feat(frontend): upload expenditure receipts for real - #307
Draft
nourshoreibah wants to merge 1 commit into
Draft
Conversation
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>
Collaborator
Author
|
GitHub retargets this to |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ℹ️ Issue
The last of the frontend's simulated data. Stacked on #306 — base that branch, not
main.📝 Description
FileUploadanimated a progress bar over asetIntervalthat counted through the file's size in 15 ticks, under a standingTODO: update to use real progress of uploaded file. Nothing was ever transferred: theFilewent into component state and was dropped on submit, so every expenditure was recorded withreceipt_urlNULL — despite the form refusing to submit without a receipt.Submitting the form now:
GET /expenditures/upload-url(added in feat(expenditures): presigned S3 uploads for expenditure receipts #306) for the chosen project and the dropped filename.objectUrltoPOST /expendituresasreceiptUrl.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.
FileUploadbecomes controlled — it reports the dropped file immediately (soFilePreviewstill appears at once) and renders whatever progress its parent passes down.src/lib/upload.ts—putWithProgressusesXMLHttpRequestbecausefetchexposes no upload progress. Two details worth a look:file.type. S3 checksContent-Typeagainst the signature, so a mismatch is a 403.Authorizationheader. 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— cleannpm run lint— cleannpx jest— 260 passed, 2 skipped, 25 suitesnpm run build— static export succeedsCoverage:
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, thatreceiptUrlreaches the POST body, that the PUT usesapplication/pdf, that the upload strictly precedes the POST, and that a failed upload records nothing and calls noonSuccess.FileUpload.test.tsx— replaces the two fake-timer tests for the simulated upload with the controlled-progress behaviour:onChangefires 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
GET /expenditures/upload-url404s.receipt_urlwill actually be populated.🤖 Generated with Claude Code