§ unswayed-backend · Social feed & reels (Phase 7)
Feeds, moderation & the follow graph
Feeds, moderation & the follow graph
This page covers the read side of the social surface — GET /api/feeds and
GET /api/reels — the two moderation writes that shape it, and the §14 follow toggle
that gives every PostResource its user.isFollowed flag.
The feed (§17)
GET /api/feeds is network-wide: every user's status=1 posts of the requested
type (post by default), newest first, paginated with the standard
{current_page,last_page,per_page,total} block under data.feed + data.pagination.
It is not a following-only feed — that surprises people, but it is the legacy
behaviour: followers only matter for isFollowed badges, not for feed membership.
Two quirks are frozen:
- the success message echoes the raw
type:Feed post retrieved successfully.,Feed reel retrieved successfully.— and?type=bananahappily answersFeed banana retrieved successfully.with an empty page. In legacy, MySQL's enum comparison just matched nothing; in the rebuildPost.typeis a Postgres enum that would reject an unknown value, so the service short-circuits unknown types to an empty page before Prisma ever sees them. Same wire, different mechanics. - the viewer's own posts appear in their own feed (again: network-wide means everyone).
Reels and the cursor (§17)
GET /api/reels is built for swipe-feeds: last_seen_id (default 0) is a keyset
cursor — id > last_seen_id, ascending, take(limit) with limit defaulting to
1. Gaps are harmless: a deleted reel's id simply never matches. Extra filters pin
reels to actually-playable video: type='reel', media_type='video',
media IS NOT NULL. The response is data.reels with no pagination block, and
the message is the frozen lowercase reels retrieved successfully.
Moderation: two writes, two exclusion sets
Users shape their own feed with POST /api/post/mark-not-interesting/{id} and the
whole network's feed with POST /api/post/report-post/{id}
(report_type ∈ spam|abusive|irrelevant|other). Both are one-per-user
(@@unique(post_id, user_id) underneath), answering 400 with the exact
already-done message on a repeat, and 404 Post not found!. — typo included — for
a missing post.
Both feeds then exclude:
- every post the viewer marked not-interesting (personal), and
- every post reported by strictly more than 3 distinct users (global — 3 reporters keep a post visible, the 4th hides it for everyone).
The critical fix from the legacy analysis (REEL_POSTING_ANALYSIS #2): the
over-reported set is computed with a groupBy(post_id) HAVING count > 3 over the
full candidate set, before the limit/page is applied. Legacy once ran the main
query first (take(limit) then pluck), so an over-reported reel inside the first
page slipped through and consumed the page slot. In the rebuild the exclusion is
part of the main query's NOT IN, so the next clean reel fills the slot — the e2e
suite pins this exact regression.
The follow toggle (§14)
POST /api/user-follow is the third soft-delete toggle in the system (likes and,
later, blog-likes are the others), keyed on
unique(following_user_id, follower_user_id):
| State | Action | Message |
|---|---|---|
| no row | create | You are now following this user. |
| active row | soft delete | You have unfollowed this user. |
| trashed row | restore the same row | You are now following this user again. |
Self-follow is allowed — legacy never guarded it, so neither do we. The target only
has to exist (bare check → 422 The selected following user id is invalid.).
The notification moved off the request path. Legacy called FCM synchronously
inside the request — a slow push provider slowed every follow. The rebuild's toggle
enqueues a send-follow-notification job on the notifications queue (payload:
recipient, follower id/username/type) and a handler — clone of the Phase 1
send-job-alert shape — runs the standard NotificationsService fan-out: an in-app
Notification row (type: 'follow', title New Follower, description
{username} is following you.), the realtime broadcast, and the
preference-gated FCM push. Two rules are frozen: the notification fires on follow
and re-follow, and never on unfollow. The enqueue itself is wrapped so a
queue failure can't fail the toggle.
Reads are covered by two new indexes — (follower_user_id, deleted_at) and
(following_user_id, deleted_at) — because every isFollowed check and every
follower count filters on exactly those pairs.
How it all joins up
The follow row written here is what PostResourceBuilder batch-reads to stamp
user.isFollowed on every post in a feed page; the moderation rows written here are
what its feed queries exclude; and Phase 11's Talent Network (networks/friends/
recommendation) will read the same UserFollower table through the same exported
service. One table, one owner, many readers.