feat(api): add multipart upload for large photos - #53
Open
MehrshadFb wants to merge 1 commit into
Open
MehrshadFb wants to merge 1 commit into
MehrshadFb wants to merge 1 commit into
Conversation
API unit-test coverage
Unit suite only; controllers are exercised by the e2e suite. |
MehrshadFb
force-pushed
the
feat/event-delete-s3-purge
branch
from
September 9, 2026 02:46
721fe28 to
e7f4296
Compare
MehrshadFb
force-pushed
the
feat/photo-multipart-upload
branch
2 times, most recently
from
September 9, 2026 02:52
bb85125 to
bf9ceef
Compare
MehrshadFb
force-pushed
the
feat/event-delete-s3-purge
branch
from
September 9, 2026 02:52
e7f4296 to
4ac0e76
Compare
MehrshadFb
force-pushed
the
feat/photo-multipart-upload
branch
from
September 9, 2026 03:01
bf9ceef to
3ac57c0
Compare
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.
Stacked on #52; the diff here is only the multipart change once that merges.
Summary
POST /events/:eventId/photos/multipart-uploads{ contentType, sizeBytes }reserves quota like a single slot, opens an S3 multipart upload, stores its id and part size on the row, and returns the layout:{ photoId, sizeBytes, partSizeBytes, expiresAt, parts: [{ partNumber, sizeBytes, uploadUrl, uploaded }] }. Parts are fixed at 5 MiB (S3's minimum), so a 25 MB file is five; eachuploadUrlis a presignedUploadPartbound to that part's exactContent-Length.GET /photos/:photoId/multipart-uploadreturns the same layout with fresh URLs anduploaded: trueon the parts S3 already holds, for resuming after a drop, an app restart, or expired URLs.POST /photos/:photoId/multipart-upload/complete(no body) lists the parts on S3, checks every planned part is there at its planned size, callsCompleteMultipartUploadwith the ETags S3 reported, then verifies the assembled object exactly like confirm and flips the row toREADY. Returns the same{ photoId, status }as confirm; a second call returnsREADYagain.DELETE /photos/:photoIdaborts an open upload before deleting; so do the stale-PENDING sweeper and every slot release (rejected confirm, expired upload, failed initiate).S3ServicegainscreateMultipartUpload,getPresignedUploadPartUrl,listMultipartParts,completeMultipartUpload(client-side S3 refusals come back as{ completed: false, code }), andabortMultipartUpload(an unknown upload counts as aborted).PhotosServiceexposesassertCanUploadToEvent,verifyUploads, andreleaseUploadSlotsso the newPhotoMultipartServicereuses the same authorization, verification, and release paths; the READY flip clears the multipart columns.20260906135921_add_photo_multipart_upload: nullablePhoto.multipartUploadId(varchar 2048) andPhoto.multipartPartSizeBytes.s3:AbortMultipartUploadands3:ListMultipartUploadPartson bucket objects.S3Servicemultipart methods (including the real signer'sX-Amz-SignedHeadersfor a part URL),PhotoMultipartService, the abort paths inPhotosServiceand the sweeper, and e2e for the three endpoints and delete.Why
A single PUT lands whole or not at all, so a 20 MB photo that drops at 95% on cellular starts over from zero. With parts uploaded, retried, and resumed independently, a drop costs one part, and an upload survives an app restart because the API, not the client, remembers what S3 already holds.
API
InitiateMultipartUploadDto,MultipartUploadResponseDto,MultipartPartResponseDtoadded to the spec. Existing endpoints and DTOs are unchanged.400naming them; S3 refuses the assembly (InvalidPart,EntityTooSmall, …) →400with the code, upload still open; S3 no longer knows the upload → slot released,410 Gone; another uploader's photo, a single-PUT slot, or a non-PENDINGrow →404; lost event access →403.sizeBytesasContent-Length(noContent-Typeneeded on parts); nothing to persist per part. Usecomplete, notconfirm, to finish a multipart upload.lib/api/generatedregenerated (four files), no hand-written mobile change.Notes
terraform applyinapi/infrabefore deploying this. Withouts3:ListMultipartUploadPartseverycompleteandGETreturns 500, and withouts3:AbortMultipartUploaddeletes of pending multipart rows fail.ListObjectsV2, so the orphan reconciler never sees them. The existing lifecycle rule aborts incomplete uploads after one day, the sweeper aborts before deleting the row, and every release path aborts first.400; they gain nothing from multipart and keep usingupload-urls.Test plan
npx jest --watchman=false(453 tests)npx jest --config ./test/jest-e2e.json --watchman=false(129 tests)npm run typechecknpm run format:checkand eslint on the changed filesnpm run openapi:generate, thenpnpm run openapi:generate,tsc --noEmit, lint, andformat:checkinmobile/terraform fmt -checkprisma migrate devagainst a scratch Postgres 18terraform apply(needs the admin profile)