CODEX // oaoisme wiki

§ unswayed-backend

Social feed & reels (Phase 7)

updated 2026-06-10

Social feed & reels (Phase 7)

Phase 7 is the LinkedIn-style side of unswayed. Where Phases 4–6 move an applicant through a hiring funnel, this phase lets users publish: posts and video reels (§18), a like toggle (§18), one-level comments + replies with a realtime broadcast (§19), the network feed and cursor-scrolled reels with moderation filters (§17), and the follow/unfollow toggle (§14) that gives the whole network its social graph.

It lives in src/social-feed/ — except the follow toggle, which deliberately lives in src/users/ (more on that below). ADR-0030 records the decisions.

The one serializer that rules the surface

Every endpoint that returns a post — index, show, store, update, feeds, reels — must emit the frozen PostResource:

{ id, user, is_mypost, isLiked, total_likes, total_comments, shares_count,
  type, content, media, media_type, status, created_at, updated_at }

The mixed casing (isLiked next to is_mypost) is not a mistake — it is byte-frozen legacy wire. Three of these fields are per-viewer: isLiked (did I like it), is_mypost (is it mine), and user.isFollowed (do I follow the author). In the legacy Laravel app each was an Eloquent accessor that ran its own query per post — a feed page of 10 posts cost 30+ queries.

The rebuild centralizes all of it in one injectable, PostResourceBuilder (post-resource.builder.ts). Callers load rows with the canonical POST_RESOURCE_INCLUDE (author + applicant/employer profile) and hand the page to buildMany(posts, viewerId), which issues exactly four grouped queries regardless of page size:

  1. the viewer's active likes over the page's post ids,
  2. the viewer's active follows over the de-duplicated author ids,
  3. likes counts per post (deletedAt IS NULL only — likes are soft-deleted),
  4. comments counts per post — filtered so deactivated authors don't count.

That last filter is subtle and legacy-faithful: legacy's PostComment model had a global scope hiding comments by deactivated users (status = 2), and that scope applied inside withCount('comments') too. So total_comments excludes deactivated authors' comments while counting both top-level comments and replies.

shares_count is a constant 0 for now: the share-into-chat endpoint (§20) and its MessagePostShare bridge table belong to the Phase 9 chat domain. The field is emitted today for shape parity and wired to the real count later — the same placeholder pattern Phase 4 used for applications_count.

The nested user block (and why comments differ)

post-user.block.ts builds the nested author block in frozen key order — id, first_name, middle_name, last_name, username, type, photo, isFollowed, then employer_id or applicant_id. Missing scalars fall back to '' (legacy used ?? '' everywhere). Name resolution reuses the Phase 3 FollowsService rules: applicants carry real name fields; employers get their companyName split.

A quirk worth knowing: the comment resource's user block has no isFollowed. That is exactly what legacy emitted, so there are two builders — toPostUserBlock (with the flag) and toPostCommentUserBlock (without).

One asset-URL gotcha

Post.media and User.profilePhoto store full Cloudinary secure_urls. The repo's common assetUrl helper unconditionally prefixes base + /storage/… — correct for relative legacy paths, corrupting for full URLs. resolveAssetUrl (in post-user.block.ts) passes http(s):// and Cloudinary URLs through verbatim and only delegates relative paths — the exact behaviour of legacy CloudinaryService::assetUrl. Anything in this module that renders media or photos goes through it; nothing calls storage.assetUrl directly on stored URLs.

The deactivated scope

Legacy Post and PostComment both had a global deactivated scope: rows by authors with status = 2 are invisible on every read (note: paused authors stay visible). The rebuild expresses it as a reusable where-helper — notDeactivatedAuthorWhere() in social-feed.query-helpers.ts — spread into every read. One deliberate asymmetry survives from legacy: the bare exists: checks (like/comment post_id validation) check the raw table, scope-free, exactly like Laravel's exists:posts,id rule did.

The like toggle

POST /api/post/like is a soft-delete toggle on unique(post_id, user_id):

  • no row → create → is_liked: true, 'Post liked successfully.'
  • active row → set deletedAtis_liked: false, 'Post unliked successfully.'
  • trashed row → restore the same row (never a duplicate) → liked again.

likes_count counts only non-trashed rows. The rebuild runs the toggle in a transaction with one bounded retry on a unique-violation race (two concurrent first likes), closing the legacy read-then-write hole without changing the wire.

Comments + the realtime broadcast

Comments are one-level threads (parent_id NULL = top-level, set = reply) — see the frozen quirks: the list returns top-level only (paginated), the replies endpoint returns all replies un-paginated, the resource's replies field is always [], and create/update/delete answer with status + message only. parent_id is accepted unvalidated (a reply can point anywhere — legacy never checked).

Every successful write broadcasts CommentReceived on ralli.comment.{post_id} with { type: 'new' | 'update' | 'delete', comment } through the RealtimeBroadcaster port (Phase 1's Socket.IO gateway). Three things to know:

  • the broadcast is non-blocking — a gateway failure never fails the HTTP write;
  • a delete snapshots the resource before deleting, so the event carries the comment that was removed;
  • one deliberate socket-only divergence: total_replies in the payload is the real count (legacy serialized an unloaded counter as null). The HTTP wire is untouched.

Channel authorization keeps Phase 1's rule: any authenticated socket may join a comment.* channel (the legacy channel was fully public — this is already stricter).

Where the follow toggle lives, and why

POST /api/user-follow is implemented in src/users/ (FollowToggleService + a new UsersController), not in social-feed. UserFollower was created in Phase 3 for the profile read-side; Phase 7 adds the write. Keeping reads and writes beside each other gives the table a single owner — social-feed only reads it (for isFollowed), and Phase 11's Talent Network will reuse the exported service. The toggle itself, the queued follow notification, and the moderation/feed mechanics have their own subpages:

Testing

Each slice was built TDD with mocked Prisma/ports (≥90% gate); four e2e suites drive the live app: social-feed-posts (multipart CRUD + likes), social-feed-comments (incl. broadcast assertions via a recording broadcaster fake), social-feed-feed (moderation thresholds, cursor), and user-follow (toggle + queued notification).

Subpages