Posts & Feed — Technical Reference
Where this lives
Backend
apps/backend/graphql/resolvers/post.resolver.js—feed,trendingPosts,userPosts,postStats,createPost,updatePost,deletePost,incrementPostViewsapps/backend/graphql/resolvers/post-interaction.resolver.js—likePost,unlikePost,postInteractionCounts,hasUserLikedPostapps/backend/graphql/resolvers/post-comment.resolver.js—createComment,postComments,commentReplies, comment reactionsapps/backend/graphql/types/post.type.js—Post/PostMediaSDL typesapps/backend/managers/post-managers/post.manager.js— feed assembly, draft/publish handling, view-count dedup logic
Frontend
apps/frontend-nextjs/src/page-components/HomePage.tsx— main feed pageapps/frontend-nextjs/src/app/home/page.tsx—/homeroute entryapps/frontend-nextjs/src/components/PostCard.tsx— renders a single post in the feedapps/frontend-nextjs/src/components/CreatePostModal.tsx— post creation modal with media upload
Technical implementation checklist
-
feed— resolver wired inpost.resolver.js; called fromHomePage.tsxvia ApollouseQuery -
reactToComment(commentId, reaction)/removeCommentReaction+PostComment.viewerReaction/reactionCounts— comment reactions (like/love/haha/wow/sad/angry). Fixed persistence bug:post-comment-interaction.access-service.jsqueriedcomment_idinstead ofpost_comment_id -
likePost/unlikePost— resolvers wired inpost-interaction.resolver.js; called fromPostCard.tsx'shandleLike(~line 499) -
createComment— resolver wired inpost-comment.resolver.js; called fromPostModal.tsxandstories/StoryViewer.tsx -
repostPost/undoRepost— resolvers wired inpost.resolver.js; called fromPostCard.tsx'shandleRepost(~line 566) andPostModal.tsx'shandleRepost(~line 710) with ahasUserRepostedcheck -
sharePost— no such GraphQL mutation exists anywhere in resolvers/schema. The "share" icon inPostCard.tsxis actually the repost action; a real (non-GraphQL) native share/copy-link exists separately inPostOptionsMenu.tsx(navigator.share/ copy-link, no backend call needed) -
createPost— resolver wired inpost.resolver.js; called fromCreatePostModal.tsx, which handles media upload, hashtag extraction, and tagged-user submission -
taggedUsers(PostMentionviapost_mentions) —CreatePostModal.tsx'shandleImageTapForTag(~line 751) lets the user tap a point on an uploaded image, search for a user (SEARCH_USERS_FOR_TAG), and the pins are submitted astaggedUsersoncreatePost(~line 907);post.resolver.js:478exposes them back on thePosttype.PostTagInput.mediaIndex/x/yare now optional, so a user can also be tagged with no photo pin (e.g. on a text-only post) — those "positionless" tags are merged in alongside the pinned ones before submission. - Hashtag auto-extraction —
post.manager.js:217callshashtagManager.extractAndCreateHashtagson everycreatePost, backing the/hashtag/[tag]feed (see Hashtags & Trending) -
feed(reused for the "Descubrir" tab) —ExplorePage.tsx'sDISCOVER_POSTSquery aliases thefeedfield -
trendingPosts— real ranking logic added inpost.access-service.js#getTrendingPosts(recent public posts scored byviewsCount*1 + likesCount*3 + commentsCount*4 + sharesCount*5 + savesCount*4over a 48h window by default) and exposed viapost.resolver.js; wired inExplorePage.tsx's "En tendencia" tab. Previously just a docblock comment (@method getTrendingPosts) with no implementation anywhere and no schema field at all. -
Post.viewerHasLiked/viewerHasSaved/viewerHasReposted/interactionCount/giftStats— resolved server-side asPostfield resolvers inpost.resolver.js, bundled into whatever query already loaded the post (feed,userPosts,post).HomePage.tsx's feed query selects them, andPostCard.tsxreads them straight off the post (itsinlineStatecheck) instead of firing separatehasUserLikedPost/isPostSaved/hasUserReposted/postInteractionCountsqueries per card — those per-post queries are kept only as a fallback for contexts that don't preload this state (e.g.PostModal.tsx). -
ContentTagManager/UserContentInterest— logic exists incontent-tag.manager.js, but not required bypost.manager.jsor any resolver — orphaned, no schema exposure, no frontend usage
Post model
| Field | Type | Description |
|---|---|---|
text | String | Post caption |
visibility | Enum | public / followers / private / subscribers |
location | String | Optional location label |
media | [PostMedia] | Attached images or videos |
viewsCount | Int | Accumulated view count |
sharesCount | Int | Share count |
isPublished | Boolean | Draft vs. published |
Media (PostMedia)
Each media item has: mediaUrl, mediaType, thumbnailUrl, duration, width, height, aspectRatio, order. The order field controls the display sequence in multi-media posts. filterCss carries an Instagram-style filter (e.g. contrast(1.2) saturate(1.35)) chosen at post time, applied on top of the raw image at display time. objectFit (cover/contain) controls how the image fills its frame. isPreview marks an item as a free preview on a paid post — visible to everyone even before purchase, while the rest stay locked.
Queries
feed returns the authenticated user's personalized home feed — posts from accounts they follow, ordered by relevance and recency. Supports cursor-based pagination via limit and offset. A post the viewer published themselves in the last 5 minutes is pinned to the very top of their own feed (post.access-service.js#getFeed), so a just-published post shows up immediately instead of waiting to earn engagement/recency score.
userPosts fetches all published posts by a specific user. Useful for rendering a profile grid. Respects the target user's privacy settings — private accounts only return posts if the caller is a follower.
postStats returns aggregate counters for a post without fetching the full post object. Use this for lightweight stat refresh without re-loading media or comments.
query GetFeed($limit: Int, $offset: Int) {
feed(limit: $limit, offset: $offset) {
id text visibility location viewsCount sharesCount
# Bundled per-viewer state - lets the card skip a separate query per post
viewerHasLiked viewerHasSaved viewerHasReposted interactionCount
user { id username profilePicture }
media { mediaUrl mediaType thumbnailUrl aspectRatio }
comments { id text user { username } }
interactions { reactionType user { username } }
}
}
# Recent public posts ranked by engagement - not personalized, unlike feed
query GetTrendingPosts($limit: Int, $offset: Int, $windowHours: Int) {
trendingPosts(limit: $limit, offset: $offset, windowHours: $windowHours) {
id text viewsCount likesCount commentsCount sharesCount
user { id username profilePicture }
media { mediaUrl mediaType thumbnailUrl }
}
}
# All posts from a specific user's profile
query UserPosts($userId: ID!, $limit: Int, $offset: Int) {
userPosts(userId: $userId, limit: $limit, offset: $offset) { id text media { mediaUrl mediaType } }
}
# Lightweight counters for a post
query PostStats($postId: ID!) {
postStats(postId: $postId) {
viewsCount likesCount commentsCount sharesCount
}
}
Mutations
createPost creates a new post. If isPublished is false the post is saved as a draft and not shown in feeds. Media can be passed as mediaUrls (simple URL array) or mediaItems (URL + dimensions).
updatePost allows editing the caption, visibility, or location of an existing post. Only the post's author can update it.
deletePost permanently removes the post and all its associated media, comments, and reactions.
incrementPostViews should be called once per unique view — typically when the post enters the viewport. The server deduplicates rapid calls.
mutation CreatePost($input: PostCreateInput!) {
createPost(input: $input) { id text visibility isPublished }
}
mutation UpdatePost($postId: ID!, $input: PostUpdateInput!) {
updatePost(postId: $postId, input: $input) { id text visibility }
}
# Permanently deletes post and all associated data
mutation DeletePost($postId: ID!) { deletePost(postId: $postId) }
# Call once when a post enters the user's viewport
mutation IncrementViews($postId: ID!) { incrementPostViews(postId: $postId) { viewsCount } }
Reactions (6 types)
Users can react to a post with one of 6 emotions. Calling likePost again with a different interactionType replaces the previous reaction — a user can only have one reaction per post at a time.
enum InteractionType { like love haha wow sad angry }
# Add or change your reaction to a post
mutation LikePost($postId: ID!, $type: InteractionType) {
likePost(postId: $postId, interactionType: $type) { id reactionType }
}
# Remove your reaction entirely
mutation UnlikePost($postId: ID!) { unlikePost(postId: $postId) }
# Get a breakdown of reactions by type
query InteractionCounts($postId: ID!) {
postInteractionCounts(postId: $postId) {
like love haha wow sad angry total
}
}
# Check if the current user has already reacted
query HasUserLiked($postId: ID!) { hasUserLikedPost(postId: $postId) }
Comments (nested)
Comments support one level of nesting via parentId — pass a parentId to create a reply to an existing comment. Omit it to create a top-level comment.
postComments returns top-level comments with their replies pre-loaded. For high-comment posts use commentReplies to lazily load replies on demand.
# Create a comment; pass parentId to reply to an existing comment
mutation CreateComment($input: PostCommentCreateInput!) {
createComment(input: $input) { id text parentId user { username } }
}
mutation UpdateComment($commentId: ID!, $text: String!) {
updateComment(commentId: $commentId, input: { text: $text }) { id text }
}
mutation DeleteComment($commentId: ID!) { deleteComment(commentId: $commentId) }
# Top-level comments with nested replies
query PostComments($postId: ID!, $limit: Int, $offset: Int) {
postComments(postId: $postId, limit: $limit, offset: $offset) {
id text
user { username profilePicture }
replies { id text user { username } }
}
}
# Lazy-load replies for a specific comment
query CommentReplies($commentId: ID!, $limit: Int, $offset: Int) {
commentReplies(commentId: $commentId, limit: $limit, offset: $offset) { id text user { username } }
}
# Cheap counter — use before fetching comments to decide whether to show the section
query CommentCount($postId: ID!) { postCommentCount(postId: $postId) }
Comment reactions
Comments support the same 6 reaction types as posts. Each user can have at most one reaction per comment.
# React to a comment (replaces any previous reaction); returns a plain Boolean
mutation ReactToComment($commentId: ID!, $reaction: String!) {
reactToComment(commentId: $commentId, reaction: $reaction)
}
# Remove your reaction from a comment; also a plain Boolean
mutation RemoveCommentReaction($commentId: ID!) {
removeCommentReaction(commentId: $commentId)
}
# There is no standalone "commentReactions" query - per-type counts and the
# viewer's own reaction come back as fields on the comment itself.
query CommentWithReactions($id: ID!) {
postComment(id: $id) {
id
reactionCounts
viewerReaction
}
}
Content tags & feed personalization
The backend automatically classifies posts with ContentTag labels using Google Cloud Vision / Video Intelligence. These tags drive the feed personalization engine.
UserContentInterest records per-tag engagement signals for each user: postsCreated, postsLiked, postsCommented, postsShared, postsSaved, postsViewed, timeSpentSeconds. From these signals the system derives an interestScore, affinityLevel (low / medium / high), and isTrendingInterest flag. Posts whose tags align with a user's high-affinity interests rank higher in their feed.
Frontend components
| Component | Description |
|---|---|
PostCard.tsx | Renders a single post in the feed |
CreatePostModal.tsx | Modal for creating a post with media upload |
SuggestedUsers.tsx | Sidebar panel with follow suggestions |
page-components/HomePage.tsx | Main feed page |