From 5dcbb73f60e0a383c41d81c6304a9c3dc95a0b71 Mon Sep 17 00:00:00 2001 From: thoran Date: Sat, 9 May 2026 05:16:42 +1000 Subject: [PATCH] feat(community): backend for feed, posts, comments, likes, leaderboard - 7 new endpoints under /api/community - DB-first with bundled seed + in-memory fallback so frontend never blocks - Standardized response envelope (matches support PR) - 17 hermetic Jest tests - Optional migration: migrations/create_community_tables.sql Backs frontend tasks M-60 through M-63 (FeedScreen, PostDetailScreen, CreatePostScreen, LeaderboardScreen). --- HANDOFF_COMMUNITY_BACKEND.md | 78 ++++ controller/communityController.js | 218 +++++++++++ controller/supportData/communitySeed.js | 175 +++++++++ docs/COMMUNITY_BACKEND.md | 117 ++++++ migrations/create_community_tables.sql | 70 ++++ routes/community.js | 44 +++ routes/index.js | 3 + services/communityService.js | 457 ++++++++++++++++++++++++ test/community.controller.test.js | 191 ++++++++++ validators/communityValidator.js | 58 +++ 10 files changed, 1411 insertions(+) create mode 100644 HANDOFF_COMMUNITY_BACKEND.md create mode 100644 controller/communityController.js create mode 100644 controller/supportData/communitySeed.js create mode 100644 docs/COMMUNITY_BACKEND.md create mode 100644 migrations/create_community_tables.sql create mode 100644 routes/community.js create mode 100644 services/communityService.js create mode 100644 test/community.controller.test.js create mode 100644 validators/communityValidator.js diff --git a/HANDOFF_COMMUNITY_BACKEND.md b/HANDOFF_COMMUNITY_BACKEND.md new file mode 100644 index 0000000..f69a0af --- /dev/null +++ b/HANDOFF_COMMUNITY_BACKEND.md @@ -0,0 +1,78 @@ +# Handoff — Community backend PR + +All files are in your working tree. To open a clean PR: + +```bash +cd /Users/thorancherukuru/Nutri + +# Get on a fresh branch off the latest master +git fetch origin +git checkout master +git pull +git checkout -b feat/community-backend + +# Stage the new + modified files +git add migrations/create_community_tables.sql \ + controller/communityController.js \ + controller/supportData/communitySeed.js \ + services/communityService.js \ + validators/communityValidator.js \ + routes/community.js \ + routes/index.js \ + test/community.controller.test.js \ + docs/COMMUNITY_BACKEND.md \ + HANDOFF_COMMUNITY_BACKEND.md + +git status # confirm + +# Commit +git commit -m "feat(community): backend for feed, posts, comments, likes, leaderboard + +- 7 new endpoints under /api/community +- DB-first with bundled seed + in-memory fallback so frontend never blocks +- Standardized response envelope (matches support PR) +- 17 hermetic Jest tests +- Optional migration: migrations/create_community_tables.sql + +Backs frontend tasks M-60 through M-63 (FeedScreen, PostDetailScreen, +CreatePostScreen, LeaderboardScreen)." + +# Push the branch (NOT to master) +git push -u origin feat/community-backend +``` + +Then on GitHub: + +1. Open a new PR from `thoran123:feat/community-backend` → `Gopher-Industries:master`. +2. Title: `BE30 : feat(community): feed, posts, comments, likes, leaderboard` +3. Description: paste the contents of `docs/COMMUNITY_BACKEND.md`. +4. Request review from Tien and Vedant. + +## Important — don't push to master directly this time + +Last time you ran `git push origin HEAD:master` which bypassed PR +review. The command above (`git push -u origin feat/community-backend`) +pushes the **branch** so GitHub's PR flow handles the merge. + +## Verify locally before pushing + +```bash +npm install +npx jest test/community.controller.test.js \ + test/contactus.controller.test.js \ + test/userFeedback.controller.test.js \ + test/faq.controller.test.js \ + test/healthTools.controller.test.js +# expected: 32 passing +``` + +## Tell the frontend team + +Send Tien (or whoever owns the frontend tasks) this: + +> Backend for community screens (M-60 → M-63) is in PR #XXX. Endpoints +> live under `/api/community`. Image upload is the existing +> `POST /api/upload` (returns `fileUrl`). Endpoints work today against +> bundled seed data, so you can build against them before the +> Supabase migration runs. See `docs/COMMUNITY_BACKEND.md` for the full +> contract and frontend-screen mapping. diff --git a/controller/communityController.js b/controller/communityController.js new file mode 100644 index 0000000..d40f680 --- /dev/null +++ b/controller/communityController.js @@ -0,0 +1,218 @@ +/** + * controller/communityController.js + * + * HTTP layer for community endpoints. Uses the standardized support + * response envelope (utils/supportResponse) so the frontend gets a + * consistent shape across the support + community surface. + */ + +const { validationResult } = require('express-validator'); +const support = require('../utils/supportResponse'); +const logger = require('../utils/logger'); +const community = require('../services/communityService'); + +function _userFrom(req) { + return req.user || {}; +} + +// ============================================================ +// GET /api/community/posts +// ============================================================ +async function listFeed(req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) return support.sendValidationError(res, errors.array()); + + try { + const result = await community.listPosts({ + page: req.query.page, + pageSize: req.query.pageSize, + }); + return support.sendSuccess( + res, + { + items: result.items, + page: result.page, + pageSize: result.pageSize, + totalCount: result.totalCount, + hasMore: result.hasMore, + }, + { meta: { source: result.source, generatedAt: new Date().toISOString() } } + ); + } catch (error) { + logger.error('communityController.listFeed failed', { error: error.message }); + return support.sendError(res, 500, 'Unable to load feed right now.', 'COMMUNITY_FEED_FAILED'); + } +} + +// ============================================================ +// GET /api/community/posts/:postId +// ============================================================ +async function getPost(req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) return support.sendValidationError(res, errors.array()); + + try { + const { post, source } = await community.getPost(req.params.postId); + if (!post) { + return support.sendError(res, 404, 'Post not found.', 'COMMUNITY_POST_NOT_FOUND'); + } + return support.sendSuccess(res, { post }, { meta: { source } }); + } catch (error) { + logger.error('communityController.getPost failed', { error: error.message }); + return support.sendError(res, 500, 'Unable to load post.', 'COMMUNITY_POST_FAILED'); + } +} + +// ============================================================ +// POST /api/community/posts (auth) +// ============================================================ +async function createPost(req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) return support.sendValidationError(res, errors.array()); + + const user = _userFrom(req); + if (!user.userId) { + return support.sendError(res, 401, 'Authentication required.', 'AUTH_REQUIRED'); + } + + try { + const { post, persistedTo } = await community.createPost({ + userId: user.userId, + userName: user.name || user.email, + content: req.body.content, + imageUrl: req.body.imageUrl, + }); + return support.sendCreated( + res, + { post }, + { meta: { persistedTo, message: 'Post created.' } } + ); + } catch (error) { + if (error.statusCode === 400) { + return support.sendError(res, 400, error.message, error.code || 'VALIDATION_ERROR'); + } + logger.error('communityController.createPost failed', { error: error.message }); + return support.sendError(res, 500, 'Unable to create post.', 'COMMUNITY_POST_CREATE_FAILED'); + } +} + +// ============================================================ +// POST /api/community/posts/:postId/like (auth) -- toggle +// ============================================================ +async function toggleLike(req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) return support.sendValidationError(res, errors.array()); + + const user = _userFrom(req); + if (!user.userId) { + return support.sendError(res, 401, 'Authentication required.', 'AUTH_REQUIRED'); + } + + try { + const { liked, persistedTo } = await community.toggleLike({ + postId: req.params.postId, + userId: user.userId, + }); + + // Return the post so the client can sync its optimistic state. + const { post } = await community.getPost(req.params.postId); + return support.sendSuccess( + res, + { liked, post }, + { meta: { persistedTo } } + ); + } catch (error) { + logger.error('communityController.toggleLike failed', { error: error.message }); + return support.sendError(res, 500, 'Unable to update like.', 'COMMUNITY_LIKE_FAILED'); + } +} + +// ============================================================ +// GET /api/community/posts/:postId/comments +// ============================================================ +async function listComments(req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) return support.sendValidationError(res, errors.array()); + + try { + const { items, source } = await community.listComments(req.params.postId); + return support.sendSuccess( + res, + { items }, + { meta: { source, count: items.length } } + ); + } catch (error) { + logger.error('communityController.listComments failed', { error: error.message }); + return support.sendError(res, 500, 'Unable to load comments.', 'COMMUNITY_COMMENTS_FAILED'); + } +} + +// ============================================================ +// POST /api/community/posts/:postId/comments (auth) +// ============================================================ +async function createComment(req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) return support.sendValidationError(res, errors.array()); + + const user = _userFrom(req); + if (!user.userId) { + return support.sendError(res, 401, 'Authentication required.', 'AUTH_REQUIRED'); + } + + try { + const { comment, persistedTo } = await community.createComment({ + postId: req.params.postId, + userId: user.userId, + userName: user.name || user.email, + content: req.body.content, + }); + return support.sendCreated(res, { comment }, { meta: { persistedTo } }); + } catch (error) { + if (error.statusCode === 400) { + return support.sendError(res, 400, error.message, error.code || 'VALIDATION_ERROR'); + } + logger.error('communityController.createComment failed', { error: error.message }); + return support.sendError(res, 500, 'Unable to add comment.', 'COMMUNITY_COMMENT_FAILED'); + } +} + +// ============================================================ +// GET /api/community/leaderboard +// ============================================================ +async function leaderboard(req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) return support.sendValidationError(res, errors.array()); + + try { + // currentUserId from auth if present, else from query (optional). + const currentUserId = req.user?.userId ?? req.query.currentUserId; + + const result = await community.getLeaderboard({ + timeframe: req.query.timeframe, + currentUserId, + limit: req.query.limit, + }); + return support.sendSuccess( + res, + { + timeframe: result.timeframe, + items: result.items, + currentUserRank: result.currentUserRank, + }, + { meta: { source: result.source } } + ); + } catch (error) { + logger.error('communityController.leaderboard failed', { error: error.message }); + return support.sendError(res, 500, 'Unable to load leaderboard.', 'COMMUNITY_LEADERBOARD_FAILED'); + } +} + +module.exports = { + listFeed, + getPost, + createPost, + toggleLike, + listComments, + createComment, + leaderboard, +}; diff --git a/controller/supportData/communitySeed.js b/controller/supportData/communitySeed.js new file mode 100644 index 0000000..b50fb6e --- /dev/null +++ b/controller/supportData/communitySeed.js @@ -0,0 +1,175 @@ +/** + * Bundled fallback content for the community endpoints. + * Used when the Supabase tables are missing/empty so the frontend never + * sees a blank state. + */ + +const NOW = Date.now(); +const minutesAgo = (m) => new Date(NOW - m * 60_000).toISOString(); +const hoursAgo = (h) => new Date(NOW - h * 3_600_000).toISOString(); +const daysAgo = (d) => new Date(NOW - d * 86_400_000).toISOString(); + +const POSTS = [ + { + id: 'seed-1', + user_id: 1001, + user_name: 'Jamie Chen', + user_avatar_url: null, + content: + "Just tried the new air-fried tofu recipe from the app — crispy outside, custardy inside. 10/10. Anyone else trying out the recommendation engine for high-protein dinners?", + image_url: null, + like_count: 24, + comment_count: 3, + created_at: hoursAgo(2), + }, + { + id: 'seed-2', + user_id: 1002, + user_name: 'Priya Raman', + user_avatar_url: null, + content: + 'Hit my weekly water intake target every day this week thanks to the reminder bot 💧. Tiny win but I\'ll take it.', + image_url: null, + like_count: 41, + comment_count: 2, + created_at: hoursAgo(8), + }, + { + id: 'seed-3', + user_id: 1003, + user_name: 'Marcus Williams', + user_avatar_url: null, + content: + 'Meal planner generated my whole week in under a minute. Saved me at least an hour on Sunday prep. Pro tip: lock your favourites first then regenerate the rest.', + image_url: null, + like_count: 67, + comment_count: 5, + created_at: daysAgo(1), + }, + { + id: 'seed-4', + user_id: 1004, + user_name: 'Sofia Alvarez', + user_avatar_url: null, + content: + 'Question for the community — anyone using the barcode scanner for kids\' snacks? Curious how accurate it is for store-brand items.', + image_url: null, + like_count: 12, + comment_count: 8, + created_at: daysAgo(2), + }, + { + id: 'seed-5', + user_id: 1005, + user_name: 'Devon Park', + user_avatar_url: null, + content: + 'BMI calculator gave me a number, the article it linked to gave me context. Appreciate the app not just dumping a metric on you.', + image_url: null, + like_count: 33, + comment_count: 1, + created_at: daysAgo(3), + }, +]; + +const COMMENTS = { + 'seed-1': [ + { + id: 'seed-c-1', + post_id: 'seed-1', + user_id: 1010, + user_name: 'Aiko Tanaka', + content: 'Adding this to my list for tonight. Did you marinate it first?', + created_at: hoursAgo(1), + }, + { + id: 'seed-c-2', + post_id: 'seed-1', + user_id: 1011, + user_name: 'Liam OConnor', + content: 'Cornstarch coating is the move 👌', + created_at: minutesAgo(45), + }, + { + id: 'seed-c-3', + post_id: 'seed-1', + user_id: 1012, + user_name: 'Nour Haddad', + content: 'Anyone tried it with tempeh instead?', + created_at: minutesAgo(20), + }, + ], + 'seed-2': [ + { + id: 'seed-c-4', + post_id: 'seed-2', + user_id: 1013, + user_name: 'Rohan Kapoor', + content: 'Same! The hourly nudge made the difference.', + created_at: hoursAgo(6), + }, + { + id: 'seed-c-5', + post_id: 'seed-2', + user_id: 1014, + user_name: 'Maya Brooks', + content: 'Way to go!', + created_at: hoursAgo(7), + }, + ], + 'seed-3': [ + { + id: 'seed-c-6', + post_id: 'seed-3', + user_id: 1015, + user_name: 'Jung-min Lee', + content: "I've been doing the same — locking the salmon bowl every week 😄", + created_at: hoursAgo(20), + }, + ], +}; + +const LEADERBOARD = { + weekly: [ + { rank: 1, user_id: 1003, user_name: 'Marcus Williams', user_avatar_url: null, points: 420 }, + { rank: 2, user_id: 1002, user_name: 'Priya Raman', user_avatar_url: null, points: 385 }, + { rank: 3, user_id: 1001, user_name: 'Jamie Chen', user_avatar_url: null, points: 340 }, + { rank: 4, user_id: 1015, user_name: 'Jung-min Lee', user_avatar_url: null, points: 310 }, + { rank: 5, user_id: 1010, user_name: 'Aiko Tanaka', user_avatar_url: null, points: 285 }, + { rank: 6, user_id: 1004, user_name: 'Sofia Alvarez', user_avatar_url: null, points: 260 }, + { rank: 7, user_id: 1013, user_name: 'Rohan Kapoor', user_avatar_url: null, points: 240 }, + { rank: 8, user_id: 1005, user_name: 'Devon Park', user_avatar_url: null, points: 220 }, + { rank: 9, user_id: 1014, user_name: 'Maya Brooks', user_avatar_url: null, points: 195 }, + { rank: 10, user_id: 1011, user_name: 'Liam OConnor', user_avatar_url: null, points: 180 }, + ], + monthly: [ + { rank: 1, user_id: 1003, user_name: 'Marcus Williams', user_avatar_url: null, points: 1820 }, + { rank: 2, user_id: 1001, user_name: 'Jamie Chen', user_avatar_url: null, points: 1640 }, + { rank: 3, user_id: 1002, user_name: 'Priya Raman', user_avatar_url: null, points: 1495 }, + { rank: 4, user_id: 1015, user_name: 'Jung-min Lee', user_avatar_url: null, points: 1310 }, + { rank: 5, user_id: 1004, user_name: 'Sofia Alvarez', user_avatar_url: null, points: 1180 }, + { rank: 6, user_id: 1013, user_name: 'Rohan Kapoor', user_avatar_url: null, points: 1050 }, + { rank: 7, user_id: 1010, user_name: 'Aiko Tanaka', user_avatar_url: null, points: 985 }, + { rank: 8, user_id: 1005, user_name: 'Devon Park', user_avatar_url: null, points: 920 }, + { rank: 9, user_id: 1014, user_name: 'Maya Brooks', user_avatar_url: null, points: 875 }, + { rank: 10, user_id: 1011, user_name: 'Liam OConnor', user_avatar_url: null, points: 800 }, + ], + all_time: [ + { rank: 1, user_id: 1003, user_name: 'Marcus Williams', user_avatar_url: null, points: 12450 }, + { rank: 2, user_id: 1001, user_name: 'Jamie Chen', user_avatar_url: null, points: 11200 }, + { rank: 3, user_id: 1002, user_name: 'Priya Raman', user_avatar_url: null, points: 9870 }, + { rank: 4, user_id: 1004, user_name: 'Sofia Alvarez', user_avatar_url: null, points: 8650 }, + { rank: 5, user_id: 1015, user_name: 'Jung-min Lee', user_avatar_url: null, points: 7990 }, + { rank: 6, user_id: 1010, user_name: 'Aiko Tanaka', user_avatar_url: null, points: 7340 }, + { rank: 7, user_id: 1013, user_name: 'Rohan Kapoor', user_avatar_url: null, points: 6810 }, + { rank: 8, user_id: 1005, user_name: 'Devon Park', user_avatar_url: null, points: 6420 }, + { rank: 9, user_id: 1011, user_name: 'Liam OConnor', user_avatar_url: null, points: 5980 }, + { rank: 10, user_id: 1014, user_name: 'Maya Brooks', user_avatar_url: null, points: 5450 }, + ], +}; + +module.exports = { + POSTS, + COMMENTS, + LEADERBOARD, +}; diff --git a/docs/COMMUNITY_BACKEND.md b/docs/COMMUNITY_BACKEND.md new file mode 100644 index 0000000..fc17935 --- /dev/null +++ b/docs/COMMUNITY_BACKEND.md @@ -0,0 +1,117 @@ +# Community Backend — feed, posts, comments, likes, leaderboard + +Branch: `feat/community-backend` + +## Why + +The mobile community screens (`FeedScreen`, `PostDetailScreen`, +`CreatePostScreen`, `LeaderboardScreen` — frontend tasks M-60 through +M-63) need backend endpoints that don't exist yet. This change adds the +nine endpoints they need, with sensible defaults so the UI never sees a +blank state. + +## What this PR adds + +### New endpoints + +| Method | Path | Auth | Notes | +| -----: | ---- | ---- | ----- | +| `GET` | `/api/community/posts` | none | Paginated feed (`?page=&pageSize=`) | +| `GET` | `/api/community/posts/:postId` | none | Post detail | +| `POST` | `/api/community/posts` | Bearer | Create post | +| `POST` | `/api/community/posts/:postId/like` | Bearer | Toggle like | +| `GET` | `/api/community/posts/:postId/comments` | none | List comments | +| `POST` | `/api/community/posts/:postId/comments` | Bearer | Create comment | +| `GET` | `/api/community/leaderboard` | none | `?timeframe=weekly\|monthly\|all_time&limit=¤tUserId=` | + +Image upload is handled by the existing `POST /api/upload` (returns a +`fileUrl` you pass into `imageUrl` on `POST /api/community/posts`). + +### Backing pieces + +- `migrations/create_community_tables.sql` — `posts`, `comments`, + `post_likes`, `user_points`, `user_points_events` (run in Supabase + SQL editor when ready; endpoints work with or without the migration). +- `services/communityService.js` — DB-first, falls back to bundled seed + + an in-memory store so frontend dev never blocks on DB provisioning. +- `controller/communityController.js` — HTTP handlers on the + standardized support response envelope. +- `controller/supportData/communitySeed.js` — sample posts, comments, + and weekly/monthly/all-time leaderboards. +- `routes/community.js` — read-public, write-authenticated. +- `validators/communityValidator.js` — content min/max, pagination + bounds, leaderboard timeframe enum. +- `test/community.controller.test.js` — 17 hermetic tests. + +## Response envelope + +Same shape as the support PR (#252): + +```json +{ "success": true, "data": { ... }, "meta": { ... } } +``` + +```json +{ "success": false, "error": { "message": "...", "code": "...", "details": { "fields": [...] } } } +``` + +## Frontend mapping + +| Frontend screen | Endpoints used | +| --------------- | -------------- | +| `FeedScreen` (M-60) | `GET /posts` (paginated), `POST /posts/:id/like` (optimistic) | +| `PostDetailScreen` (M-61) | `GET /posts/:id`, `GET /posts/:id/comments`, `POST /posts/:id/comments`, `POST /posts/:id/like` | +| `CreatePostScreen` (M-62) | `POST /api/upload` → `POST /posts` | +| `LeaderboardScreen` (M-63) | `GET /leaderboard?timeframe=` | + +## Acceptance criteria coverage + +- ✅ Feed pagination — `page`/`pageSize` query params, `hasMore` in response +- ✅ Like is optimistic — endpoint returns the updated post so client can sync +- ✅ Pull-to-refresh — frontend just re-fetches `?page=1` +- ✅ Comments append — `POST /posts/:id/comments` returns the new comment +- ✅ Create post validates content min length (10 chars) +- ✅ Image upload separate from post creation (existing `POST /api/upload`) +- ✅ Leaderboard timeframe filter — `weekly` / `monthly` / `all_time` +- ✅ Current user rank pinned — `currentUserRank` field in response + +## Behaviour with no DB + +If the Supabase tables don't exist yet, every read returns seed content +and every write goes into an in-memory store that resets on server +restart. The response includes `meta.source: 'seed'` / +`meta.persistedTo: 'memory'` so this state is observable. + +When the migration is applied, the endpoints automatically switch to +the DB on the next request — no code change, no redeploy. + +## Tests + +``` +Test Suites: 1 passed +Tests: 17 passed +``` + +Mocks `dbConnection.js` and the auth middleware to keep the suite +hermetic. Run alongside the support suites to confirm no regressions: + +```bash +npx jest test/community.controller.test.js \ + test/contactus.controller.test.js \ + test/userFeedback.controller.test.js \ + test/faq.controller.test.js \ + test/healthTools.controller.test.js +``` + +(32 / 32 passing.) + +## Risks / rollout + +- **Low blast radius.** Brand-new API surface under `/api/community`. + Nothing else calls it. +- **Migration is opt-in.** The endpoints work without it, so the PR can + ship before DBA review. Run the migration whenever you're ready. +- **Auth uses the existing `authenticateToken` middleware** — no new + auth code paths. +- **No image processing.** Image uploads go through the existing + `/api/upload` route, which already validates mime type and size. diff --git a/migrations/create_community_tables.sql b/migrations/create_community_tables.sql new file mode 100644 index 0000000..406f6bf --- /dev/null +++ b/migrations/create_community_tables.sql @@ -0,0 +1,70 @@ +-- Migration: community tables (posts, comments, likes, user_points) +-- Run this in the Supabase SQL editor before deploying the community endpoints. +-- The endpoints fall back to bundled seed data if these tables are missing, +-- so the migration can be applied at any time without downtime. + +-- ========================================================================= +-- posts +-- ========================================================================= +CREATE TABLE IF NOT EXISTS posts ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + content TEXT NOT NULL CHECK (char_length(content) BETWEEN 10 AND 5000), + image_url TEXT, + like_count INTEGER NOT NULL DEFAULT 0, + comment_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts (user_id); + +-- ========================================================================= +-- comments +-- ========================================================================= +CREATE TABLE IF NOT EXISTS comments ( + id BIGSERIAL PRIMARY KEY, + post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL, + content TEXT NOT NULL CHECK (char_length(content) BETWEEN 1 AND 1000), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments (post_id, created_at); +CREATE INDEX IF NOT EXISTS idx_comments_user_id ON comments (user_id); + +-- ========================================================================= +-- post_likes (one row per (post, user) pair = "user liked this post") +-- ========================================================================= +CREATE TABLE IF NOT EXISTS post_likes ( + id BIGSERIAL PRIMARY KEY, + post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (post_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_post_likes_post_id ON post_likes (post_id); +CREATE INDEX IF NOT EXISTS idx_post_likes_user_id ON post_likes (user_id); + +-- ========================================================================= +-- user_points (denormalised running totals for the leaderboard) +-- ========================================================================= +CREATE TABLE IF NOT EXISTS user_points ( + user_id BIGINT PRIMARY KEY, + points INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ========================================================================= +-- user_points_events (append-only log used by the leaderboard timeframe filter) +-- ========================================================================= +CREATE TABLE IF NOT EXISTS user_points_events ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + points INTEGER NOT NULL, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_points_events_user_created + ON user_points_events (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_points_events_created + ON user_points_events (created_at DESC); diff --git a/routes/community.js b/routes/community.js new file mode 100644 index 0000000..bf80c30 --- /dev/null +++ b/routes/community.js @@ -0,0 +1,44 @@ +/** + * routes/community.js + * + * Public reads, authenticated writes. + * + * GET /api/community/posts list feed (paginated) + * GET /api/community/posts/:postId post detail + * POST /api/community/posts create post [auth] + * POST /api/community/posts/:postId/like toggle like [auth] + * GET /api/community/posts/:postId/comments list comments + * POST /api/community/posts/:postId/comments create comment [auth] + * GET /api/community/leaderboard leaderboard + */ + +const express = require('express'); +const router = express.Router(); + +const controller = require('../controller/communityController'); +const { authenticateToken } = require('../middleware/authenticateToken'); +const { + createPostValidator, + createCommentValidator, + postIdParamValidator, + feedQueryValidator, + leaderboardQueryValidator, +} = require('../validators/communityValidator'); + +// Reads +router.get('/posts', feedQueryValidator, controller.listFeed); +router.get('/posts/:postId', postIdParamValidator, controller.getPost); +router.get('/posts/:postId/comments', postIdParamValidator, controller.listComments); +router.get('/leaderboard', leaderboardQueryValidator, controller.leaderboard); + +// Writes (auth required) +router.post('/posts', authenticateToken, createPostValidator, controller.createPost); +router.post('/posts/:postId/like', authenticateToken, postIdParamValidator, controller.toggleLike); +router.post( + '/posts/:postId/comments', + authenticateToken, + createCommentValidator, + controller.createComment +); + +module.exports = router; diff --git a/routes/index.js b/routes/index.js index 1d60e5f..6236c71 100644 --- a/routes/index.js +++ b/routes/index.js @@ -11,6 +11,9 @@ module.exports = app => { app.use("/api/faq", require('./faq')); app.use('/api/health-tools', require('./healthTools')); + // community surface (feed, posts, comments, likes, leaderboard) + app.use('/api/community', require('./community')); + app.use("/api/recipe", require('./recipe')); app.use("/api/appointments", require('./appointment')); app.use("/api/imageClassification", require('./imageClassification')); diff --git a/services/communityService.js b/services/communityService.js new file mode 100644 index 0000000..f1ed245 --- /dev/null +++ b/services/communityService.js @@ -0,0 +1,457 @@ +/** + * services/communityService.js + * + * Backing service for the community routes (feed, post detail, comments, + * likes, leaderboard). + * + * Strategy: + * - Each function tries the Supabase tables defined in + * migrations/create_community_tables.sql. + * - On any DB error or empty result we fall back to bundled seed data + * from controller/supportData/communitySeed.js so the frontend never + * sees a blank screen while content is being provisioned. + * - Writes (createPost, createComment, toggleLike) require a userId + * and persist to the DB. If the DB is unavailable we record the + * write in an in-memory store and surface `meta.persistedTo: 'memory'` + * so the client can warn during development. + */ + +const supabase = require('../dbConnection.js'); +const logger = require('../utils/logger'); +const seed = require('../controller/supportData/communitySeed'); + +// --- in-memory fallback store ------------------------------------------- +const memoryStore = { + posts: [], + commentsByPost: new Map(), + likesByPost: new Map(), // post_id -> Set + nextPostId: 9_000_000, + nextCommentId: 9_500_000, +}; + +function nowIso() { + return new Date().toISOString(); +} + +function safeChain() { + if (!supabase || typeof supabase.from !== 'function') return null; + return supabase; +} + +function shapePost(row) { + return { + id: row.id, + userId: row.user_id, + userName: row.user_name || row.userName || `User ${row.user_id}`, + userAvatarUrl: row.user_avatar_url || null, + content: row.content, + imageUrl: row.image_url || null, + likeCount: row.like_count ?? 0, + commentCount: row.comment_count ?? 0, + createdAt: row.created_at, + }; +} + +function shapeComment(row) { + return { + id: row.id, + postId: row.post_id, + userId: row.user_id, + userName: row.user_name || row.userName || `User ${row.user_id}`, + content: row.content, + createdAt: row.created_at, + }; +} + +// ========================================================================= +// FEED +// ========================================================================= + +/** + * Paginated feed (newest first). + * @param {{ page?:number, pageSize?:number }} opts + */ +async function listPosts({ page = 1, pageSize = 10 } = {}) { + const safePage = Math.max(1, Number(page) || 1); + const safeSize = Math.min(50, Math.max(1, Number(pageSize) || 10)); + const from = (safePage - 1) * safeSize; + const to = from + safeSize - 1; + + // Try DB + try { + const sb = safeChain(); + if (sb) { + const { data, error, count } = await sb + .from('posts') + .select('*', { count: 'exact' }) + .order('created_at', { ascending: false }) + .range(from, to); + + if (!error && data && data.length > 0) { + // Add in-memory writes that aren't in the DB yet. + return { + source: 'db', + page: safePage, + pageSize: safeSize, + totalCount: count ?? data.length, + hasMore: (count ?? data.length) > to + 1, + items: data.map(shapePost), + }; + } + } + } catch (err) { + logger.warn('communityService.listPosts: db error, falling back to seed', { error: err.message }); + } + + // Combined fallback: any in-memory writes + seed posts + const combined = [...memoryStore.posts, ...seed.POSTS] + .map(shapePost) + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + const slice = combined.slice(from, from + safeSize); + return { + source: combined.length > seed.POSTS.length ? 'memory+seed' : 'seed', + page: safePage, + pageSize: safeSize, + totalCount: combined.length, + hasMore: from + safeSize < combined.length, + items: slice, + }; +} + +// ========================================================================= +// POST DETAIL +// ========================================================================= + +async function getPost(postId) { + if (!postId) throw new Error('postId required'); + + try { + const sb = safeChain(); + if (sb) { + const { data, error } = await sb + .from('posts') + .select('*') + .eq('id', postId) + .maybeSingle(); + + if (!error && data) { + return { source: 'db', post: shapePost(data) }; + } + } + } catch (err) { + logger.warn('communityService.getPost: db error, falling back', { error: err.message, postId }); + } + + // Memory then seed + const inMem = memoryStore.posts.find((p) => String(p.id) === String(postId)); + if (inMem) return { source: 'memory', post: shapePost(inMem) }; + + const seedPost = seed.POSTS.find((p) => String(p.id) === String(postId)); + if (seedPost) return { source: 'seed', post: shapePost(seedPost) }; + + return { source: null, post: null }; +} + +// ========================================================================= +// CREATE POST +// ========================================================================= + +async function createPost({ userId, userName, content, imageUrl }) { + if (!userId) throw new Error('userId required'); + if (!content || content.trim().length < 10) { + const err = new Error('Content must be at least 10 characters'); + err.statusCode = 400; + err.code = 'CONTENT_TOO_SHORT'; + throw err; + } + + const newRow = { + user_id: userId, + user_name: userName || `User ${userId}`, + content: content.trim(), + image_url: imageUrl || null, + like_count: 0, + comment_count: 0, + created_at: nowIso(), + updated_at: nowIso(), + }; + + try { + const sb = safeChain(); + if (sb) { + const { data, error } = await sb + .from('posts') + .insert(newRow) + .select() + .maybeSingle(); + if (!error && data) { + return { persistedTo: 'db', post: shapePost(data) }; + } + } + } catch (err) { + logger.warn('communityService.createPost: db insert failed, using memory', { error: err.message }); + } + + // Memory fallback + const memRow = { ...newRow, id: ++memoryStore.nextPostId }; + memoryStore.posts.unshift(memRow); + return { persistedTo: 'memory', post: shapePost(memRow) }; +} + +// ========================================================================= +// LIKES +// ========================================================================= + +/** + * Toggle the like state for (postId, userId). Returns the new like count + * and whether the user now likes the post. + */ +async function toggleLike({ postId, userId }) { + if (!postId) throw new Error('postId required'); + if (!userId) throw new Error('userId required'); + + try { + const sb = safeChain(); + if (sb) { + // Check existing like + const { data: existing, error: lookupError } = await sb + .from('post_likes') + .select('id') + .eq('post_id', postId) + .eq('user_id', userId) + .maybeSingle(); + + if (!lookupError) { + if (existing) { + // Unlike + await sb.from('post_likes').delete().eq('id', existing.id); + // Decrement counter best-effort + await _adjustLikeCount(sb, postId, -1); + return { liked: false, persistedTo: 'db' }; + } + // Like + await sb.from('post_likes').insert({ post_id: postId, user_id: userId }); + await _adjustLikeCount(sb, postId, +1); + return { liked: true, persistedTo: 'db' }; + } + } + } catch (err) { + logger.warn('communityService.toggleLike: db failed, using memory', { error: err.message, postId, userId }); + } + + // Memory fallback + const set = memoryStore.likesByPost.get(String(postId)) || new Set(); + const had = set.has(userId); + if (had) set.delete(userId); + else set.add(userId); + memoryStore.likesByPost.set(String(postId), set); + return { liked: !had, persistedTo: 'memory' }; +} + +async function _adjustLikeCount(sb, postId, delta) { + try { + const { data, error } = await sb.from('posts').select('like_count').eq('id', postId).maybeSingle(); + if (error || !data) return; + const next = Math.max(0, (data.like_count ?? 0) + delta); + await sb.from('posts').update({ like_count: next }).eq('id', postId); + } catch (err) { + logger.warn('communityService._adjustLikeCount failed', { error: err.message }); + } +} + +// ========================================================================= +// COMMENTS +// ========================================================================= + +async function listComments(postId) { + if (!postId) throw new Error('postId required'); + + try { + const sb = safeChain(); + if (sb) { + const { data, error } = await sb + .from('comments') + .select('*') + .eq('post_id', postId) + .order('created_at', { ascending: true }); + if (!error && data && data.length > 0) { + return { source: 'db', items: data.map(shapeComment) }; + } + } + } catch (err) { + logger.warn('communityService.listComments: db error, falling back', { error: err.message, postId }); + } + + const memList = memoryStore.commentsByPost.get(String(postId)) || []; + const seedList = seed.COMMENTS[postId] || []; + const items = [...seedList, ...memList].map(shapeComment); + return { source: items.length > seedList.length ? 'memory+seed' : 'seed', items }; +} + +async function createComment({ postId, userId, userName, content }) { + if (!postId) throw new Error('postId required'); + if (!userId) throw new Error('userId required'); + if (!content || content.trim().length < 1) { + const err = new Error('Comment cannot be empty'); + err.statusCode = 400; + err.code = 'COMMENT_EMPTY'; + throw err; + } + + const newRow = { + post_id: postId, + user_id: userId, + user_name: userName || `User ${userId}`, + content: content.trim(), + created_at: nowIso(), + }; + + try { + const sb = safeChain(); + if (sb) { + const { data, error } = await sb + .from('comments') + .insert(newRow) + .select() + .maybeSingle(); + if (!error && data) { + // Best-effort comment_count bump + try { + const { data: post } = await sb + .from('posts') + .select('comment_count') + .eq('id', postId) + .maybeSingle(); + if (post) { + await sb + .from('posts') + .update({ comment_count: (post.comment_count ?? 0) + 1 }) + .eq('id', postId); + } + } catch (_) {} + return { persistedTo: 'db', comment: shapeComment(data) }; + } + } + } catch (err) { + logger.warn('communityService.createComment: db insert failed, using memory', { error: err.message }); + } + + const memRow = { ...newRow, id: ++memoryStore.nextCommentId }; + const list = memoryStore.commentsByPost.get(String(postId)) || []; + list.push(memRow); + memoryStore.commentsByPost.set(String(postId), list); + return { persistedTo: 'memory', comment: shapeComment(memRow) }; +} + +// ========================================================================= +// LEADERBOARD +// ========================================================================= + +const TIMEFRAMES = new Set(['weekly', 'monthly', 'all_time']); + +function _timeframeBoundary(timeframe) { + const now = Date.now(); + if (timeframe === 'weekly') return new Date(now - 7 * 86_400_000).toISOString(); + if (timeframe === 'monthly') return new Date(now - 30 * 86_400_000).toISOString(); + return null; // all_time +} + +async function getLeaderboard({ timeframe = 'weekly', currentUserId, limit = 10 } = {}) { + const tf = TIMEFRAMES.has(timeframe) ? timeframe : 'weekly'; + const safeLimit = Math.min(50, Math.max(1, Number(limit) || 10)); + + let items = null; + let source = 'seed'; + + try { + const sb = safeChain(); + if (sb) { + const boundary = _timeframeBoundary(tf); + if (boundary) { + // Aggregate from points events within window + const { data, error } = await sb + .from('user_points_events') + .select('user_id, points') + .gte('created_at', boundary); + if (!error && data && data.length > 0) { + const totals = new Map(); + data.forEach((e) => { + totals.set(e.user_id, (totals.get(e.user_id) || 0) + (e.points || 0)); + }); + items = Array.from(totals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, safeLimit) + .map(([user_id, points], idx) => ({ + rank: idx + 1, + user_id, + user_name: `User ${user_id}`, + user_avatar_url: null, + points, + })); + source = 'db'; + } + } else { + // all_time -> totals table + const { data, error } = await sb + .from('user_points') + .select('user_id, points') + .order('points', { ascending: false }) + .limit(safeLimit); + if (!error && data && data.length > 0) { + items = data.map((row, idx) => ({ + rank: idx + 1, + user_id: row.user_id, + user_name: `User ${row.user_id}`, + user_avatar_url: null, + points: row.points, + })); + source = 'db'; + } + } + } + } catch (err) { + logger.warn('communityService.getLeaderboard: db error, falling back to seed', { error: err.message }); + } + + if (!items) { + items = (seed.LEADERBOARD[tf] || []).slice(0, safeLimit); + } + + // Compute current-user rank if not in visible list + let currentUserRank = null; + if (currentUserId) { + const inVisible = items.find((r) => String(r.user_id) === String(currentUserId)); + if (inVisible) { + currentUserRank = inVisible; + } else { + // search the wider seed for an approximation + const wider = seed.LEADERBOARD[tf] || []; + const found = wider.find((r) => String(r.user_id) === String(currentUserId)); + currentUserRank = found || null; + } + } + + return { timeframe: tf, source, items, currentUserRank }; +} + +// ========================================================================= +// Test helpers +// ========================================================================= + +function _resetMemory() { + memoryStore.posts.length = 0; + memoryStore.commentsByPost.clear(); + memoryStore.likesByPost.clear(); +} + +module.exports = { + listPosts, + getPost, + createPost, + toggleLike, + listComments, + createComment, + getLeaderboard, + _resetMemory, + TIMEFRAMES: Array.from(TIMEFRAMES), +}; diff --git a/test/community.controller.test.js b/test/community.controller.test.js new file mode 100644 index 0000000..0909940 --- /dev/null +++ b/test/community.controller.test.js @@ -0,0 +1,191 @@ +/** + * Community endpoint tests. + * + * Mounts the router on a fresh express app per test. The auth middleware + * is mocked to inject a fake user so we don't need real JWTs. + */ + +// Mock dbConnection to avoid the SUPABASE_URL guard / process.exit. +// Returning an object whose `from` is undefined makes safeChain() -> null, +// which forces the service into the seed/memory fallback path. Perfect +// for hermetic tests that exercise the contract, not the DB. +jest.mock('../dbConnection.js', () => ({})); + +jest.mock('../middleware/authenticateToken', () => ({ + authenticateToken: (req, _res, next) => { + req.user = { userId: 9001, email: 'tester@example.com', role: 'user' }; + next(); + }, +})); + +const express = require('express'); +const request = require('supertest'); + +const router = require('../routes/community'); +const community = require('../services/communityService'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/api/community', router); + return app; +} + +beforeEach(() => { + community._resetMemory(); +}); + +describe('GET /api/community/posts', () => { + test('returns paginated feed (seed fallback when DB empty)', async () => { + const res = await request(makeApp()).get('/api/community/posts'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data.items)).toBe(true); + expect(res.body.data.items.length).toBeGreaterThan(0); + expect(res.body.data.page).toBe(1); + expect(res.body.data.pageSize).toBe(10); + expect(typeof res.body.data.hasMore).toBe('boolean'); + }); + + test('honours page and pageSize', async () => { + const res = await request(makeApp()).get('/api/community/posts?page=1&pageSize=2'); + expect(res.status).toBe(200); + expect(res.body.data.pageSize).toBe(2); + expect(res.body.data.items.length).toBeLessThanOrEqual(2); + }); + + test('rejects invalid page', async () => { + const res = await request(makeApp()).get('/api/community/posts?page=-1'); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); +}); + +describe('GET /api/community/posts/:postId', () => { + test('returns a seed post by id', async () => { + const res = await request(makeApp()).get('/api/community/posts/seed-1'); + expect(res.status).toBe(200); + expect(res.body.data.post.id).toBe('seed-1'); + expect(res.body.data.post.content).toBeTruthy(); + }); + + test('returns 404 for unknown post', async () => { + const res = await request(makeApp()).get('/api/community/posts/does-not-exist'); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('COMMUNITY_POST_NOT_FOUND'); + }); +}); + +describe('POST /api/community/posts', () => { + test('rejects content shorter than 10 chars', async () => { + const res = await request(makeApp()) + .post('/api/community/posts') + .send({ content: 'short' }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + test('creates a post with valid content', async () => { + const res = await request(makeApp()) + .post('/api/community/posts') + .send({ content: 'A perfectly valid post about meal planning.' }); + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.post.content).toMatch(/meal planning/); + expect(res.body.data.post.userId).toBe(9001); + expect(['db', 'memory']).toContain(res.body.meta.persistedTo); + }); + + test('post appears at top of feed after creation', async () => { + await request(makeApp()) + .post('/api/community/posts') + .send({ content: 'Brand new post that should land first in the feed.' }); + + const feed = await request(makeApp()).get('/api/community/posts'); + expect(feed.body.data.items[0].content).toMatch(/Brand new post/); + }); +}); + +describe('POST /api/community/posts/:postId/like', () => { + test('toggles like state and returns updated post', async () => { + const created = await request(makeApp()) + .post('/api/community/posts') + .send({ content: 'Post we will like and unlike for testing.' }); + const postId = created.body.data.post.id; + + const liked = await request(makeApp()).post(`/api/community/posts/${postId}/like`); + expect(liked.status).toBe(200); + expect(liked.body.data.liked).toBe(true); + + const unliked = await request(makeApp()).post(`/api/community/posts/${postId}/like`); + expect(unliked.status).toBe(200); + expect(unliked.body.data.liked).toBe(false); + }); +}); + +describe('comments', () => { + test('lists seed comments for seed-1', async () => { + const res = await request(makeApp()).get('/api/community/posts/seed-1/comments'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.data.items)).toBe(true); + expect(res.body.data.items.length).toBeGreaterThan(0); + }); + + test('rejects empty comment body', async () => { + const res = await request(makeApp()) + .post('/api/community/posts/seed-1/comments') + .send({ content: '' }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + test('appends a new comment', async () => { + const res = await request(makeApp()) + .post('/api/community/posts/seed-1/comments') + .send({ content: 'Thanks for posting this!' }); + expect(res.status).toBe(201); + expect(res.body.data.comment.content).toBe('Thanks for posting this!'); + expect(res.body.data.comment.userId).toBe(9001); + + const list = await request(makeApp()).get('/api/community/posts/seed-1/comments'); + const last = list.body.data.items[list.body.data.items.length - 1]; + expect(last.content).toBe('Thanks for posting this!'); + }); +}); + +describe('GET /api/community/leaderboard', () => { + test('default (weekly) returns ranked items', async () => { + const res = await request(makeApp()).get('/api/community/leaderboard'); + expect(res.status).toBe(200); + expect(res.body.data.timeframe).toBe('weekly'); + expect(res.body.data.items.length).toBeGreaterThan(0); + expect(res.body.data.items[0].rank).toBe(1); + }); + + test('honours timeframe', async () => { + const res = await request(makeApp()).get('/api/community/leaderboard?timeframe=all_time'); + expect(res.body.data.timeframe).toBe('all_time'); + }); + + test('rejects invalid timeframe', async () => { + const res = await request(makeApp()).get('/api/community/leaderboard?timeframe=lifetime'); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + test('returns currentUserRank when user is outside visible window', async () => { + // The auth mock injects userId=9001 which is NOT in the seed leaderboard, + // so currentUserRank should be null (no-op) — exercise the code path. + const res = await request(makeApp()).get('/api/community/leaderboard'); + expect(res.status).toBe(200); + // currentUserRank may be null when the user has no points yet — that's expected. + expect(res.body.data).toHaveProperty('currentUserRank'); + }); + + test('returns currentUserRank for a known seed user via query param', async () => { + const res = await request(makeApp()).get('/api/community/leaderboard?currentUserId=1003'); + expect(res.status).toBe(200); + // 1003 is rank 1 in the weekly seed; should be returned in items already. + expect(res.body.data.items.find((i) => i.user_id === 1003)).toBeTruthy(); + }); +}); diff --git a/validators/communityValidator.js b/validators/communityValidator.js new file mode 100644 index 0000000..a41f9a5 --- /dev/null +++ b/validators/communityValidator.js @@ -0,0 +1,58 @@ +const { body, param, query } = require('express-validator'); + +const createPostValidator = [ + body('content') + .trim() + .notEmpty() + .withMessage('Content is required') + .isLength({ min: 10, max: 5000 }) + .withMessage('Content must be between 10 and 5000 characters'), + body('imageUrl') + .optional({ nullable: true }) + .isURL() + .withMessage('imageUrl must be a valid URL'), +]; + +const createCommentValidator = [ + param('postId').notEmpty().withMessage('postId is required'), + body('content') + .trim() + .notEmpty() + .withMessage('Comment cannot be empty') + .isLength({ min: 1, max: 1000 }) + .withMessage('Comment must be between 1 and 1000 characters'), +]; + +const postIdParamValidator = [ + param('postId').notEmpty().withMessage('postId is required'), +]; + +const feedQueryValidator = [ + query('page') + .optional() + .isInt({ min: 1 }) + .withMessage('page must be a positive integer'), + query('pageSize') + .optional() + .isInt({ min: 1, max: 50 }) + .withMessage('pageSize must be between 1 and 50'), +]; + +const leaderboardQueryValidator = [ + query('timeframe') + .optional() + .isIn(['weekly', 'monthly', 'all_time']) + .withMessage('timeframe must be one of: weekly, monthly, all_time'), + query('limit') + .optional() + .isInt({ min: 1, max: 50 }) + .withMessage('limit must be between 1 and 50'), +]; + +module.exports = { + createPostValidator, + createCommentValidator, + postIdParamValidator, + feedQueryValidator, + leaderboardQueryValidator, +};