CODEX // oaoisme wiki

§ unswayed-backend · Social feed & reels (Phase 7)

The media pipeline

updated 2026-06-10

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:

  1. Multer hard cap, 105 MBFileInterceptor('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.
  2. The validator (post-media.validator.ts) — runs once on the already-buffered Express.Multer.File and throws keyed 422s with the legacy-exact messages.
  3. 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 MIMEThe media field must be a file of type: jpg, jpeg, png, webp, mp4, avi, mov, webm. by filename extensionThe 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_type is forced to 'video' no matter what the client sent. A reel can never persist with media_type NULL (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 body media URL string accompanied by media_type='video'. Changing a post into a reel is stricter: only a real video file or the stored media type satisfies it, else Cannot 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 media URL 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:

  1. validate everything that can be validated before spending money;
  2. upload to Cloudinary (folder: 'unswayed/posts', resource type derived from the MIME — video/*video, else image);
  3. run the DB write;
  4. if the write throws → delete the just-uploaded asset (compensation) and rethrow — no orphans;
  5. 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.