CODEX // oaoisme wiki

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

Feeds, moderation & the follow graph

updated 2026-06-10

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=banana happily answers Feed banana retrieved successfully. with an empty page. In legacy, MySQL's enum comparison just matched nothing; in the rebuild Post.type is 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 cursorid > 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:

  1. every post the viewer marked not-interesting (personal), and
  2. 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.