diff --git a/controller/communityController.js b/controller/communityController.js new file mode 100644 index 0000000..7027cb8 --- /dev/null +++ b/controller/communityController.js @@ -0,0 +1,514 @@ +const { validationResult } = require('express-validator'); +const communityPostsModel = require('../model/communityPosts'); +const communityCommentsModel = require('../model/communityComments'); +const communityLikesModel = require('../model/communityLikes'); + +// Create a new community post +const createPost = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { user_id, content, category, tags = [], image_url = null } = req.body; + + const postData = { + user_id, + content, + category, + tags, + image_url, + likes_count: 0, + comments_count: 0, + shares_count: 0 + }; + + const newPost = await communityPostsModel.createCommunityPost(postData); + + return res.status(201).json({ + statusCode: 201, + message: 'Post created successfully', + data: newPost + }); + + } catch (error) { + console.error('createPost error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Get all community posts with filters +const getPosts = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { + page = 1, + limit = 10, + category = 'all', + search = null, + user_id = null, + sort_by = 'created_at', + sort_order = 'desc' + } = req.query; + + const filters = { + page: parseInt(page), + limit: parseInt(limit), + category: category === 'all' ? null : category, + search, + user_id: user_id ? parseInt(user_id) : null, + sort_by, + sort_order + }; + + const posts = await communityPostsModel.getCommunityPosts(filters); + + return res.status(200).json({ + statusCode: 200, + message: 'Posts retrieved successfully', + data: posts, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: posts.length + } + }); + + } catch (error) { + console.error('getPosts error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Get a single post by ID +const getPost = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { id } = req.params; + const post = await communityPostsModel.getCommunityPostById(parseInt(id)); + + if (!post) { + return res.status(404).json({ + statusCode: 404, + error: 'Post not found' + }); + } + + return res.status(200).json({ + statusCode: 200, + message: 'Post retrieved successfully', + data: post + }); + + } catch (error) { + console.error('getPost error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Update a post +const updatePost = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { id } = req.params; + const updateData = req.body; + + const updatedPost = await communityPostsModel.updateCommunityPost(parseInt(id), updateData); + + if (!updatedPost) { + return res.status(404).json({ + statusCode: 404, + error: 'Post not found' + }); + } + + return res.status(200).json({ + statusCode: 200, + message: 'Post updated successfully', + data: updatedPost + }); + + } catch (error) { + console.error('updatePost error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Delete a post +const deletePost = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { id } = req.params; + await communityPostsModel.deleteCommunityPost(parseInt(id)); + + return res.status(200).json({ + statusCode: 200, + message: 'Post deleted successfully' + }); + + } catch (error) { + console.error('deletePost error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Get community statistics +const getCommunityStats = async (req, res) => { + try { + const stats = await communityPostsModel.getCommunityStats(); + + return res.status(200).json({ + statusCode: 200, + message: 'Community statistics retrieved successfully', + data: stats + }); + + } catch (error) { + console.error('getCommunityStats error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Create a comment +const createComment = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { user_id, post_id, content } = req.body; + + const commentData = { + user_id, + post_id, + content + }; + + const newComment = await communityCommentsModel.createCommunityComment(commentData); + + // Update post comments count + await communityPostsModel.updateCommunityPost(post_id, { + comments_count: await communityCommentsModel.getCommentCount(post_id) + }); + + return res.status(201).json({ + statusCode: 201, + message: 'Comment created successfully', + data: newComment + }); + + } catch (error) { + console.error('createComment error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Get comments for a post +const getComments = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { postId } = req.params; + const { page = 1, limit = 20 } = req.query; + + const comments = await communityCommentsModel.getCommentsByPostId( + parseInt(postId), + parseInt(page), + parseInt(limit) + ); + + return res.status(200).json({ + statusCode: 200, + message: 'Comments retrieved successfully', + data: comments, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: comments.length + } + }); + + } catch (error) { + console.error('getComments error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Update a comment +const updateComment = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { id } = req.params; + const { content } = req.body; + + const updatedComment = await communityCommentsModel.updateCommunityComment(parseInt(id), { content }); + + if (!updatedComment) { + return res.status(404).json({ + statusCode: 404, + error: 'Comment not found' + }); + } + + return res.status(200).json({ + statusCode: 200, + message: 'Comment updated successfully', + data: updatedComment + }); + + } catch (error) { + console.error('updateComment error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Delete a comment +const deleteComment = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { id } = req.params; + await communityCommentsModel.deleteCommunityComment(parseInt(id)); + + return res.status(200).json({ + statusCode: 200, + message: 'Comment deleted successfully' + }); + + } catch (error) { + console.error('deleteComment error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Like a post +const likePost = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { user_id, post_id } = req.body; + + const result = await communityLikesModel.likePost(user_id, post_id); + + if (result.already_liked) { + return res.status(200).json({ + statusCode: 200, + message: 'Post already liked', + data: result + }); + } + + // Update post likes count + const likeCount = await communityLikesModel.getLikeCount(post_id); + await communityPostsModel.updateCommunityPost(post_id, { + likes_count: likeCount + }); + + return res.status(200).json({ + statusCode: 200, + message: 'Post liked successfully', + data: result + }); + + } catch (error) { + console.error('likePost error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Unlike a post +const unlikePost = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { user_id, post_id } = req.body; + + await communityLikesModel.unlikePost(user_id, post_id); + + // Update post likes count + const likeCount = await communityLikesModel.getLikeCount(post_id); + await communityPostsModel.updateCommunityPost(post_id, { + likes_count: likeCount + }); + + return res.status(200).json({ + statusCode: 200, + message: 'Post unliked successfully' + }); + + } catch (error) { + console.error('unlikePost error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +// Get user's liked posts +const getUserLikedPosts = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + statusCode: 400, + error: 'Validation failed', + details: errors.array() + }); + } + + const { userId } = req.params; + const { page = 1, limit = 20 } = req.query; + + const likedPosts = await communityLikesModel.getUserLikedPosts( + parseInt(userId), + parseInt(page), + parseInt(limit) + ); + + return res.status(200).json({ + statusCode: 200, + message: 'Liked posts retrieved successfully', + data: likedPosts, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: likedPosts.length + } + }); + + } catch (error) { + console.error('getUserLikedPosts error:', error); + return res.status(500).json({ + statusCode: 500, + error: 'Internal server error' + }); + } +}; + +module.exports = { + createPost, + getPosts, + getPost, + updatePost, + deletePost, + getCommunityStats, + createComment, + getComments, + updateComment, + deleteComment, + likePost, + unlikePost, + getUserLikedPosts +}; diff --git a/controller/communityImageController.js b/controller/communityImageController.js new file mode 100644 index 0000000..5c6a849 --- /dev/null +++ b/controller/communityImageController.js @@ -0,0 +1,77 @@ +require('dotenv').config(); +const multer = require('multer'); +const path = require('path'); + +// Configure multer for memory storage +const storage = multer.memoryStorage(); +const upload = multer({ + storage: storage, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit + }, + fileFilter: (req, file, cb) => { + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Only image files (JPEG, PNG, GIF, WebP) are allowed'), false); + } + } +}).single('image'); + +// Upload community post image +const uploadCommunityImage = async (req, res) => { + upload(req, res, async (err) => { + if (err) { + return res.status(400).json({ + success: false, + error: err.message + }); + } + + if (!req.file) { + return res.status(400).json({ + success: false, + error: 'No image file provided' + }); + } + + try { + const { user_id } = req.body; + + if (!user_id) { + return res.status(400).json({ + success: false, + error: 'User ID is required' + }); + } + + // For now, let's use a simpler approach - convert image to base64 + // This avoids Supabase storage permission issues + const base64Image = req.file.buffer.toString('base64'); + const mimeType = req.file.mimetype; + const dataUrl = `data:${mimeType};base64,${base64Image}`; + + return res.status(200).json({ + success: true, + message: 'Image processed successfully', + data: { + image_url: dataUrl, + file_name: req.file.originalname, + file_size: req.file.size + } + }); + + } catch (error) { + console.error('Community image upload error:', error); + return res.status(500).json({ + success: false, + error: 'Internal server error during image upload' + }); + } + }); +}; + +module.exports = { + uploadCommunityImage +}; diff --git a/controller/communityShareController.js b/controller/communityShareController.js new file mode 100644 index 0000000..fbf485a --- /dev/null +++ b/controller/communityShareController.js @@ -0,0 +1,113 @@ +const { supabase } = require('../database/supabase'); + +// Share a community post +const sharePost = async (req, res) => { + try { + const { user_id, post_id, share_platform = 'copy_link' } = req.body; + + if (!user_id || !post_id) { + return res.status(400).json({ + success: false, + error: 'User ID and Post ID are required' + }); + } + + // Check if post exists + const { data: post, error: postError } = await supabase + .from('community_posts') + .select('id') + .eq('id', post_id) + .single(); + + if (postError || !post) { + return res.status(404).json({ + success: false, + error: 'Post not found' + }); + } + + // Create share record + const { data: shareData, error: shareError } = await supabase + .from('community_shares') + .insert([{ + user_id: parseInt(user_id), + post_id: parseInt(post_id), + share_platform: share_platform + }]) + .select(); + + if (shareError) { + console.error('Error creating share:', shareError); + return res.status(500).json({ + success: false, + error: 'Failed to record share', + details: shareError.message + }); + } + + // Update post shares count + const { data: sharesCount, error: countError } = await supabase + .from('community_shares') + .select('id', { count: 'exact' }) + .eq('post_id', post_id); + + if (!countError && sharesCount) { + await supabase + .from('community_posts') + .update({ shares_count: sharesCount.length }) + .eq('id', post_id); + } + + return res.status(201).json({ + success: true, + message: 'Post shared successfully', + data: shareData[0] + }); + + } catch (error) { + console.error('Share post error:', error); + return res.status(500).json({ + success: false, + error: 'Internal server error' + }); + } +}; + +// Get share count for a post +const getShareCount = async (req, res) => { + try { + const { postId } = req.params; + + const { data: shares, error } = await supabase + .from('community_shares') + .select('id', { count: 'exact' }) + .eq('post_id', postId); + + if (error) { + console.error('Error getting share count:', error); + return res.status(500).json({ + success: false, + error: 'Failed to get share count' + }); + } + + return res.status(200).json({ + success: true, + data: { + share_count: shares ? shares.length : 0 + } + }); + + } catch (error) { + console.error('Get share count error:', error); + return res.status(500).json({ + success: false, + error: 'Internal server error' + }); + } +}; + +module.exports = { + sharePost, + getShareCount +}; diff --git a/controller/extendedUserPreferencesController.js b/controller/extendedUserPreferencesController.js new file mode 100644 index 0000000..d3fa461 --- /dev/null +++ b/controller/extendedUserPreferencesController.js @@ -0,0 +1,278 @@ +const fetchUserPreferences = require("../model/fetchUserPreferences"); +const updateUserPreferences = require("../model/updateUserPreferences"); +const supabase = require("../dbConnection.js"); + +/** + * Get user preferences including notification preferences + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +const getUserPreferences = async (req, res) => { + try { + const userId = req.user.userId; + if (!userId) { + return res.status(400).json({ + success: false, + error: "User ID is required" + }); + } + + // Get existing user preferences + const userPreferences = await fetchUserPreferences(userId); + + // Get notification preferences from users table + const { data: userData, error: userError } = await supabase + .from('users') + .select('notification_preferences, language, theme, font_size') + .eq('id', userId) + .single(); + + if (userError) { + console.error('Error fetching user settings:', userError); + } + + // Parse notification preferences + let notificationPreferences = {}; + if (userData && userData.notification_preferences) { + try { + notificationPreferences = JSON.parse(userData.notification_preferences); + } catch (parseError) { + console.warn('Failed to parse notification preferences:', parseError); + notificationPreferences = { + mealReminders: true, + waterReminders: true, + healthTips: true, + weeklyReports: false, + systemUpdates: true + }; + } + } else { + // Default notification preferences + notificationPreferences = { + mealReminders: true, + waterReminders: true, + healthTips: true, + weeklyReports: false, + systemUpdates: true + }; + } + + // Combine all preferences + const response = { + success: true, + data: { + ...userPreferences, + notification_preferences: notificationPreferences, + language: userData?.language || 'en', + theme: userData?.theme || 'light', + font_size: userData?.font_size || '16px' + } + }; + + return res.status(200).json(response); + } catch (error) { + console.error('Error fetching user preferences:', error); + return res.status(500).json({ + success: false, + error: "Internal server error" + }); + } +}; + +/** + * Update user preferences including notification preferences + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +const postUserPreferences = async (req, res) => { + try { + const { user } = req.body; + const userId = user.userId; + + if (!userId) { + return res.status(400).json({ + success: false, + error: "User ID is required" + }); + } + + // Update existing user preferences (dietary requirements, allergies, etc.) + await updateUserPreferences(userId, req.body); + + // Update notification preferences and other settings in users table + const updateData = { + updated_at: new Date().toISOString() + }; + + // Handle notification preferences + if (req.body.notification_preferences) { + updateData.notification_preferences = JSON.stringify(req.body.notification_preferences); + } + + // Handle other user settings + if (req.body.language !== undefined) { + updateData.language = req.body.language; + } + if (req.body.theme !== undefined) { + updateData.theme = req.body.theme; + } + if (req.body.font_size !== undefined) { + updateData.font_size = req.body.font_size; + } + + // Update users table if there are settings to update + if (Object.keys(updateData).length > 1) { // More than just updated_at + const { error: updateError } = await supabase + .from('users') + .update(updateData) + .eq('id', userId); + + if (updateError) { + console.error('Error updating user settings:', updateError); + return res.status(500).json({ + success: false, + error: "Failed to update user settings" + }); + } + } + + return res.status(200).json({ + success: true, + message: "User preferences updated successfully" + }); + } catch (error) { + console.error('Error updating user preferences:', error); + return res.status(500).json({ + success: false, + error: "Internal server error" + }); + } +}; + +/** + * Get only notification preferences + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +const getNotificationPreferences = async (req, res) => { + try { + const userId = req.user.userId; + + if (!userId) { + return res.status(400).json({ + success: false, + error: "User ID is required" + }); + } + + const { data: userData, error } = await supabase + .from('users') + .select('notification_preferences') + .eq('id', userId) + .single(); + + if (error) { + console.error('Error fetching notification preferences:', error); + return res.status(500).json({ + success: false, + error: "Failed to fetch notification preferences" + }); + } + + let notificationPreferences = {}; + if (userData && userData.notification_preferences) { + try { + notificationPreferences = JSON.parse(userData.notification_preferences); + } catch (parseError) { + console.warn('Failed to parse notification preferences:', parseError); + notificationPreferences = { + mealReminders: true, + waterReminders: true, + healthTips: true, + weeklyReports: false, + systemUpdates: true + }; + } + } else { + // Default notification preferences + notificationPreferences = { + mealReminders: true, + waterReminders: true, + healthTips: true, + weeklyReports: false, + systemUpdates: true + }; + } + + return res.status(200).json({ + success: true, + data: notificationPreferences + }); + } catch (error) { + console.error('Error fetching notification preferences:', error); + return res.status(500).json({ + success: false, + error: "Internal server error" + }); + } +}; + +/** + * Update only notification preferences + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +const updateNotificationPreferences = async (req, res) => { + try { + const userId = req.user.userId; + const { notification_preferences } = req.body; + + if (!userId) { + return res.status(400).json({ + success: false, + error: "User ID is required" + }); + } + + if (!notification_preferences) { + return res.status(400).json({ + success: false, + error: "Notification preferences are required" + }); + } + + const { error } = await supabase + .from('users') + .update({ + notification_preferences: JSON.stringify(notification_preferences), + updated_at: new Date().toISOString() + }) + .eq('id', userId); + + if (error) { + console.error('Error updating notification preferences:', error); + return res.status(500).json({ + success: false, + error: "Failed to update notification preferences" + }); + } + + return res.status(200).json({ + success: true, + message: "Notification preferences updated successfully" + }); + } catch (error) { + console.error('Error updating notification preferences:', error); + return res.status(500).json({ + success: false, + error: "Internal server error" + }); + } +}; + +module.exports = { + getUserPreferences, + postUserPreferences, + getNotificationPreferences, + updateNotificationPreferences +}; diff --git a/database/community_tables.sql b/database/community_tables.sql new file mode 100644 index 0000000..dbcc931 --- /dev/null +++ b/database/community_tables.sql @@ -0,0 +1,187 @@ +-- Community Database Tables for Nutrihelp API +-- This file contains the SQL schema for community features + +-- Community Posts Table +CREATE TABLE IF NOT EXISTS community_posts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + content TEXT NOT NULL CHECK (length(content) >= 10 AND length(content) <= 2000), + category VARCHAR(50) NOT NULL CHECK (category IN ( + 'weight-loss', + 'fitness', + 'dietary-restrictions', + 'meal-prep', + 'nutrition-tips', + 'success-story', + 'recipe-share', + 'motivation' + )), + tags TEXT[] DEFAULT '{}', + image_url TEXT, + likes_count INTEGER DEFAULT 0 CHECK (likes_count >= 0), + comments_count INTEGER DEFAULT 0 CHECK (comments_count >= 0), + shares_count INTEGER DEFAULT 0 CHECK (shares_count >= 0), + is_published BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Community Comments Table +CREATE TABLE IF NOT EXISTS community_comments ( + id SERIAL PRIMARY KEY, + post_id INTEGER NOT NULL REFERENCES community_posts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + content TEXT NOT NULL CHECK (length(content) >= 1 AND length(content) <= 500), + parent_comment_id INTEGER REFERENCES community_comments(id) ON DELETE CASCADE, + is_edited BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Community Likes Table +CREATE TABLE IF NOT EXISTS community_likes ( + id SERIAL PRIMARY KEY, + post_id INTEGER NOT NULL REFERENCES community_posts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(post_id, user_id) +); + +-- Community Bookmarks Table (for future use) +CREATE TABLE IF NOT EXISTS community_bookmarks ( + id SERIAL PRIMARY KEY, + post_id INTEGER NOT NULL REFERENCES community_posts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(post_id, user_id) +); + +-- Community Shares Table (for future use) +CREATE TABLE IF NOT EXISTS community_shares ( + id SERIAL PRIMARY KEY, + post_id INTEGER NOT NULL REFERENCES community_posts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + share_platform VARCHAR(50), -- 'facebook', 'twitter', 'linkedin', 'copy_link', etc. + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for better performance +CREATE INDEX IF NOT EXISTS idx_community_posts_user_id ON community_posts(user_id); +CREATE INDEX IF NOT EXISTS idx_community_posts_category ON community_posts(category); +CREATE INDEX IF NOT EXISTS idx_community_posts_created_at ON community_posts(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_community_posts_likes_count ON community_posts(likes_count DESC); +CREATE INDEX IF NOT EXISTS idx_community_posts_tags ON community_posts USING GIN(tags); +CREATE INDEX IF NOT EXISTS idx_community_posts_content_search ON community_posts USING GIN(to_tsvector('english', content)); + +CREATE INDEX IF NOT EXISTS idx_community_comments_post_id ON community_comments(post_id); +CREATE INDEX IF NOT EXISTS idx_community_comments_user_id ON community_comments(user_id); +CREATE INDEX IF NOT EXISTS idx_community_comments_created_at ON community_comments(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_community_likes_post_id ON community_likes(post_id); +CREATE INDEX IF NOT EXISTS idx_community_likes_user_id ON community_likes(user_id); + +CREATE INDEX IF NOT EXISTS idx_community_bookmarks_post_id ON community_bookmarks(post_id); +CREATE INDEX IF NOT EXISTS idx_community_bookmarks_user_id ON community_bookmarks(user_id); + +CREATE INDEX IF NOT EXISTS idx_community_shares_post_id ON community_shares(post_id); +CREATE INDEX IF NOT EXISTS idx_community_shares_user_id ON community_shares(user_id); + +-- Triggers to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_community_posts_updated_at + BEFORE UPDATE ON community_posts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_community_comments_updated_at + BEFORE UPDATE ON community_comments + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Function to update post counts when comments are added/deleted +CREATE OR REPLACE FUNCTION update_post_comment_count() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE community_posts + SET comments_count = comments_count + 1 + WHERE id = NEW.post_id; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + UPDATE community_posts + SET comments_count = comments_count - 1 + WHERE id = OLD.post_id; + RETURN OLD; + END IF; + RETURN NULL; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_post_comment_count_trigger + AFTER INSERT OR DELETE ON community_comments + FOR EACH ROW EXECUTE FUNCTION update_post_comment_count(); + +-- Function to update post likes count when likes are added/deleted +CREATE OR REPLACE FUNCTION update_post_likes_count() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE community_posts + SET likes_count = likes_count + 1 + WHERE id = NEW.post_id; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + UPDATE community_posts + SET likes_count = likes_count - 1 + WHERE id = OLD.post_id; + RETURN OLD; + END IF; + RETURN NULL; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_post_likes_count_trigger + AFTER INSERT OR DELETE ON community_likes + FOR EACH ROW EXECUTE FUNCTION update_post_likes_count(); + +-- Insert sample data for testing +INSERT INTO community_posts (user_id, content, category, tags, image_url) VALUES +(1, 'Just completed my first 30-day healthy eating challenge! 🎉 The key was meal prepping on Sundays and having healthy snacks ready. Lost 8 pounds and feel so much more energetic. Anyone else doing similar challenges?', 'weight-loss', ARRAY['weight-loss', 'meal-prep', 'healthy-eating'], 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=600&h=400&fit=crop'), +(2, 'Found this amazing protein smoothie recipe that''s perfect for post-workout recovery. 25g protein, low sugar, and tastes amazing! Recipe in comments 👇', 'fitness', ARRAY['protein', 'smoothie', 'post-workout'], 'https://images.unsplash.com/photo-1553530666-ba11a7da3888?w=600&h=400&fit=crop'), +(3, 'Struggling with gluten sensitivity? Here are my top 5 gluten-free alternatives that actually taste good! Quinoa pasta, almond flour, and more. What''s your favorite gluten-free substitute?', 'dietary-restrictions', ARRAY['gluten-free', 'dietary-restrictions', 'healthy-alternatives'], 'https://images.unsplash.com/photo-1517686469429-8bdb88b9f907?w=600&h=400&fit=crop'); + +-- Insert sample comments +INSERT INTO community_comments (post_id, user_id, content) VALUES +(1, 2, 'Congratulations! That''s amazing progress. I''m starting my own 30-day challenge next week. Any tips for meal prep?'), +(1, 3, 'Great job! I''ve been doing meal prep for 6 months now and it''s been a game changer.'), +(2, 1, 'This looks delicious! Can you share the recipe?'), +(2, 3, 'I make something similar with spinach and banana. So good!'), +(3, 1, 'I love quinoa pasta! Have you tried chickpea pasta? It''s also great.'), +(3, 2, 'Almond flour is my go-to for baking. Works great in pancakes too!'); + +-- Insert sample likes +INSERT INTO community_likes (post_id, user_id) VALUES +(1, 2), (1, 3), (1, 4), +(2, 1), (2, 3), (2, 4), (2, 5), +(3, 1), (3, 2), (3, 4), (3, 5), (3, 6); + +-- Insert sample bookmarks +INSERT INTO community_bookmarks (post_id, user_id) VALUES +(1, 2), (1, 3), +(2, 1), (2, 4), +(3, 1), (3, 2), (3, 5); + +-- Insert sample shares +INSERT INTO community_shares (post_id, user_id, share_platform) VALUES +(1, 2, 'facebook'), +(1, 3, 'twitter'), +(2, 1, 'copy_link'), +(2, 4, 'linkedin'), +(3, 1, 'facebook'), +(3, 2, 'twitter'), +(3, 5, 'copy_link'); diff --git a/database/supabase_community_setup.sql b/database/supabase_community_setup.sql new file mode 100644 index 0000000..3047892 --- /dev/null +++ b/database/supabase_community_setup.sql @@ -0,0 +1,188 @@ +-- Community Database Tables for Supabase +-- Execute this SQL in your Supabase SQL Editor + +-- 1. Community Posts Table +CREATE TABLE IF NOT EXISTS community_posts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + content TEXT NOT NULL CHECK (length(content) >= 10 AND length(content) <= 2000), + category VARCHAR(50) NOT NULL CHECK (category IN ( + 'weight-loss', + 'fitness', + 'dietary-restrictions', + 'meal-prep', + 'nutrition-tips', + 'success-story', + 'recipe-share', + 'motivation' + )), + tags TEXT[] DEFAULT '{}', + image_url TEXT, + likes_count INTEGER DEFAULT 0 CHECK (likes_count >= 0), + comments_count INTEGER DEFAULT 0 CHECK (comments_count >= 0), + shares_count INTEGER DEFAULT 0 CHECK (shares_count >= 0), + is_published BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- 2. Community Comments Table +CREATE TABLE IF NOT EXISTS community_comments ( + id SERIAL PRIMARY KEY, + post_id INTEGER NOT NULL REFERENCES community_posts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + content TEXT NOT NULL CHECK (length(content) >= 1 AND length(content) <= 500), + parent_comment_id INTEGER REFERENCES community_comments(id) ON DELETE CASCADE, + is_edited BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- 3. Community Likes Table +CREATE TABLE IF NOT EXISTS community_likes ( + id SERIAL PRIMARY KEY, + post_id INTEGER NOT NULL REFERENCES community_posts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(post_id, user_id) +); + +-- 4. Community Bookmarks Table (for future use) +CREATE TABLE IF NOT EXISTS community_bookmarks ( + id SERIAL PRIMARY KEY, + post_id INTEGER NOT NULL REFERENCES community_posts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(post_id, user_id) +); + +-- 5. Community Shares Table (for future use) +CREATE TABLE IF NOT EXISTS community_shares ( + id SERIAL PRIMARY KEY, + post_id INTEGER NOT NULL REFERENCES community_posts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + share_platform VARCHAR(50), -- 'facebook', 'twitter', 'linkedin', 'copy_link', etc. + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create Indexes for better performance +CREATE INDEX IF NOT EXISTS idx_community_posts_user_id ON community_posts(user_id); +CREATE INDEX IF NOT EXISTS idx_community_posts_category ON community_posts(category); +CREATE INDEX IF NOT EXISTS idx_community_posts_created_at ON community_posts(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_community_posts_likes_count ON community_posts(likes_count DESC); +CREATE INDEX IF NOT EXISTS idx_community_posts_tags ON community_posts USING GIN(tags); +CREATE INDEX IF NOT EXISTS idx_community_posts_content_search ON community_posts USING GIN(to_tsvector('english', content)); + +CREATE INDEX IF NOT EXISTS idx_community_comments_post_id ON community_comments(post_id); +CREATE INDEX IF NOT EXISTS idx_community_comments_user_id ON community_comments(user_id); +CREATE INDEX IF NOT EXISTS idx_community_comments_created_at ON community_comments(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_community_likes_post_id ON community_likes(post_id); +CREATE INDEX IF NOT EXISTS idx_community_likes_user_id ON community_likes(user_id); + +CREATE INDEX IF NOT EXISTS idx_community_bookmarks_post_id ON community_bookmarks(post_id); +CREATE INDEX IF NOT EXISTS idx_community_bookmarks_user_id ON community_bookmarks(user_id); + +CREATE INDEX IF NOT EXISTS idx_community_shares_post_id ON community_shares(post_id); +CREATE INDEX IF NOT EXISTS idx_community_shares_user_id ON community_shares(user_id); + +-- Create function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create triggers +CREATE TRIGGER update_community_posts_updated_at + BEFORE UPDATE ON community_posts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_community_comments_updated_at + BEFORE UPDATE ON community_comments + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Function to update post counts when comments are added/deleted +CREATE OR REPLACE FUNCTION update_post_comment_count() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE community_posts + SET comments_count = comments_count + 1 + WHERE id = NEW.post_id; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + UPDATE community_posts + SET comments_count = comments_count - 1 + WHERE id = OLD.post_id; + RETURN OLD; + END IF; + RETURN NULL; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_post_comment_count_trigger + AFTER INSERT OR DELETE ON community_comments + FOR EACH ROW EXECUTE FUNCTION update_post_comment_count(); + +-- Function to update post likes count when likes are added/deleted +CREATE OR REPLACE FUNCTION update_post_likes_count() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE community_posts + SET likes_count = likes_count + 1 + WHERE id = NEW.post_id; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + UPDATE community_posts + SET likes_count = likes_count - 1 + WHERE id = OLD.post_id; + RETURN OLD; + END IF; + RETURN NULL; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_post_likes_count_trigger + AFTER INSERT OR DELETE ON community_likes + FOR EACH ROW EXECUTE FUNCTION update_post_likes_count(); + +-- Insert sample data for testing +INSERT INTO community_posts (user_id, content, category, tags, image_url) VALUES +(1, 'Just completed my first 30-day healthy eating challenge! 🎉 The key was meal prepping on Sundays and having healthy snacks ready. Lost 8 pounds and feel so much more energetic. Anyone else doing similar challenges?', 'weight-loss', ARRAY['weight-loss', 'meal-prep', 'healthy-eating'], 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=600&h=400&fit=crop'), +(2, 'Found this amazing protein smoothie recipe that''s perfect for post-workout recovery. 25g protein, low sugar, and tastes amazing! Recipe in comments 👇', 'fitness', ARRAY['protein', 'smoothie', 'post-workout'], 'https://images.unsplash.com/photo-1553530666-ba11a7da3888?w=600&h=400&fit=crop'), +(3, 'Struggling with gluten sensitivity? Here are my top 5 gluten-free alternatives that actually taste good! Quinoa pasta, almond flour, and more. What''s your favorite gluten-free substitute?', 'dietary-restrictions', ARRAY['gluten-free', 'dietary-restrictions', 'healthy-alternatives'], 'https://images.unsplash.com/photo-1517686469429-8bdb88b9f907?w=600&h=400&fit=crop'); + +-- Insert sample comments +INSERT INTO community_comments (post_id, user_id, content) VALUES +(1, 2, 'Congratulations! That''s amazing progress. I''m starting my own 30-day challenge next week. Any tips for meal prep?'), +(1, 3, 'Great job! I''ve been doing meal prep for 6 months now and it''s been a game changer.'), +(2, 1, 'This looks delicious! Can you share the recipe?'), +(2, 3, 'I make something similar with spinach and banana. So good!'), +(3, 1, 'I love quinoa pasta! Have you tried chickpea pasta? It''s also great.'), +(3, 2, 'Almond flour is my go-to for baking. Works great in pancakes too!'); + +-- Insert sample likes +INSERT INTO community_likes (post_id, user_id) VALUES +(1, 2), (1, 3), (1, 4), +(2, 1), (2, 3), (2, 4), (2, 5), +(3, 1), (3, 2), (3, 4), (3, 5), (3, 6); + +-- Insert sample bookmarks +INSERT INTO community_bookmarks (post_id, user_id) VALUES +(1, 2), (1, 3), +(2, 1), (2, 4), +(3, 1), (3, 2), (3, 5); + +-- Insert sample shares +INSERT INTO community_shares (post_id, user_id, share_platform) VALUES +(1, 2, 'facebook'), +(1, 3, 'twitter'), +(2, 1, 'copy_link'), +(2, 4, 'linkedin'), +(3, 1, 'facebook'), +(3, 2, 'twitter'), +(3, 5, 'copy_link'); diff --git a/index.yaml b/index.yaml index 341fe0f..f5f6b5d 100644 --- a/index.yaml +++ b/index.yaml @@ -11,6 +11,8 @@ tags: description: KPIs and trends from public.audit_logs - name: Allergy description: Endpoints for allergy checks and warnings + - name: Community + description: Community features including posts, comments, and likes paths: /allergy/common: get: @@ -2719,6 +2721,697 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + # Community API Endpoints + /community/posts: + post: + tags: + - Community + summary: Create a new community post + description: Create a new post in the community with content, category, and optional tags + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityPostCreate' + responses: + '201': + description: Post created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityPostResponse' + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + get: + tags: + - Community + summary: Get community posts + description: Retrieve community posts with filtering, pagination, and sorting options + parameters: + - name: page + in: query + required: false + description: Page number for pagination + schema: + type: integer + minimum: 1 + default: 1 + - name: limit + in: query + required: false + description: Number of posts per page + schema: + type: integer + minimum: 1 + maximum: 50 + default: 10 + - name: category + in: query + required: false + description: Filter by post category + schema: + type: string + enum: [all, weight-loss, fitness, dietary-restrictions, meal-prep, nutrition-tips, success-story, recipe-share, motivation] + default: all + - name: search + in: query + required: false + description: Search in post content and tags + schema: + type: string + maxLength: 100 + - name: user_id + in: query + required: false + description: Filter posts by specific user + schema: + type: integer + - name: sort_by + in: query + required: false + description: Sort field + schema: + type: string + enum: [created_at, likes_count, comments_count] + default: created_at + - name: sort_order + in: query + required: false + description: Sort order + schema: + type: string + enum: [asc, desc] + default: desc + responses: + '200': + description: Posts retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityPostsResponse' + '400': + description: Bad request - invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /community/posts/{id}: + get: + tags: + - Community + summary: Get a single community post + description: Retrieve a specific community post by ID + parameters: + - name: id + in: path + required: true + description: Post ID + schema: + type: integer + responses: + '200': + description: Post retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityPostResponse' + '404': + description: Post not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + put: + tags: + - Community + summary: Update a community post + description: Update an existing community post + parameters: + - name: id + in: path + required: true + description: Post ID + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityPostUpdate' + responses: + '200': + description: Post updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityPostResponse' + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Post not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + tags: + - Community + summary: Delete a community post + description: Delete a community post by ID + parameters: + - name: id + in: path + required: true + description: Post ID + schema: + type: integer + responses: + '200': + description: Post deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '404': + description: Post not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /community/stats: + get: + tags: + - Community + summary: Get community statistics + description: Retrieve community statistics including total posts, members, and daily activity + responses: + '200': + description: Statistics retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityStatsResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /community/comments: + post: + tags: + - Community + summary: Create a comment + description: Add a comment to a community post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityCommentCreate' + responses: + '201': + description: Comment created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityCommentResponse' + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /community/comments/{id}: + put: + tags: + - Community + summary: Update a comment + description: Update an existing comment + parameters: + - name: id + in: path + required: true + description: Comment ID + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityCommentUpdate' + responses: + '200': + description: Comment updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityCommentResponse' + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Comment not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + tags: + - Community + summary: Delete a comment + description: Delete a comment by ID + parameters: + - name: id + in: path + required: true + description: Comment ID + schema: + type: integer + responses: + '200': + description: Comment deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '404': + description: Comment not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /community/posts/{postId}/comments: + get: + tags: + - Community + summary: Get comments for a post + description: Retrieve all comments for a specific post with pagination + parameters: + - name: postId + in: path + required: true + description: Post ID + schema: + type: integer + - name: page + in: query + required: false + description: Page number for pagination + schema: + type: integer + minimum: 1 + default: 1 + - name: limit + in: query + required: false + description: Number of comments per page + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + responses: + '200': + description: Comments retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityCommentsResponse' + '400': + description: Bad request - invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /community/likes: + post: + tags: + - Community + summary: Like a post + description: Add a like to a community post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityLikeRequest' + responses: + '200': + description: Post liked successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityLikeResponse' + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + tags: + - Community + summary: Unlike a post + description: Remove a like from a community post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityLikeRequest' + responses: + '200': + description: Post unliked successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /community/users/{userId}/liked-posts: + get: + tags: + - Community + summary: Get user's liked posts + description: Retrieve all posts liked by a specific user + parameters: + - name: userId + in: path + required: true + description: User ID + schema: + type: integer + - name: page + in: query + required: false + description: Page number for pagination + schema: + type: integer + minimum: 1 + default: 1 + - name: limit + in: query + required: false + description: Number of posts per page + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + responses: + '200': + description: Liked posts retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityPostsResponse' + '400': + description: Bad request - invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /community/upload-image: + post: + tags: + - Community + summary: Upload community post image + description: Upload an image for a community post + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + image: + type: string + format: binary + description: Image file + user_id: + type: integer + description: User ID + responses: + '200': + description: Image uploaded successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "Image processed successfully" + data: + type: object + properties: + image_url: + type: string + example: "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..." + file_name: + type: string + example: "image.jpg" + file_size: + type: integer + example: 123456 + '400': + description: Bad request - missing image or user_id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /community/shares: + post: + tags: + - Community + summary: Share a post + description: Record a post share + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - user_id + - post_id + properties: + user_id: + type: integer + description: User ID + example: 202 + post_id: + type: integer + description: Post ID + example: 7 + share_platform: + type: string + description: Share platform + example: "facebook" + default: "copy_link" + responses: + '201': + description: Post shared successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "Post shared successfully" + data: + type: object + properties: + id: + type: integer + example: 1 + user_id: + type: integer + example: 202 + post_id: + type: integer + example: 7 + share_platform: + type: string + example: "facebook" + created_at: + type: string + format: date-time + example: "2025-09-12T07:47:18.693112+00:00" + '400': + description: Bad request - missing required fields + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Post not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /community/posts/{postId}/shares: + get: + tags: + - Community + summary: Get share count for a post + description: Get the number of shares for a specific post + parameters: + - name: postId + in: path + required: true + description: Post ID + schema: + type: integer + responses: + '200': + description: Share count retrieved successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + share_count: + type: integer + example: 5 + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + components: securitySchemes: @@ -3756,3 +4449,353 @@ components: items: type: string example: ["Vegetable", "Meat", "Dairy", "Pantry"] + + # Community API Schemas + CommunityPostCreate: + type: object + required: + - user_id + - content + - category + properties: + user_id: + type: integer + description: ID of the user creating the post + example: 123 + content: + type: string + description: Post content + minLength: 10 + maxLength: 2000 + example: "Just tried this amazing healthy recipe! The quinoa salad was perfect for my weight loss journey." + category: + type: string + description: Post category + enum: [weight-loss, fitness, dietary-restrictions, meal-prep, nutrition-tips, success-story, recipe-share, motivation] + example: "success-story" + tags: + type: array + description: Post tags + items: + type: string + maxLength: 50 + maxItems: 10 + example: ["quinoa", "salad", "healthy", "weight-loss"] + image_url: + type: string + format: uri + description: Optional image URL + example: "https://example.com/images/quinoa-salad.jpg" + + CommunityPostUpdate: + type: object + properties: + content: + type: string + description: Updated post content + minLength: 10 + maxLength: 2000 + example: "Updated: The quinoa salad recipe was even better with added avocado!" + category: + type: string + description: Updated post category + enum: [weight-loss, fitness, dietary-restrictions, meal-prep, nutrition-tips, success-story, recipe-share, motivation] + example: "recipe-share" + tags: + type: array + description: Updated post tags + items: + type: string + maxLength: 50 + maxItems: 10 + example: ["quinoa", "salad", "healthy", "avocado", "recipe"] + image_url: + type: string + format: uri + description: Updated image URL + example: "https://example.com/images/updated-quinoa-salad.jpg" + + CommunityUser: + type: object + properties: + user_id: + type: integer + description: User ID + example: 123 + username: + type: string + description: Username + example: "healthyliving_user" + email: + type: string + format: email + description: User email + example: "user@example.com" + profile_picture: + type: string + format: uri + description: Profile picture URL + example: "https://example.com/avatars/user123.jpg" + verified: + type: boolean + description: Whether the user is verified + example: true + + CommunityPost: + type: object + properties: + id: + type: integer + description: Post ID + example: 1 + user_id: + type: integer + description: User ID who created the post + example: 123 + content: + type: string + description: Post content + example: "Just tried this amazing healthy recipe! The quinoa salad was perfect for my weight loss journey." + category: + type: string + description: Post category + example: "success-story" + tags: + type: array + description: Post tags + items: + type: string + example: ["quinoa", "salad", "healthy", "weight-loss"] + image_url: + type: string + format: uri + description: Image URL + example: "https://example.com/images/quinoa-salad.jpg" + likes_count: + type: integer + description: Number of likes + example: 15 + comments_count: + type: integer + description: Number of comments + example: 8 + shares_count: + type: integer + description: Number of shares + example: 3 + created_at: + type: string + format: date-time + description: Creation timestamp + example: "2024-01-15T10:30:00Z" + updated_at: + type: string + format: date-time + description: Last update timestamp + example: "2024-01-15T10:30:00Z" + users: + $ref: '#/components/schemas/CommunityUser' + + CommunityPostResponse: + type: object + properties: + statusCode: + type: integer + example: 200 + message: + type: string + example: "Post retrieved successfully" + data: + $ref: '#/components/schemas/CommunityPost' + + CommunityPostsResponse: + type: object + properties: + statusCode: + type: integer + example: 200 + message: + type: string + example: "Posts retrieved successfully" + data: + type: array + items: + $ref: '#/components/schemas/CommunityPost' + pagination: + type: object + properties: + page: + type: integer + example: 1 + limit: + type: integer + example: 10 + total: + type: integer + example: 25 + + CommunityCommentCreate: + type: object + required: + - user_id + - post_id + - content + properties: + user_id: + type: integer + description: ID of the user creating the comment + example: 456 + post_id: + type: integer + description: ID of the post being commented on + example: 1 + content: + type: string + description: Comment content + minLength: 1 + maxLength: 500 + example: "This looks amazing! Can you share the recipe?" + + CommunityCommentUpdate: + type: object + required: + - content + properties: + content: + type: string + description: Updated comment content + minLength: 1 + maxLength: 500 + example: "Updated: This looks amazing! Can you share the recipe? I'd love to try it this weekend." + + CommunityComment: + type: object + properties: + id: + type: integer + description: Comment ID + example: 1 + user_id: + type: integer + description: User ID who created the comment + example: 456 + post_id: + type: integer + description: Post ID being commented on + example: 1 + content: + type: string + description: Comment content + example: "This looks amazing! Can you share the recipe?" + created_at: + type: string + format: date-time + description: Creation timestamp + example: "2024-01-15T11:00:00Z" + updated_at: + type: string + format: date-time + description: Last update timestamp + example: "2024-01-15T11:00:00Z" + users: + $ref: '#/components/schemas/CommunityUser' + + CommunityCommentResponse: + type: object + properties: + statusCode: + type: integer + example: 201 + message: + type: string + example: "Comment created successfully" + data: + $ref: '#/components/schemas/CommunityComment' + + CommunityCommentsResponse: + type: object + properties: + statusCode: + type: integer + example: 200 + message: + type: string + example: "Comments retrieved successfully" + data: + type: array + items: + $ref: '#/components/schemas/CommunityComment' + pagination: + type: object + properties: + page: + type: integer + example: 1 + limit: + type: integer + example: 20 + total: + type: integer + example: 8 + + CommunityLikeRequest: + type: object + required: + - user_id + - post_id + properties: + user_id: + type: integer + description: ID of the user liking the post + example: 789 + post_id: + type: integer + description: ID of the post being liked + example: 1 + + CommunityLikeResponse: + type: object + properties: + statusCode: + type: integer + example: 200 + message: + type: string + example: "Post liked successfully" + data: + type: object + properties: + user_id: + type: integer + example: 789 + post_id: + type: integer + example: 1 + already_liked: + type: boolean + example: false + + CommunityStatsResponse: + type: object + properties: + statusCode: + type: integer + example: 200 + message: + type: string + example: "Community statistics retrieved successfully" + data: + type: object + properties: + total_posts: + type: integer + description: Total number of posts + example: 1250 + total_members: + type: integer + description: Total number of community members + example: 850 + posts_today: + type: integer + description: Number of posts created today + example: 15 diff --git a/model/communityComments.js b/model/communityComments.js new file mode 100644 index 0000000..f5d9c03 --- /dev/null +++ b/model/communityComments.js @@ -0,0 +1,144 @@ +const supabase = require('../dbConnection.js'); + +// Create a new comment on a community post +const createCommunityComment = async (commentData) => { + try { + const { data, error } = await supabase + .from('community_comments') + .insert([commentData]) + .select(` + *, + users!inner( + user_id, + name, + email, + image_id, + mfa_enabled + ) + `) + .single(); + + if (error) { + console.error('Error creating community comment:', error); + throw error; + } + + return data; + } catch (error) { + console.error('createCommunityComment error:', error); + throw error; + } +}; + +// Get comments for a specific post +const getCommentsByPostId = async (postId, page = 1, limit = 20) => { + try { + const from = (page - 1) * limit; + const to = from + limit - 1; + + const { data, error } = await supabase + .from('community_comments') + .select(` + *, + users!inner( + user_id, + name, + email, + image_id, + mfa_enabled + ) + `) + .eq('post_id', postId) + .order('created_at', { ascending: true }) + .range(from, to); + + if (error) { + console.error('Error fetching comments:', error); + throw error; + } + + return data; + } catch (error) { + console.error('getCommentsByPostId error:', error); + throw error; + } +}; + +// Update a comment +const updateCommunityComment = async (commentId, updateData) => { + try { + const { data, error } = await supabase + .from('community_comments') + .update(updateData) + .eq('id', commentId) + .select(` + *, + users!inner( + user_id, + name, + email, + image_id, + mfa_enabled + ) + `) + .single(); + + if (error) { + console.error('Error updating comment:', error); + throw error; + } + + return data; + } catch (error) { + console.error('updateCommunityComment error:', error); + throw error; + } +}; + +// Delete a comment +const deleteCommunityComment = async (commentId) => { + try { + const { error } = await supabase + .from('community_comments') + .delete() + .eq('id', commentId); + + if (error) { + console.error('Error deleting comment:', error); + throw error; + } + + return { success: true }; + } catch (error) { + console.error('deleteCommunityComment error:', error); + throw error; + } +}; + +// Get comment count for a post +const getCommentCount = async (postId) => { + try { + const { count, error } = await supabase + .from('community_comments') + .select('*', { count: 'exact', head: true }) + .eq('post_id', postId); + + if (error) { + console.error('Error getting comment count:', error); + throw error; + } + + return count || 0; + } catch (error) { + console.error('getCommentCount error:', error); + throw error; + } +}; + +module.exports = { + createCommunityComment, + getCommentsByPostId, + updateCommunityComment, + deleteCommunityComment, + getCommentCount +}; diff --git a/model/communityLikes.js b/model/communityLikes.js new file mode 100644 index 0000000..9fc9098 --- /dev/null +++ b/model/communityLikes.js @@ -0,0 +1,151 @@ +const supabase = require('../dbConnection.js'); + +// Like a post +const likePost = async (userId, postId) => { + try { + // Check if user already liked the post + const { data: existingLike, error: checkError } = await supabase + .from('community_likes') + .select('id') + .eq('user_id', userId) + .eq('post_id', postId) + .single(); + + if (checkError && checkError.code !== 'PGRST116') { // PGRST116 = no rows found + console.error('Error checking existing like:', checkError); + throw checkError; + } + + if (existingLike) { + return { message: 'Post already liked', already_liked: true }; + } + + // Add like + const { data, error } = await supabase + .from('community_likes') + .insert([{ + user_id: userId, + post_id: postId + }]) + .select() + .single(); + + if (error) { + console.error('Error liking post:', error); + throw error; + } + + return { data, already_liked: false }; + } catch (error) { + console.error('likePost error:', error); + throw error; + } +}; + +// Unlike a post +const unlikePost = async (userId, postId) => { + try { + const { error } = await supabase + .from('community_likes') + .delete() + .eq('user_id', userId) + .eq('post_id', postId); + + if (error) { + console.error('Error unliking post:', error); + throw error; + } + + return { success: true }; + } catch (error) { + console.error('unlikePost error:', error); + throw error; + } +}; + +// Get like count for a post +const getLikeCount = async (postId) => { + try { + const { count, error } = await supabase + .from('community_likes') + .select('*', { count: 'exact', head: true }) + .eq('post_id', postId); + + if (error) { + console.error('Error getting like count:', error); + throw error; + } + + return count || 0; + } catch (error) { + console.error('getLikeCount error:', error); + throw error; + } +}; + +// Check if user liked a post +const checkUserLike = async (userId, postId) => { + try { + const { data, error } = await supabase + .from('community_likes') + .select('id') + .eq('user_id', userId) + .eq('post_id', postId) + .single(); + + if (error && error.code !== 'PGRST116') { + console.error('Error checking user like:', error); + throw error; + } + + return !!data; + } catch (error) { + console.error('checkUserLike error:', error); + throw error; + } +}; + +// Get user's liked posts +const getUserLikedPosts = async (userId, page = 1, limit = 20) => { + try { + const from = (page - 1) * limit; + const to = from + limit - 1; + + const { data, error } = await supabase + .from('community_likes') + .select(` + *, + community_posts!inner( + *, + users!inner( + user_id, + name, + email, + image_id, + mfa_enabled + ) + ) + `) + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .range(from, to); + + if (error) { + console.error('Error fetching user liked posts:', error); + throw error; + } + + return data; + } catch (error) { + console.error('getUserLikedPosts error:', error); + throw error; + } +}; + +module.exports = { + likePost, + unlikePost, + getLikeCount, + checkUserLike, + getUserLikedPosts +}; diff --git a/model/communityPosts.js b/model/communityPosts.js new file mode 100644 index 0000000..491e8e3 --- /dev/null +++ b/model/communityPosts.js @@ -0,0 +1,251 @@ +const supabase = require('../dbConnection.js'); + +// Create a new community post +const createCommunityPost = async (postData) => { + try { + const { data, error } = await supabase + .from('community_posts') + .insert([postData]) + .select(` + *, + users!inner( + user_id, + name, + email, + image_id, + mfa_enabled + ) + `) + .single(); + + if (error) { + console.error('Error creating community post:', error); + throw error; + } + + return data; + } catch (error) { + console.error('createCommunityPost error:', error); + throw error; + } +}; + +// Get all community posts with pagination and filters +const getCommunityPosts = async (filters = {}) => { + try { + const { + page = 1, + limit = 10, + category = null, + search = null, + user_id = null, + sort_by = 'created_at', + sort_order = 'desc' + } = filters; + + let query = supabase + .from('community_posts') + .select(` + *, + users!inner( + user_id, + name, + email, + image_id, + mfa_enabled + ), + community_likes( + user_id, + created_at + ) + `); + + // Apply filters + if (category && category !== 'all') { + query = query.eq('category', category); + } + + if (search) { + query = query.or(`content.ilike.%${search}%,tags.cs.{${search}}`); + } + + if (user_id) { + query = query.eq('user_id', user_id); + } + + // Apply sorting + query = query.order(sort_by, { ascending: sort_order === 'asc' }); + + // Apply pagination + const from = (page - 1) * limit; + const to = from + limit - 1; + query = query.range(from, to); + + const { data, error } = await query; + + if (error) { + console.error('Error fetching community posts:', error); + throw error; + } + + return data; + } catch (error) { + console.error('getCommunityPosts error:', error); + throw error; + } +}; + +// Get a single community post by ID +const getCommunityPostById = async (postId) => { + try { + const { data, error } = await supabase + .from('community_posts') + .select(` + *, + users!inner( + user_id, + name, + email, + image_id, + mfa_enabled + ), + community_likes( + user_id, + created_at + ) + `) + .eq('id', postId) + .single(); + + if (error) { + console.error('Error fetching community post:', error); + throw error; + } + + return data; + } catch (error) { + console.error('getCommunityPostById error:', error); + throw error; + } +}; + +// Update a community post +const updateCommunityPost = async (postId, updateData) => { + try { + const { data, error } = await supabase + .from('community_posts') + .update(updateData) + .eq('id', postId) + .select(` + *, + users!inner( + user_id, + name, + email, + image_id, + mfa_enabled + ) + `) + .single(); + + if (error) { + console.error('Error updating community post:', error); + throw error; + } + + return data; + } catch (error) { + console.error('updateCommunityPost error:', error); + throw error; + } +}; + +// Delete a community post +const deleteCommunityPost = async (postId) => { + try { + const { error } = await supabase + .from('community_posts') + .delete() + .eq('id', postId); + + if (error) { + console.error('Error deleting community post:', error); + throw error; + } + + return { success: true }; + } catch (error) { + console.error('deleteCommunityPost error:', error); + throw error; + } +}; + +// Get community statistics +const getCommunityStats = async () => { + try { + console.log('Fetching community statistics...'); + + // Get total posts count + const { count: totalPosts, error: postsError } = await supabase + .from('community_posts') + .select('*', { count: 'exact', head: true }); + + console.log('Total posts query result:', { totalPosts, postsError }); + + // Get total users count + const { count: totalUsers, error: usersError } = await supabase + .from('users') + .select('*', { count: 'exact', head: true }); + + console.log('Total users query result:', { totalUsers, usersError }); + + // Get today's posts count - fix date filtering + const today = new Date(); + const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); + + console.log('Date range for today posts:', { + startOfDay: startOfDay.toISOString(), + endOfDay: endOfDay.toISOString() + }); + + const { count: todayPosts, error: todayError } = await supabase + .from('community_posts') + .select('*', { count: 'exact', head: true }) + .gte('created_at', startOfDay.toISOString()) + .lt('created_at', endOfDay.toISOString()); + + console.log('Today posts query result:', { todayPosts, todayError }); + + if (postsError) { + console.error('Posts count error:', postsError); + } + if (usersError) { + console.error('Users count error:', usersError); + } + if (todayError) { + console.error('Today posts count error:', todayError); + } + + const stats = { + total_posts: totalPosts || 0, + total_members: totalUsers || 0, + posts_today: todayPosts || 0 + }; + + console.log('Final stats:', stats); + return stats; + } catch (error) { + console.error('getCommunityStats error:', error); + throw error; + } +}; + +module.exports = { + createCommunityPost, + getCommunityPosts, + getCommunityPostById, + updateCommunityPost, + deleteCommunityPost, + getCommunityStats +}; diff --git a/routes/community.js b/routes/community.js new file mode 100644 index 0000000..2730220 --- /dev/null +++ b/routes/community.js @@ -0,0 +1,57 @@ +const express = require("express"); +const router = express.Router(); +const controller = require('../controller/communityController.js'); +const imageController = require('../controller/communityImageController.js'); +const shareController = require('../controller/communityShareController.js'); +const { + createPostValidation, + updatePostValidation, + getPostsValidation, + getPostValidation, + deletePostValidation, + createCommentValidation, + updateCommentValidation, + deleteCommentValidation, + getCommentsValidation, + likePostValidation, + getUserLikedPostsValidation +} = require('../validators/communityValidator.js'); +const validate = require('../middleware/validateRequest.js'); + +// Community Posts Routes +router.route('/posts') + .post(createPostValidation, validate, controller.createPost) // Create a new post + .get(getPostsValidation, validate, controller.getPosts); // Get all posts with filters + +router.route('/posts/:id') + .get(getPostValidation, validate, controller.getPost) // Get single post + .put(updatePostValidation, validate, controller.updatePost) // Update post + .delete(deletePostValidation, validate, controller.deletePost); // Delete post + +// Community Statistics +router.get('/stats', controller.getCommunityStats); // Get community statistics + +// Comments Routes +router.route('/comments') + .post(createCommentValidation, validate, controller.createComment); // Create comment + +router.route('/comments/:id') + .put(updateCommentValidation, validate, controller.updateComment) // Update comment + .delete(deleteCommentValidation, validate, controller.deleteComment); // Delete comment + +router.get('/posts/:postId/comments', getCommentsValidation, validate, controller.getComments); // Get comments for post + +// Likes Routes +router.post('/likes', likePostValidation, validate, controller.likePost); // Like a post +router.delete('/likes', likePostValidation, validate, controller.unlikePost); // Unlike a post + +router.get('/users/:userId/liked-posts', getUserLikedPostsValidation, validate, controller.getUserLikedPosts); // Get user's liked posts + +// Image Upload Route +router.post('/upload-image', imageController.uploadCommunityImage); // Upload community post image + +// Share Routes +router.post('/shares', shareController.sharePost); // Share a post +router.get('/posts/:postId/shares', shareController.getShareCount); // Get share count for a post + +module.exports = router; diff --git a/routes/extendedUserPreferences.js b/routes/extendedUserPreferences.js new file mode 100644 index 0000000..94854d3 --- /dev/null +++ b/routes/extendedUserPreferences.js @@ -0,0 +1,77 @@ +const express = require("express"); +const router = express.Router(); +const controller = require("../controller/extendedUserPreferencesController"); +const { authenticateToken } = require("../middleware/authenticateToken"); +const authorizeRoles = require("../middleware/authorizeRoles"); +const { validateUserPreferences } = require("../validators/userPreferencesValidator"); +const ValidateRequest = require("../middleware/validateRequest"); + +/** + * @api {get} /api/user/preferences/extended Get Extended User Preferences + * @apiName GetExtendedUserPreferences + * @apiGroup User Preferences + * @apiDescription Get user preferences including notification preferences, language, theme, and font size + * + * @apiHeader {String} Authorization Bearer token for authentication + * + * @apiSuccess {Object} response API response + * @apiSuccess {Boolean} response.success Success status + * @apiSuccess {Object} response.data User preferences data + * @apiSuccess {Object} response.data.notification_preferences User's notification preferences + * @apiSuccess {String} response.data.language User's preferred language + * @apiSuccess {String} response.data.theme User's theme preference + * @apiSuccess {String} response.data.font_size User's font size preference + */ +router.get("/", authenticateToken, controller.getUserPreferences); + +/** + * @api {post} /api/user/preferences/extended Update Extended User Preferences + * @apiName UpdateExtendedUserPreferences + * @apiGroup User Preferences + * @apiDescription Update user preferences including notification preferences, language, theme, and font size + * + * @apiHeader {String} Authorization Bearer token for authentication + * + * @apiParam {Object} user User object containing userId + * @apiParam {Object} [notification_preferences] User's notification preferences + * @apiParam {String} [language] User's preferred language code + * @apiParam {String} [theme] User's theme preference (light/dark) + * @apiParam {String} [font_size] User's font size preference + * + * @apiSuccess {Object} response API response + * @apiSuccess {Boolean} response.success Success status + * @apiSuccess {String} response.message Success message + */ +router.post("/", authenticateToken, validateUserPreferences, ValidateRequest, controller.postUserPreferences); + +/** + * @api {get} /api/user/preferences/notifications Get Notification Preferences + * @apiName GetNotificationPreferences + * @apiGroup User Preferences + * @apiDescription Get user's notification preferences only + * + * @apiHeader {String} Authorization Bearer token for authentication + * + * @apiSuccess {Object} response API response + * @apiSuccess {Boolean} response.success Success status + * @apiSuccess {Object} response.data Notification preferences object + */ +router.get("/notifications", authenticateToken, controller.getNotificationPreferences); + +/** + * @api {put} /api/user/preferences/notifications Update Notification Preferences + * @apiName UpdateNotificationPreferences + * @apiGroup User Preferences + * @apiDescription Update user's notification preferences only + * + * @apiHeader {String} Authorization Bearer token for authentication + * + * @apiParam {Object} notification_preferences User's notification preferences object + * + * @apiSuccess {Object} response API response + * @apiSuccess {Boolean} response.success Success status + * @apiSuccess {String} response.message Success message + */ +router.put("/notifications", authenticateToken, controller.updateNotificationPreferences); + +module.exports = router; diff --git a/routes/index.js b/routes/index.js index 77b9ae1..463eab6 100644 --- a/routes/index.js +++ b/routes/index.js @@ -11,6 +11,7 @@ module.exports = app => { app.use("/api/userpassword", require('./userpassword')); app.use("/api/fooddata", require('./fooddata')); app.use("/api/user/preferences", require('./userPreferences')); + app.use("/api/user/preferences/extended", require('./extendedUserPreferences')); // Extended user preferences with notification settings app.use("/api/mealplan", require('./mealplan')); app.use("/api/account", require('./account')); app.use('/api/notifications', require('./notifications')); @@ -35,5 +36,8 @@ module.exports = app => { app.use('/api/shopping-list', require('./shoppingList')); app.use('/api/barcode', require('./barcodeScanning')); + // Add community routes + app.use('/api/community', require('./community')); + }; \ No newline at end of file diff --git a/validators/communityValidator.js b/validators/communityValidator.js new file mode 100644 index 0000000..660aaaf --- /dev/null +++ b/validators/communityValidator.js @@ -0,0 +1,253 @@ +const { body, query, param } = require('express-validator'); + +// Validation for creating a community post +const createPostValidation = [ + body('user_id') + .notEmpty() + .withMessage('User ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('User ID must be a positive integer'), + body('content') + .notEmpty() + .withMessage('Post content cannot be empty') + .isLength({ min: 10, max: 2000 }) + .withMessage('Post content must be between 10-2000 characters'), + body('category') + .notEmpty() + .withMessage('Category cannot be empty') + .isIn(['weight-loss', 'fitness', 'dietary-restrictions', 'meal-prep', 'nutrition-tips', 'success-story', 'recipe-share', 'motivation']) + .withMessage('Invalid category'), + body('tags') + .optional() + .isArray() + .withMessage('Tags must be an array') + .custom((tags) => { + if (tags && tags.length > 10) { + throw new Error('Maximum 10 tags allowed'); + } + if (tags) { + tags.forEach(tag => { + if (typeof tag !== 'string' || tag.length > 50) { + throw new Error('Each tag must be a string with max 50 characters'); + } + }); + } + return true; + }), + body('image_url') + .optional() + .custom((value) => { + if (value === null || value === undefined || value === '') { + return true; // Allow null, undefined, or empty string + } + // Allow both HTTP/HTTPS URLs and base64 data URLs + const httpPattern = /^https?:\/\/.+/; + const dataUrlPattern = /^data:image\/[a-zA-Z]+;base64,.+/; + if (!httpPattern.test(value) && !dataUrlPattern.test(value)) { + throw new Error('Image URL must be a valid URL or base64 data URL'); + } + return true; + }) +]; + +// Validation for updating a community post +const updatePostValidation = [ + param('id') + .notEmpty() + .withMessage('Post ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('Post ID must be a positive integer'), + body('content') + .optional() + .isLength({ min: 10, max: 2000 }) + .withMessage('Post content must be between 10-2000 characters'), + body('category') + .optional() + .isIn(['weight-loss', 'fitness', 'dietary-restrictions', 'meal-prep', 'nutrition-tips', 'success-story', 'recipe-share', 'motivation']) + .withMessage('Invalid category'), + body('tags') + .optional() + .isArray() + .withMessage('Tags must be an array') + .custom((tags) => { + if (tags && tags.length > 10) { + throw new Error('Maximum 10 tags allowed'); + } + if (tags) { + tags.forEach(tag => { + if (typeof tag !== 'string' || tag.length > 50) { + throw new Error('Each tag must be a string with max 50 characters'); + } + }); + } + return true; + }), + body('image_url') + .optional() + .custom((value) => { + if (value === null || value === undefined || value === '') { + return true; // Allow null, undefined, or empty string + } + // Allow both HTTP/HTTPS URLs and base64 data URLs + const httpPattern = /^https?:\/\/.+/; + const dataUrlPattern = /^data:image\/[a-zA-Z]+;base64,.+/; + if (!httpPattern.test(value) && !dataUrlPattern.test(value)) { + throw new Error('Image URL must be a valid URL or base64 data URL'); + } + return true; + }) +]; + +// Validation for getting community posts +const getPostsValidation = [ + query('page') + .optional() + .isInt({ min: 1 }) + .withMessage('Page must be a positive integer'), + query('limit') + .optional() + .isInt({ min: 1, max: 50 }) + .withMessage('Limit must be between 1-50'), + query('category') + .optional() + .isIn(['all', 'weight-loss', 'fitness', 'dietary-restrictions', 'meal-prep', 'nutrition-tips', 'success-story', 'recipe-share', 'motivation']) + .withMessage('Invalid category'), + query('search') + .optional() + .isLength({ max: 100 }) + .withMessage('Search query cannot exceed 100 characters'), + query('user_id') + .optional() + .isInt({ min: 1 }) + .withMessage('User ID must be a positive integer'), + query('sort_by') + .optional() + .isIn(['created_at', 'likes_count', 'comments_count']) + .withMessage('Invalid sort field'), + query('sort_order') + .optional() + .isIn(['asc', 'desc']) + .withMessage('Sort order must be asc or desc') +]; + +// Validation for getting a single post +const getPostValidation = [ + param('id') + .notEmpty() + .withMessage('Post ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('Post ID must be a positive integer') +]; + +// Validation for deleting a post +const deletePostValidation = [ + param('id') + .notEmpty() + .withMessage('Post ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('Post ID must be a positive integer') +]; + +// Validation for creating a comment +const createCommentValidation = [ + body('user_id') + .notEmpty() + .withMessage('User ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('User ID must be a positive integer'), + body('post_id') + .notEmpty() + .withMessage('Post ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('Post ID must be a positive integer'), + body('content') + .notEmpty() + .withMessage('Comment content cannot be empty') + .isLength({ min: 1, max: 500 }) + .withMessage('Comment content must be between 1-500 characters') +]; + +// Validation for updating a comment +const updateCommentValidation = [ + param('id') + .notEmpty() + .withMessage('Comment ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('Comment ID must be a positive integer'), + body('content') + .notEmpty() + .withMessage('Comment content cannot be empty') + .isLength({ min: 1, max: 500 }) + .withMessage('Comment content must be between 1-500 characters') +]; + +// Validation for deleting a comment +const deleteCommentValidation = [ + param('id') + .notEmpty() + .withMessage('Comment ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('Comment ID must be a positive integer') +]; + +// Validation for getting comments +const getCommentsValidation = [ + param('postId') + .notEmpty() + .withMessage('Post ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('Post ID must be a positive integer'), + query('page') + .optional() + .isInt({ min: 1 }) + .withMessage('Page must be a positive integer'), + query('limit') + .optional() + .isInt({ min: 1, max: 50 }) + .withMessage('Limit must be between 1-50') +]; + +// Validation for liking/unliking a post +const likePostValidation = [ + body('user_id') + .notEmpty() + .withMessage('User ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('User ID must be a positive integer'), + body('post_id') + .notEmpty() + .withMessage('Post ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('Post ID must be a positive integer') +]; + +// Validation for getting user's liked posts +const getUserLikedPostsValidation = [ + param('userId') + .notEmpty() + .withMessage('User ID cannot be empty') + .isInt({ min: 1 }) + .withMessage('User ID must be a positive integer'), + query('page') + .optional() + .isInt({ min: 1 }) + .withMessage('Page must be a positive integer'), + query('limit') + .optional() + .isInt({ min: 1, max: 50 }) + .withMessage('Limit must be between 1-50') +]; + +module.exports = { + createPostValidation, + updatePostValidation, + getPostsValidation, + getPostValidation, + deletePostValidation, + createCommentValidation, + updateCommentValidation, + deleteCommentValidation, + getCommentsValidation, + likePostValidation, + getUserLikedPostsValidation +};