feat: Social feed with photo sharing, likes, and comments#1882
Conversation
|
@coderabbitai Please review this PR for code quality, best practices, and potential issues. |
Rate Limit Exceeded
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Pull request overview
Adds a new “social feed” feature across the API and Expo app, enabling users to create photo posts and interact via likes and comments (including replies), with navigation entry points in the app UI.
Changes:
- API: introduced feed schemas, DB tables/relations, and protected
/api/feedroutes for posts, likes, and comments. - Expo: added
features/feedmodule (screens/components/hooks/utils) and new feed navigation routes. - UI/i18n: added feed strings and exposed the feed via a new tab + dashboard tile behind
enableFeed.
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/api/src/schemas/feed.ts | New OpenAPI/Zod schemas for posts/comments/feed responses. |
| packages/api/src/routes/index.ts | Mounts new protected /feed routes. |
| packages/api/src/routes/feed/posts.ts | Implements post CRUD + post like toggle + feed pagination. |
| packages/api/src/routes/feed/index.ts | Aggregates feed post + comment subroutes. |
| packages/api/src/routes/feed/comments.ts | Implements comment list/create/delete + comment like toggle. |
| packages/api/src/db/schema.ts | Adds posts, post_likes, post_comments, comment_likes tables + relations/types. |
| apps/expo/lib/i18n/locales/en.json | Adds navigation/feed strings for the new feature. |
| apps/expo/features/feed/utils/index.ts | Adds feed helpers (image URL, author name, relative time). |
| apps/expo/features/feed/types.ts | Adds TS types for post/comment payloads. |
| apps/expo/features/feed/screens/index.ts | Re-exports feed screens. |
| apps/expo/features/feed/screens/PostDetailScreen.tsx | Post detail UI with paginated comments + inline composer. |
| apps/expo/features/feed/screens/FeedScreen.tsx | Feed list UI with infinite scroll + empty state + create CTA. |
| apps/expo/features/feed/screens/CreatePostScreen.tsx | Create flow: pick/take photos, upload to R2, create post. |
| apps/expo/features/feed/index.ts | Barrel export for feed feature module. |
| apps/expo/features/feed/hooks/useTogglePostLike.ts | React Query mutation for post like toggle. |
| apps/expo/features/feed/hooks/useToggleCommentLike.ts | React Query mutation for comment like toggle. |
| apps/expo/features/feed/hooks/usePostComments.ts | Infinite query for comments pagination. |
| apps/expo/features/feed/hooks/useFeed.ts | Infinite query for feed pagination. |
| apps/expo/features/feed/hooks/useDeletePost.ts | Mutation for deleting a post. |
| apps/expo/features/feed/hooks/useDeleteComment.ts | Mutation for deleting a comment. |
| apps/expo/features/feed/hooks/useCreatePost.ts | Mutation for creating a post. |
| apps/expo/features/feed/hooks/useAddComment.ts | Mutation for adding a comment/reply. |
| apps/expo/features/feed/hooks/index.ts | Re-exports feed hooks. |
| apps/expo/features/feed/components/index.ts | Re-exports feed components. |
| apps/expo/features/feed/components/PostCard.tsx | Post card UI (images carousel, like/comment actions, owner delete). |
| apps/expo/features/feed/components/FeedTile.tsx | Dashboard tile entrypoint into the feed. |
| apps/expo/features/feed/components/CommentItem.tsx | Comment row UI with indentation + like/delete actions. |
| apps/expo/config.ts | Enables featureFlags.enableFeed. |
| apps/expo/app/(app)/feed/create.tsx | Expo Router entry for creating a post. |
| apps/expo/app/(app)/feed/[id].tsx | Expo Router entry for post detail (fetch post by id). |
| apps/expo/app/(app)/(tabs)/feed/index.tsx | Feed tab route. |
| apps/expo/app/(app)/(tabs)/feed/_layout.tsx | Feed tab stack layout. |
| apps/expo/app/(app)/(tabs)/_layout.tsx | Adds feed tab + icon mapping with feature flag gating. |
| apps/expo/app/(app)/(tabs)/(home)/index.tsx | Adds FeedTile to dashboard layout and searchable tiles. |
You can also share your feedback on Copilot code review. Take the survey.
| commentsRoutes.openapi(deleteCommentRoute, async (c) => { | ||
| const auth = c.get('user'); | ||
| const { commentId } = c.req.valid('param'); | ||
| const db = createDb(c); | ||
|
|
||
| const comment = await db.query.postComments.findFirst({ | ||
| where: eq(postComments.id, commentId), | ||
| }); | ||
|
|
||
| if (!comment) { | ||
| return c.json({ error: 'Comment not found' }, 404); | ||
| } | ||
|
|
||
| if (comment.userId !== auth.userId) { | ||
| return c.json({ error: 'Forbidden' }, 403); | ||
| } | ||
|
|
||
| await db.delete(postComments).where(eq(postComments.id, commentId)); | ||
|
|
There was a problem hiding this comment.
deleteCommentRoute ignores the postId path param and deletes solely by commentId. This allows deleting a comment even if the URL’s postId doesn’t match the comment’s postId, which is surprising for consumers and can mask client bugs. Validate that the comment belongs to the postId in the route params (or remove postId from the path).
| // POST /feed/:postId/comments/:commentId/like - toggle like on comment | ||
| const toggleCommentLikeRoute = createRoute({ | ||
| method: 'post', | ||
| path: '/:postId/comments/:commentId/like', | ||
| tags: ['Feed'], | ||
| summary: 'Toggle like on a comment', | ||
| security: [{ bearerAuth: [] }], | ||
| request: { | ||
| params: z.object({ | ||
| postId: z.coerce.number().int(), | ||
| commentId: z.coerce.number().int(), | ||
| }), | ||
| }, | ||
| responses: { | ||
| 200: { | ||
| description: 'Like toggled', | ||
| content: { 'application/json': { schema: LikeToggleResponseSchema } }, | ||
| }, | ||
| 404: { | ||
| description: 'Comment not found', | ||
| content: { 'application/json': { schema: ErrorResponseSchema } }, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| commentsRoutes.openapi(toggleCommentLikeRoute, async (c) => { | ||
| const auth = c.get('user'); | ||
| const { commentId } = c.req.valid('param'); | ||
| const db = createDb(c); | ||
|
|
||
| const comment = await db.query.postComments.findFirst({ | ||
| where: eq(postComments.id, commentId), | ||
| }); | ||
|
|
||
| if (!comment) { | ||
| return c.json({ error: 'Comment not found' }, 404); | ||
| } | ||
|
|
There was a problem hiding this comment.
toggleCommentLikeRoute also ignores the postId param and doesn’t verify the comment belongs to that post. For consistency with the REST path and to prevent mismatched URLs from succeeding, check comment.postId === postId (and ideally return 404 when it doesn’t match).
| postsRoutes.openapi(deletePostRoute, async (c) => { | ||
| const auth = c.get('user'); | ||
| const { postId } = c.req.valid('param'); | ||
| const db = createDb(c); | ||
|
|
||
| const post = await db.query.posts.findFirst({ where: eq(posts.id, postId) }); | ||
|
|
||
| if (!post) { | ||
| return c.json({ error: 'Post not found' }, 404); | ||
| } | ||
|
|
||
| if (post.userId !== auth.userId) { | ||
| return c.json({ error: 'Forbidden' }, 403); | ||
| } | ||
|
|
||
| await db.delete(posts).where(eq(posts.id, postId)); | ||
|
|
||
| return c.json({ success: true }, 200); |
There was a problem hiding this comment.
When deleting a post, the DB rows are removed but the image objects in R2 are not. This can leave orphaned files and increase storage costs over time. Consider deleting post.images keys from the R2 bucket (similar to pack item image deletion) before/after the DB delete, while keeping failures non-blocking.
| return useMutation({ | ||
| mutationFn: async (postId: number) => { | ||
| const response = await axiosInstance.post<LikeToggleResponse>(`/api/feed/${postId}/like`); | ||
| return response.data; | ||
| }, | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: ['feed'] }); | ||
| }, | ||
| }); |
There was a problem hiding this comment.
useTogglePostLike only invalidates the feed list query (['feed']). The post detail route uses a separate query key (['feed', postId]), so liking a post from PostDetailScreen won’t refresh the post’s likeCount/likedByMe and the UI can stay stale. Also invalidate the per-post query key (or update the cache optimistically).
| // Post likes table | ||
| export const postLikes = pgTable('post_likes', { | ||
| id: serial('id').primaryKey(), | ||
| postId: integer('post_id') | ||
| .references(() => posts.id, { onDelete: 'cascade' }) | ||
| .notNull(), | ||
| userId: integer('user_id') | ||
| .references(() => users.id, { onDelete: 'cascade' }) | ||
| .notNull(), | ||
| createdAt: timestamp('created_at').defaultNow().notNull(), | ||
| }); |
There was a problem hiding this comment.
post_likes is missing a uniqueness constraint on (postId, userId). With the current read-then-insert toggle, concurrent requests can insert duplicate likes, inflating likeCount and breaking toggling. Add a composite unique constraint/index and handle insert via conflict-safe semantics (and delete by the same pair).
| import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; | ||
| import { Icon } from '@roninoss/icons'; | ||
| import { userStore } from 'expo-app/features/auth/store'; | ||
| import { uploadImage } from 'expo-app/features/packs/utils/uploadImage'; |
There was a problem hiding this comment.
userStore is imported but never used in this screen. Please remove the unused import to keep the file lint-clean.
| if (!post) { | ||
| return ( | ||
| <View className="flex-1 items-center justify-center bg-background"> | ||
| <Text>Post not found</Text> | ||
| </View> |
There was a problem hiding this comment.
The post detail route renders a hardcoded "Post not found" string instead of using i18n like the rest of the feed UI. Consider adding a feed.postNotFound (or similar) key and using t(...) here for localization consistency.
| {post.images.length > 1 && ( | ||
| <View className="absolute bottom-2 right-3 bg-black/50 rounded-full px-2 py-0.5"> | ||
| <Text className="text-white text-xs">{post.images.length} photos</Text> | ||
| </View> |
There was a problem hiding this comment.
The "{post.images.length} photos" overlay is hardcoded English. Since feed strings are otherwise localized, consider moving this to i18n (including pluralization) so it translates correctly.
| // Comment likes table | ||
| export const commentLikes = pgTable('comment_likes', { | ||
| id: serial('id').primaryKey(), | ||
| commentId: integer('comment_id') | ||
| .references(() => postComments.id, { onDelete: 'cascade' }) | ||
| .notNull(), | ||
| userId: integer('user_id') | ||
| .references(() => users.id, { onDelete: 'cascade' }) | ||
| .notNull(), | ||
| createdAt: timestamp('created_at').defaultNow().notNull(), | ||
| }); |
There was a problem hiding this comment.
comment_likes is missing a uniqueness constraint on (commentId, userId). As written, concurrent toggles can create duplicate rows and cause incorrect likeCount results. Add a composite unique constraint/index and make the toggle insert conflict-safe.
| export const postComments = pgTable('post_comments', { | ||
| id: serial('id').primaryKey(), | ||
| postId: integer('post_id') | ||
| .references(() => posts.id, { onDelete: 'cascade' }) | ||
| .notNull(), | ||
| userId: integer('user_id') | ||
| .references(() => users.id, { onDelete: 'cascade' }) | ||
| .notNull(), | ||
| content: text('content').notNull(), | ||
| parentCommentId: integer('parent_comment_id'), | ||
| createdAt: timestamp('created_at').defaultNow().notNull(), | ||
| updatedAt: timestamp('updated_at').defaultNow().notNull(), | ||
| }); |
There was a problem hiding this comment.
post_comments.parentCommentId is just a plain integer; there’s no foreign key back to post_comments.id, so replies can reference non-existent comments and won’t cascade on delete. Consider making it a self-referential FK (and ideally add an index) to enforce reply integrity.
Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
…s, fix type inconsistencies Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
…eanup - Validate postId param in deleteCommentRoute and toggleCommentLikeRoute to prevent cross-post comment deletion/liking (security fix) - Add composite unique constraints on (postId, userId) for post_likes and (commentId, userId) for comment_likes to prevent duplicate likes - Replace duplicated formatRelativeDate with shared getRelativeTime utility - Remove unused userStore import from CreatePostScreen Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Set enableFeed to false in apps/expo/config.ts (feature not yet QA'd) - Add migration 0033_social_feed_tables.sql for posts, postLikes, postComments, commentLikes tables - Add self-referential FK on postComments.parentCommentId with ON DELETE cascade - Fix comment count display in PostDetailScreen to use post.commentCount instead of comments.length (which only reflects the current page)
d31f885 to
e5c3950
Compare
Coverage Report for Expo Unit Tests Coverage (./apps/expo)
File Coverage
|
||||||||||||||||||||||||||||||||||||||
Coverage Report for API Unit Tests Coverage (./packages/api)
File CoverageNo changed files found. |
The Expo Unit Tests CI job on development was failing because the v8 statement-coverage threshold (75%) dropped to 74.32% after recent feature merges. PR #1882 (social feed) and the pack-template weight helpers landed with no unit tests, and vitest.config.ts includes every .ts under `features/**/utils/**` in the coverage report. This adds pure-logic unit tests for two easy-to-cover modules: - features/feed/utils/index.ts: buildPostImageUrl, formatAuthorName, and formatRelativeDate. clientEnvs and getRelativeTime are mocked. - features/pack-templates/utils/computePacktemplateWeight.ts: covers empty templates, base gear, consumables, worn items, quantity, and unit conversion. The expo-app/features/packs/utils barrel is mocked to the two pure conversion helpers so the test does not pull in uploadImage.ts (which imports expo-file-system -> expo-sqlite). Coverage goes from 74.32% -> 79.87% statements; 330/330 tests pass. No production code changes.
…g-social-interactions feat: Social feed with photo sharing, likes, and comments
The Expo Unit Tests CI job on development was failing because the v8 statement-coverage threshold (75%) dropped to 74.32% after recent feature merges. PR #1882 (social feed) and the pack-template weight helpers landed with no unit tests, and vitest.config.ts includes every .ts under `features/**/utils/**` in the coverage report. This adds pure-logic unit tests for two easy-to-cover modules: - features/feed/utils/index.ts: buildPostImageUrl, formatAuthorName, and formatRelativeDate. clientEnvs and getRelativeTime are mocked. - features/pack-templates/utils/computePacktemplateWeight.ts: covers empty templates, base gear, consumables, worn items, quantity, and unit conversion. The expo-app/features/packs/utils barrel is mocked to the two pure conversion helpers so the test does not pull in uploadImage.ts (which imports expo-file-system -> expo-sqlite). Coverage goes from 74.32% -> 79.87% statements; 330/330 tests pass. No production code changes.
The app lacked any social sharing capabilities. This adds a full social feed feature: photo posts, likes, comments (with replies), and the ability to delete your own content.
API (
packages/api/)DB Schema — 4 new tables:
posts— caption + jsonb array of R2 object keyspost_likes,post_comments(supportsparent_comment_idfor replies),comment_likesRoutes —
packages/api/src/routes/feed//api/feed/api/feed/:postId/api/feed/:postId/like/api/feed/:postId/comments/api/feed/:postId/comments/:commentId/api/feed/:postId/comments/:commentId/likeFeed list query batches like/comment counts in parallel to avoid N+1.
Expo App (
apps/expo/)Feature module —
features/feed/follows the existing pattern (hooks, components, screens, utils):PostCard— images (horizontal scroll for multi-photo), like/comment counts, owner deleteCommentItem— supports nested reply indentation, per-comment likesFeedScreen,CreatePostScreen(multi-select gallery + camera, parallel R2 uploads viaPromise.all),PostDetailScreen(inline comment input with keyboard-avoiding view)buildPostImageUrl,formatAuthorName,formatRelativeDate) to avoid duplication across componentsNavigation
feedtab in bottom nav, gated byfeatureFlags.enableFeed = trueFeedTileadded to the home dashboard(tabs)/feed/index,feed/[id],feed/createi18n — all feed strings added to
en.json;navigation.feedkey added.Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.