§ unswayed-backend
Social feed & reels (Phase 7)
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:
- the viewer's active likes over the page's post ids,
- the viewer's active follows over the de-duplicated author ids,
likescounts per post (deletedAt IS NULLonly — likes are soft-deleted),commentscounts 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
deletedAt→is_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_repliesin the payload is the real count (legacy serialized an unloaded counter asnull). 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:
- The media pipeline — limits, the 413/422 split, compensating deletes, reel invariants.
- Feeds, moderation & the follow graph —
the exclusion sets, the
>3report rule, the reels cursor, and the follow toggle.
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