§ unswayed-backend · Social feed & reels (Phase 7)
The media pipeline
The media pipeline
Posts and reels carry images (≤ 10 MB) or video (≤ 100 MB) through Cloudinary. In the
legacy app this was the single most broken surface — uploading a large video returned a
blank 500 because the web-server limits sat below the validation layer, the file's
MIME was re-read repeatedly (throwing on truncated uploads), and a failed write could
strand an orphaned Cloudinary asset or persist a reel with media_type = NULL. The
rebuild keeps the wire identical and fixes the structure underneath.
The limit ladder: who rejects what
There are three layers, each with a precise status code:
- Multer hard cap, 105 MB —
FileInterceptor('media', { limits: { fileSize: 105MB } }). Anything bigger is cut off during streaming and Nest's multer integration maps it to a clean 413. Nothing ever buffers unbounded; the legacy 500 is structurally impossible. - The validator (
post-media.validator.ts) — runs once on the already-bufferedExpress.Multer.Fileand throws keyed 422s with the legacy-exact messages. - Business rules in the service — content-or-media, reel invariants — as plain
422s with the exact legacy strings.
Store and update validate differently (on purpose)
Legacy's store() used Laravel rules (mimes:…|max:…) while update() used a custom
closure — so the two endpoints emit different messages for the same mistake, and
both are frozen:
| Case | POST /posts (store) |
POST /posts/{id} (update) |
|---|---|---|
| Type check | by MIME — The media field must be a file of type: jpg, jpeg, png, webp, mp4, avi, mov, webm. |
by filename extension — The media file type is not allowed. |
| Too large | The media field must not be greater than 102400 kilobytes. (or 10240 for images) |
The media file is too large. Max size: 100MB for videos, 10MB for images. — and yes, the image variant really reads Max size: 10MB for videos, 10MB for images. |
The size budget itself is decided by the upload's MIME: anything whose mimetype
contains video gets the 100 MB budget, everything else 10 MB.
Reel invariants
A reel is a video post, and the contract enforces it at every door:
- store: no file →
Reels must include a video.; a non-video file →Reels must be video content.;media_typeis forced to'video'no matter what the client sent. A reel can never persist withmedia_typeNULL (the legacy bug where auto-detection failed silently). - update: a reel must keep video — either a new video file, or the stored
media + media_type='video', or a bodymediaURL string accompanied bymedia_type='video'. Changing a post into a reel is stricter: only a real video file or the stored media type satisfies it, elseCannot change to reel without video media. - the content-or-media rule trims first (whitespace-only content no longer
passes), and on store only a real file counts as media — a
mediaURL string does not.
One faithful asymmetry: update does not force media_type to 'video' — it
trusts the request/stored value (the validation above guarantees consistency). That is
exactly what legacy did.
Upload → write → compensate
The order matters and is the same on store and update:
- validate everything that can be validated before spending money;
- upload to Cloudinary (
folder: 'unswayed/posts', resource type derived from the MIME —video/*→video, elseimage); - run the DB write;
- if the write throws → delete the just-uploaded asset (compensation) and rethrow — no orphans;
- on a successful update that replaced or cleared media → delete the old asset, after the write, best-effort.
The update's media resolution ladder is legacy-verbatim: a new file replaces
(old asset queued for deletion); a media string equal to the stored URL keeps;
no file and no string clears — but only when neither the old nor the new type is
reel; an unrecognized string also keeps. Content is stored trimmed, and a
media-only post stores content: '' (not NULL) — both legacy behaviours the resource
echoes back.
deleteByUrl — a new port capability
The DB stores only the delivery URL, not Cloudinary's public_id. Deleting therefore
needs the legacy deleteByUrl trick: parse
…/{resource_type}/upload/[v123/]{public_id}.{ext} back out of the URL. The rebuild
adds MediaStoragePort.deleteByUrl(url) with a pure, unit-tested parser
(src/storage/cloudinary-url.ts); foreign or unparseable URLs are a silent no-op, and
provider errors are swallowed — cleanup beside a business write is always
best-effort. Phase 9 (chat attachments) inherits this for free.
Why a 1-byte "video" works in tests
Neither the validator nor the stub storage parses file bytes — MIME comes from the
multipart part header, and in test/e2e environments Cloudinary is unconfigured so the
LogStorageService stub returns a deterministic URL. E2e suites upload tiny buffers
with contentType: 'video/mp4' and the whole pipeline behaves exactly as in
production, minus the network.