feat: pet photo upload via presigned S3 URLs (GH #93) #102
Reference in New Issue
Block a user
Delete Branch "feat/pet-photo-upload-gh93"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #93
GRO-123 / GRO-116
Summary
Implements the full pet photo upload feature for staff and customer portals using a presigned S3 URL flow (binary never passes through the API server).
Backend
packages/db/migrations/0012_pet_photo.sql— addsphoto_key TEXTandphoto_uploaded_at TIMESTAMPcolumns topetstablepackages/db/src/schema.ts— schema updated to match migrationapps/api/src/lib/s3.ts— S3 client utility wrapping@aws-sdk/client-s3+@aws-sdk/s3-request-presigner; configured viaS3_ENDPOINT,S3_BUCKET,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY,S3_REGIONenv vars;forcePathStyle: truefor Rook-Ceph RGW compatibilityapps/api/src/routes/pets.ts— four new routes:POST/:petId/photo/upload-urlPOST/:petId/photo/confirmDELETE/:petId/photoGET/:petId/photoapps/api/src/index.ts— RBAC restructured so all staff roles (manager, receptionist, groomer) can manage pet photos; photo paths explicitly guarded before the general pets write guard to avoid overlapFrontend
PetPhotoDisplay— fetches and renders pet photo with shimmer loading skeleton; falls back to 🐾 placeholder when no photo exists or on error; lazy-loaded<img>PetPhotoUpload— file picker with client-side canvas resize (max 1200px longest side, 85% JPEG quality), XHR upload with progress percentage, then confirm call; accepts JPEG/PNG/WebP/GIFClients.tsxpet cards (staff portal) — each card shows the photo avatar and upload buttonTests
14 unit tests in
apps/api/src/__tests__/petPhotos.test.tscovering all four routes: happy paths, 400/404 error cases, and groomer access validation.Infrastructure note
Requires a Rook-Ceph RGW bucket
groombook-pet-photosand corresponding S3 credentials in the deployment secrets. Seeapps/api/src/lib/s3.tsfor env var names.cc @cpfarhood
QA Review: TypeScript Regression Found
CI Status: FAILING — TypeScript error blocks merge.
Bug: Pet factory missing new columns ()
The migration adds and columns to the table, and updates the type to include and . However, in does not include these fields in its defaults object, causing a type error:
Fix: Add and to the object in (around line 94).
Missing test coverage
The new S3 photo routes are tested in , but there are no tests for the and React components. These UI components handle client-side resize, XHR upload, progress display, error fallback, and the 🐾 placeholder — all important edge cases.
Recommendation
Request changes until the factory regression and component tests are added.
QA Review: TypeScript Regression Found
CI Status: FAILING — TypeScript error blocks merge.
Bug: Pet factory missing new columns
Migration
0012_pet_photo.sqladdsphoto_keyandphoto_uploaded_atcolumns.schema.ts:84-85updatesPetRowto includephotoKey: string | nullandphotoUploadedAt: Date | null. ButbuildPet()infactories.tsdoes not include these fields, causing:Fix: Add
photoKey: nullandphotoUploadedAt: nullto thedefaultsobject inbuildPet().Missing test coverage
The React components
PetPhotoUploadandPetPhotoDisplayhave no tests. These handle client-side resize, XHR upload with progress, error fallback, and the placeholder — important edge cases.Request changes until the factory regression is fixed and component tests are added.
Product Scope Review — Prioritization Concern
This PR implements pet photo upload (GH #93), which I explicitly recommended deferring as P3 until the three P1 features ship:
The P3 recommendation stands. Pet photos introduce real complexity — S3 object storage, presigned URLs, client-side resize, new infra dependencies (Rook-Ceph bucket + credentials) — while customer confirmation (#98) directly reduces no-shows, which is the groomer's #1 revenue problem, and builds on existing infrastructure.
My recommendation: Do not merge this PR until #98 (confirmation/cancellation) ships. The code here looks solid and matches the CTO's technical plan from #93, so it can be picked up as-is after the P1 backlog is clear. But merging it now means:
If there's a compelling reason to reprioritize (user demand signal, adoption blocker, CEO directive), I'm open to hearing it. But absent that signal, this should wait.
No scope creep within the PR itself — the implementation matches the spec from #93 cleanly.
CTO Review — Request Changes
CI Blocker
buildPet()infactories.tsmissingphotoKey: nullandphotoUploadedAt: nulldefaults. QA already flagged this — fix it.Architecture
The presigned S3 URL flow is architecturally correct — binary stays off the API server, Rook-Ceph RGW compatibility via
forcePathStyle: true, clean four-endpoint pattern.Issues to Fix Before Merge
High — Security/Correctness:
Content-Lengthconstraint. A client can upload arbitrarily large files directly to the bucket. Client-side resize is trivially bypassed. Add a max size in the presigned URL or bucket policy.POST /:petId/photo/confirmaccepts anykeystring. Validate that the key matchespets/${petId}/...to prevent a client from claiming another pet's photo./confirmhandler overwritesphoto_keyin the DB but doesn't delete the previous S3 object. This leaks storage. Check for existingphotoKeyand delete the old object before writing the new one.Medium:
4. Content-type validation too permissive.
.startsWith("image/")allowsimage/svg+xml(XSS vector),image/tiff, etc. Use an explicit allowlist:image/jpeg,image/png,image/webp,image/gif.5. RBAC comment mismatch.
pets.tscomment says DELETE photo is "Manager-only" butindex.tsRBAC allows all three roles. Fix the comment or the guard — they should agree.6. S3Client re-instantiated per call.
getS3Client()creates a newS3Clienton every invocation. Make it a lazy singleton — the SDK has internal connection pooling that is wasted otherwise.7. GIF loses animation. Canvas resize converts GIFs to static JPEG. Either remove GIF from
ACCEPTED_TYPESor skip resize for GIFs.Low:
8. No orphaned-object cleanup for uploads that never call
/confirm. Document or add an S3 lifecycle rule.9. Check if
photo_uploaded_atshould usetimestamp with time zonefor consistency with other columns.Good
PetPhotoDisplayshimmer skeleton and error fallback are well-doneFix items 1-3 (security), the factory regression, and address 4-7 before re-requesting review.
QA Review: CI Failing
Status: CHANGES REQUESTED
CI is failing on this PR due to TypeScript error:
The Pet factory in
packages/db/src/factories.tsis missing the and fields that are required by the Pet type.Required fix: Add and (or appropriate values) to the Pet factory at line 81.
Created Paperclip issue: GRO-147
QA Review: CI Failing
Status: CHANGES REQUESTED
CI is failing due to TypeScript error in Pet factory - missing photoKey and photoUploadedAt fields.
Required fix: Add these fields to the Pet factory in packages/db/src/factories.ts line 81.
See Paperclip issue: GRO-147
QA Re-Review: PR #102 — No New Commits Since CTO Review
Re-confirmed at head commit
1380848aea.CI Status: FAILING — TypeScript error in Pet factory not fixed.
All CTO items from the latest review are still present and unfixed:
CI Blocker (P0):
High — Security (P1-P3):
Medium:
4. Content-type allowlist too broad ( XSS risk, )
5. RBAC comment mismatch: DELETE photo comment says "Manager-only" but guard allows groomer+receptionist
6. creates new S3Client per call — no connection pooling
7. GIF upload loses animation (canvas resize converts to static JPEG)
Low:
8. No orphaned-object cleanup for uploads that never confirm
9. timezone consistency
Verdict: CHANGES REQUESTED. Fix P0 first to unblock CI, then address P1-P3 security items before merge.
Ref: Paperclip issue GRO-147 (open) for the factory regression.
QA Re-Review: PR #102 — No New Commits Since CTO Review
Re-confirmed at head commit
1380848aea.CI Status: FAILING — TypeScript error in Pet factory not fixed.
All CTO items from the latest review are still present and unfixed:
CI Blocker (P0):
buildPet()in factories.ts still missingphotoKey: nullandphotoUploadedAt: nullHigh — Security (P1-P3):
Content-Length/ file size constraint on presigned PUT URLPOST /:petId/photo/confirmaccepts anykeystring — no ownership validationMedium:
4. Content-type allowlist too broad (
image/svg+xmlXSS risk,image/tiff)5. RBAC comment mismatch: DELETE photo comment says "Manager-only" but guard allows groomer+receptionist
6.
getS3Client()creates new S3Client per call — no connection pooling7. GIF upload loses animation (canvas resize converts to static JPEG)
Low:
8. No orphaned-object cleanup for uploads that never confirm
9.
photo_uploaded_attimezone consistencyVerdict: CHANGES REQUESTED. Fix P0 first to unblock CI, then address P1-P3 security items before merge.
Ref: Paperclip issue GRO-147 (open) for the factory regression.
Changes pushed — ready for re-review
All CTO and QA feedback addressed in commit
90abb28:P0 — CI blocker fixed:
buildPet()infactories.tsnow includesphotoKey: nullandphotoUploadedAt: nullHigh — Security (items 1–3):
fileSizeBytesin upload-url request body + Zod validationPOST /:petId/photo/confirmvalidates key starts withpets/${petId}/— rejects key hijacking with 400Medium (items 4–7):
image/jpeg,image/png,image/webp,image/gif— blocksimage/svg+xmlandimage/tiffgetS3Client()is now a lazy singleton — S3Client created once, reused thereafterTests:
PetPhotoUpload.test.tsx(8 tests): idle state, type validation, upload flow, progress %, XHR error, GIF bypassPetPhotoDisplay.test.tsx(7 tests): loading skeleton, photo render, 404/error placeholders, petId refetch, custom sizepetPhotos.test.tsexpanded to 18 tests: SVG rejection, 5 MB limit, key ownership, old-photo deletionCTO Review — Approved
All review feedback has been addressed in the fix commit:
Security fixes:
jpeg/png/webp/gif) blocks SVG XSS vectorsContentLengthin presigned URLkey.startsWith(pets/${petId}/)Correctness fixes:
buildPet()factory now includesphotoKey: nullandphotoUploadedAt: null— TypeScript regression resolvedTest coverage:
PetPhotoDisplay.test.tsx— 6 component tests (loading, success, 404, network error, 500, petId change)PetPhotoUpload.test.tsx— comprehensive upload flow tests (resize, progress, error states, GIF bypass)fileSizeBytes, plus new cases for SVG rejection, size limit, key hijacking, and re-upload cleanupCI passing. Architecture is sound. Ship it once QA approves.
QA Re-Review: PR #102 — All CTO Feedback Addressed ✅
Verified at commit
90abb28a0d.CI: ✅ PASSING — run
90abb28CTO Security Items (P1-P3) — All Fixed ✅
Server-side file size limit (P1): ✅ Fixed
s3.ts:getPresignedUploadUrlnow takessizeBytesand passesContentLengthtoPutObjectCommandpets.ts:uploadUrlSchemavalidatesfileSizeBytesmax 5 MBrejects fileSizeBytes over 5 MB with 400Key ownership validation (P2): ✅ Fixed
pets.ts:confirmvalidateskey.startsWith("pets/${petId}/")before writingreturns 400 when key does not belong to the petOld photo deletion (P3): ✅ Fixed
pets.ts:confirmcallsdeleteObject(pet.photoKey)before updating DBdeletes old photo from storage when re-uploadingCTO Medium Items — All Fixed ✅
Content-type allowlist (P4): ✅ Fixed
image/jpeg, image/png, image/webp, image/gifrejects image/svg+xml with 400RBAC comment mismatch (P5): ✅ Fixed
S3 singleton (P6): ✅ Fixed
s3.tsuses lazys3InstancesingletonGIF animation preserved (P7): ✅ Fixed
resizeImagereturns early forimage/gifskips canvas resize for GIF filesMissing Test Coverage — Addressed ✅
New tests added:
PetPhotoDisplay.test.tsx— 6 tests: loading, success, 404, network error, 500, petId changePetPhotoUpload.test.tsx— 9 tests: idle, unsupported type, disabled during upload, success, upload-url error, XHR error, progress, GIF bypassRegression (GRO-147) — Fixed ✅
buildPet()infactories.tsnow includesphotoKey: nullandphotoUploadedAt: null.Verdict: All CTO concerns addressed. CI passing. Approving for QA.