Skip to content

[API] S3 image upload pipeline — resize, store, and return CDN URLs #182

Description

@zakkiyyat

Overview

Implement a reusable image upload service that accepts an image file, resizes it into three variants (thumbnail, medium, full), uploads all three to AWS S3, and returns their public URLs. This pipeline is used by both the restaurant module (logo, cover image) and the food item module (item photos).


Figma Reference

📐 Figma: [Upload UX flow & image size specifications — placeholder, link to be added by design lead]


File Location

```
apps/api/src/shared/upload/
upload.service.ts # resize + S3 logic
upload.middleware.ts # multer config
upload.types.ts # UploadResult, ImageVariant types

apps/api/src/modules/upload/
controllers/upload.controller.ts
routes/upload.routes.ts
```

The `upload.service` lives in `shared/` because it is reused by multiple modules. The route/controller are their own thin module.


Image Variants

Variant Max dimension Format Use
`thumbnail` 200 × 200 px WebP Feed cards, list views
`medium` 600 × 600 px WebP Detail screens
`full` 1200 × 1200 px WebP Hero images

All variants maintain aspect ratio (object-fit: cover). Use the `sharp` package for resizing.


API Endpoint

```
POST /api/upload
Content-Type: multipart/form-data
Authorization: Bearer

Field name: file
Max file size: 5 MB
Accepted MIME types: image/jpeg, image/png, image/webp
```

Response (200)
```json
{
"thumbnail": "https://cdn.example.com/uploads/abc123-thumb.webp",
"medium": "https://cdn.example.com/uploads/abc123-medium.webp",
"full": "https://cdn.example.com/uploads/abc123-full.webp"
}
```


S3 Key Pattern

```
uploads/{uuid}-{variant}.webp
```

Example: `uploads/550e8400-e29b-41d4-a716-446655440000-thumb.webp`


Environment Variables

Add to `apps/api/.env.example`:

```
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_S3_BUCKET=
AWS_S3_PUBLIC_BASE_URL=https://.s3..amazonaws.com
```


Acceptance Criteria

  • Endpoint requires authentication (use the existing `authenticate` middleware)
  • Multer is configured to use memory storage (never write to disk on the server)
  • Files larger than 5 MB are rejected with a clear 413 error before reaching the controller
  • Non-image MIME types are rejected with 415
  • All three variants are uploaded to S3 in parallel (Promise.all)
  • Each upload uses a UUID-based key — never derive the key from the original filename
  • If any S3 upload fails, the endpoint returns 500 and does not return partial URLs
  • The `sharp` image pipeline converts all inputs to WebP regardless of input format
  • `AWS_S3_PUBLIC_BASE_URL` is used to construct returned URLs (not the S3 API URL)
  • The service is exported from `shared/upload/` and can be called directly from other services (e.g. to attach image URLs during restaurant create)

Dependencies to Install

```bash
npm install sharp multer @aws-sdk/client-s3 uuid --workspace @discoverly/api
npm install @types/multer @types/uuid --save-dev --workspace @discoverly/api
```


Error Responses

Scenario Status
No file attached 400
File exceeds 5 MB 413
Unsupported MIME type 415
S3 upload failure 500
Unauthenticated 401

Testing Notes

Unit-test `upload.service.ts` by mocking the S3 client and verifying:

  • Three PutObject calls are made per upload
  • Keys follow the UUID pattern
  • Returned URLs use `AWS_S3_PUBLIC_BASE_URL` as the base

Do not make real S3 calls in CI. Use `vi.mock` to stub `@aws-sdk/client-s3`.


Out of Scope

  • Video uploads
  • Avatar/profile images (attached to user — future sprint)
  • Image deletion / cleanup

Metadata

Metadata

Assignees

Labels

BACKENDBACKENDGrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official Campaigncomplex~3–4 day effortsprint-1Sprint 1: Restaurant & Menu Foundation

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions