diff --git a/backend/add_social_features.sql b/backend/add_social_features.sql new file mode 100644 index 0000000..fe43f84 --- /dev/null +++ b/backend/add_social_features.sql @@ -0,0 +1,127 @@ +-- Add social networking features to recipes database + +-- Add visibility column to recipes table +ALTER TABLE recipes +ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) DEFAULT 'public' CHECK (visibility IN ('public', 'private', 'friends', 'groups')); + +-- Create friendships table +CREATE TABLE IF NOT EXISTS friendships ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + friend_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, friend_id), + CHECK (user_id != friend_id) +); + +-- Create friend_requests table +CREATE TABLE IF NOT EXISTS friend_requests ( + id SERIAL PRIMARY KEY, + sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + receiver_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(sender_id, receiver_id), + CHECK (sender_id != receiver_id) +); + +-- Create conversations table +CREATE TABLE IF NOT EXISTS conversations ( + id SERIAL PRIMARY KEY, + name VARCHAR(100), + is_group BOOLEAN DEFAULT FALSE, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create conversation_members table +CREATE TABLE IF NOT EXISTS conversation_members ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(conversation_id, user_id) +); + +-- Create messages table +CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + edited_at TIMESTAMP, + is_deleted BOOLEAN DEFAULT FALSE +); + +-- Create recipe_ratings table +CREATE TABLE IF NOT EXISTS recipe_ratings ( + id SERIAL PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(recipe_id, user_id) +); + +-- Create recipe_comments table +CREATE TABLE IF NOT EXISTS recipe_comments ( + id SERIAL PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + parent_comment_id INTEGER REFERENCES recipe_comments(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_deleted BOOLEAN DEFAULT FALSE +); + +-- Create groups table +CREATE TABLE IF NOT EXISTS groups ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_private BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create group_members table +CREATE TABLE IF NOT EXISTS group_members ( + id SERIAL PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(20) DEFAULT 'member' CHECK (role IN ('admin', 'moderator', 'member')), + joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(group_id, user_id) +); + +-- Create recipe_shares table (for sharing recipes to specific groups) +CREATE TABLE IF NOT EXISTS recipe_shares ( + id SERIAL PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + shared_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(recipe_id, group_id) +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_friendships_user_id ON friendships(user_id); +CREATE INDEX IF NOT EXISTS idx_friendships_friend_id ON friendships(friend_id); +CREATE INDEX IF NOT EXISTS idx_friend_requests_receiver ON friend_requests(receiver_id); +CREATE INDEX IF NOT EXISTS idx_friend_requests_sender ON friend_requests(sender_id); +CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id); +CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at); +CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id); +CREATE INDEX IF NOT EXISTS idx_recipe_ratings_recipe ON recipe_ratings(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_comments_recipe ON recipe_comments(recipe_id); +CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id); +CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id); +CREATE INDEX IF NOT EXISTS idx_recipe_shares_group ON recipe_shares(group_id); +CREATE INDEX IF NOT EXISTS idx_recipes_visibility ON recipes(visibility); diff --git a/backend/auth_utils.py b/backend/auth_utils.py index f6925fc..7815c77 100644 --- a/backend/auth_utils.py +++ b/backend/auth_utils.py @@ -12,6 +12,7 @@ ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days security = HTTPBearer() +security_optional = HTTPBearer(auto_error=False) def hash_password(password: str) -> str: @@ -86,7 +87,7 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit # Optional dependency - returns None if no token provided -def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[dict]: +def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional)) -> Optional[dict]: """Get current user if authenticated, otherwise None""" if not credentials: return None diff --git a/backend/chat_db_utils.py b/backend/chat_db_utils.py new file mode 100644 index 0000000..523def0 --- /dev/null +++ b/backend/chat_db_utils.py @@ -0,0 +1,239 @@ +import os +import psycopg2 +from psycopg2.extras import RealDictCursor +from typing import List, Optional + + +def get_db_connection(): + """Get database connection""" + return psycopg2.connect( + host=os.getenv("DB_HOST", "localhost"), + port=int(os.getenv("DB_PORT", "5432")), + database=os.getenv("DB_NAME", "recipes_db"), + user=os.getenv("DB_USER", "recipes_user"), + password=os.getenv("DB_PASSWORD", "recipes_password"), + ) + + +# ============= Conversations & Messages ============= + +def create_conversation(user_ids: List[int], is_group: bool = False, name: Optional[str] = None, created_by: int = None): + """Create a new conversation""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + # For private chats, check if conversation already exists + if not is_group and len(user_ids) == 2: + cur.execute( + """ + SELECT c.id FROM conversations c + JOIN conversation_members cm1 ON c.id = cm1.conversation_id + JOIN conversation_members cm2 ON c.id = cm2.conversation_id + WHERE c.is_group = FALSE + AND cm1.user_id = %s AND cm2.user_id = %s + """, + (user_ids[0], user_ids[1]) + ) + existing = cur.fetchone() + if existing: + return get_conversation(existing["id"]) + + # Create conversation + cur.execute( + """ + INSERT INTO conversations (name, is_group, created_by) + VALUES (%s, %s, %s) + RETURNING id, name, is_group, created_by, created_at + """, + (name, is_group, created_by) + ) + conversation = dict(cur.fetchone()) + conversation_id = conversation["id"] + + # Add members + for user_id in user_ids: + cur.execute( + "INSERT INTO conversation_members (conversation_id, user_id) VALUES (%s, %s)", + (conversation_id, user_id) + ) + + conn.commit() + + # Return conversation with conversation_id field + conversation["conversation_id"] = conversation["id"] + return conversation + finally: + cur.close() + conn.close() + + +def get_conversation(conversation_id: int): + """Get conversation details""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT id, name, is_group, created_by, created_at FROM conversations WHERE id = %s", + (conversation_id,) + ) + conversation = cur.fetchone() + if not conversation: + return None + + # Get members + cur.execute( + """ + SELECT u.id, u.username, u.display_name + FROM conversation_members cm + JOIN users u ON u.id = cm.user_id + WHERE cm.conversation_id = %s + """, + (conversation_id,) + ) + members = [dict(row) for row in cur.fetchall()] + + result = dict(conversation) + result["members"] = members + return result + finally: + cur.close() + conn.close() + + +def get_user_conversations(user_id: int): + """Get all conversations for a user""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + SELECT c.id AS conversation_id, c.name, c.is_group, c.created_at, + (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id AND m.created_at > cm.last_read_at) AS unread_count, + (SELECT m.content FROM messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) AS last_message, + (SELECT m.created_at FROM messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) AS last_message_at + FROM conversations c + JOIN conversation_members cm ON c.id = cm.conversation_id + WHERE cm.user_id = %s + ORDER BY last_message_at DESC NULLS LAST, c.created_at DESC + """, + (user_id,) + ) + conversations = [dict(row) for row in cur.fetchall()] + + # Get members for each conversation and add other_member_name for private chats + for conv in conversations: + cur.execute( + """ + SELECT u.id, u.username, u.display_name, u.email + FROM conversation_members cm + JOIN users u ON u.id = cm.user_id + WHERE cm.conversation_id = %s AND u.id != %s + """, + (conv["conversation_id"], user_id) + ) + members = [dict(row) for row in cur.fetchall()] + conv["members"] = members + + # For private chats, add other_member_name + if not conv["is_group"] and len(members) > 0: + conv["other_member_name"] = members[0].get("display_name") or members[0].get("username") or members[0].get("email") + + return conversations + finally: + cur.close() + conn.close() + + +def send_message(conversation_id: int, sender_id: int, content: str): + """Send a message in a conversation""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + # Verify user is member of conversation + cur.execute( + "SELECT 1 FROM conversation_members WHERE conversation_id = %s AND user_id = %s", + (conversation_id, sender_id) + ) + if not cur.fetchone(): + return {"error": "Not a member of this conversation"} + + cur.execute( + """ + INSERT INTO messages (conversation_id, sender_id, content) + VALUES (%s, %s, %s) + RETURNING id, conversation_id, sender_id, content, created_at + """, + (conversation_id, sender_id, content) + ) + message = cur.fetchone() + + # Update conversation updated_at + cur.execute( + "UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = %s", + (conversation_id,) + ) + + conn.commit() + return dict(message) + finally: + cur.close() + conn.close() + + +def get_messages(conversation_id: int, user_id: int, limit: int = 50, before_id: Optional[int] = None): + """Get messages from a conversation""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + # Verify user is member + cur.execute( + "SELECT 1 FROM conversation_members WHERE conversation_id = %s AND user_id = %s", + (conversation_id, user_id) + ) + if not cur.fetchone(): + return {"error": "Not a member of this conversation"} + + # Get messages + if before_id: + cur.execute( + """ + SELECT m.id AS message_id, m.sender_id, m.content, m.created_at, m.edited_at, + u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email, + CASE WHEN m.sender_id = %s THEN TRUE ELSE FALSE END AS is_mine + FROM messages m + JOIN users u ON u.id = m.sender_id + WHERE m.conversation_id = %s AND m.is_deleted = FALSE AND m.id < %s + ORDER BY m.created_at DESC + LIMIT %s + """, + (user_id, conversation_id, before_id, limit) + ) + else: + cur.execute( + """ + SELECT m.id AS message_id, m.sender_id, m.content, m.created_at, m.edited_at, + u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email, + CASE WHEN m.sender_id = %s THEN TRUE ELSE FALSE END AS is_mine + FROM messages m + JOIN users u ON u.id = m.sender_id + WHERE m.conversation_id = %s AND m.is_deleted = FALSE + ORDER BY m.created_at DESC + LIMIT %s + """, + (user_id, conversation_id, limit) + ) + + messages = [dict(row) for row in cur.fetchall()] + messages.reverse() # Return in chronological order + + # Mark as read + cur.execute( + "UPDATE conversation_members SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = %s AND user_id = %s", + (conversation_id, user_id) + ) + conn.commit() + + return messages + finally: + cur.close() + conn.close() diff --git a/backend/db_utils.py b/backend/db_utils.py index f2c1881..afbe4a1 100644 --- a/backend/db_utils.py +++ b/backend/db_utils.py @@ -48,20 +48,43 @@ def get_conn(): return psycopg2.connect(dsn, cursor_factory=RealDictCursor) -def list_recipes_db() -> List[Dict[str, Any]]: +def list_recipes_db(user_id: Optional[int] = None) -> List[Dict[str, Any]]: + """List recipes visible to the user. If user_id is None, only show public recipes.""" conn = get_conn() try: with conn.cursor() as cur: - cur.execute( - """ - SELECT r.id, r.name, r.meal_type, r.time_minutes, - r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id, - u.display_name as owner_display_name - FROM recipes r - LEFT JOIN users u ON r.user_id = u.id - ORDER BY r.id - """ - ) + if user_id is None: + # Not authenticated - only public recipes + cur.execute( + """ + SELECT r.id, r.name, r.meal_type, r.time_minutes, + r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id, + r.visibility, u.display_name as owner_display_name + FROM recipes r + LEFT JOIN users u ON r.user_id = u.id + WHERE r.visibility = 'public' + ORDER BY r.id + """ + ) + else: + # Authenticated - show public, own recipes, friends' recipes + cur.execute( + """ + SELECT r.id, r.name, r.meal_type, r.time_minutes, + r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id, + r.visibility, u.display_name as owner_display_name + FROM recipes r + LEFT JOIN users u ON r.user_id = u.id + WHERE r.visibility = 'public' + OR r.user_id = %s + OR (r.visibility = 'friends' AND EXISTS ( + SELECT 1 FROM friendships f + WHERE f.user_id = %s AND f.friend_id = r.user_id + )) + ORDER BY r.id + """, + (user_id, user_id) + ) rows = cur.fetchall() return rows finally: @@ -70,7 +93,7 @@ def list_recipes_db() -> List[Dict[str, Any]]: def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ עדכון מתכון קיים לפי id. - recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by + recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, visibility """ conn = get_conn() try: @@ -85,9 +108,10 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di ingredients = %s, steps = %s, image = %s, - made_by = %s + made_by = %s, + visibility = %s WHERE id = %s - RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id + RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility """, ( recipe_data["name"], @@ -98,6 +122,7 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di json.dumps(recipe_data.get("steps", [])), recipe_data.get("image"), recipe_data.get("made_by"), + recipe_data.get("visibility", "public"), recipe_id, ), ) @@ -135,9 +160,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]: with conn.cursor() as cur: cur.execute( """ - INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id + INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility """, ( recipe_data["name"], @@ -149,6 +174,7 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]: recipe_data.get("image"), recipe_data.get("made_by"), recipe_data.get("user_id"), + recipe_data.get("visibility", "public"), ), ) row = cur.fetchone() @@ -161,19 +187,34 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]: def get_recipes_by_filters_db( meal_type: Optional[str], max_time: Optional[int], + user_id: Optional[int] = None, ) -> List[Dict[str, Any]]: conn = get_conn() try: query = """ SELECT r.id, r.name, r.meal_type, r.time_minutes, r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id, - u.display_name as owner_display_name + r.visibility, u.display_name as owner_display_name FROM recipes r LEFT JOIN users u ON r.user_id = u.id WHERE 1=1 """ params: List = [] + # Visibility filter + if user_id is None: + query += " AND r.visibility = 'public'" + else: + query += """ AND ( + r.visibility = 'public' + OR r.user_id = %s + OR (r.visibility = 'friends' AND EXISTS ( + SELECT 1 FROM friendships f + WHERE f.user_id = %s AND f.friend_id = r.user_id + )) + )""" + params.extend([user_id, user_id]) + if meal_type: query += " AND r.meal_type = %s" params.append(meal_type.lower()) diff --git a/backend/groups_db_utils.py b/backend/groups_db_utils.py new file mode 100644 index 0000000..aafcf6f --- /dev/null +++ b/backend/groups_db_utils.py @@ -0,0 +1,380 @@ +import os +import psycopg2 +from psycopg2.extras import RealDictCursor +from typing import List, Optional + + +def get_db_connection(): + """Get database connection""" + return psycopg2.connect( + host=os.getenv("DB_HOST", "localhost"), + port=int(os.getenv("DB_PORT", "5432")), + database=os.getenv("DB_NAME", "recipes_db"), + user=os.getenv("DB_USER", "recipes_user"), + password=os.getenv("DB_PASSWORD", "recipes_password"), + ) + + +# ============= Groups ============= + +def create_group(name: str, description: str, created_by: int, is_private: bool = False): + """Create a new group""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + INSERT INTO groups (name, description, created_by, is_private) + VALUES (%s, %s, %s, %s) + RETURNING id, name, description, created_by, is_private, created_at + """, + (name, description, created_by, is_private) + ) + group = dict(cur.fetchone()) + + # Add creator as admin + cur.execute( + "INSERT INTO group_members (group_id, user_id, role) VALUES (%s, %s, 'admin')", + (group["id"], created_by) + ) + + conn.commit() + return group + finally: + cur.close() + conn.close() + + +def get_group(group_id: int): + """Get group details""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT id, name, description, created_by, is_private, created_at FROM groups WHERE id = %s", + (group_id,) + ) + group = cur.fetchone() + if not group: + return None + + # Get members + cur.execute( + """ + SELECT gm.role, gm.joined_at, u.id, u.username, u.display_name + FROM group_members gm + JOIN users u ON u.id = gm.user_id + WHERE gm.group_id = %s + ORDER BY gm.role, u.display_name + """, + (group_id,) + ) + members = [dict(row) for row in cur.fetchall()] + + result = dict(group) + result["members"] = members + result["member_count"] = len(members) + return result + finally: + cur.close() + conn.close() + + +def get_user_groups(user_id: int): + """Get all groups user is member of""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + SELECT g.id, g.name, g.description, g.is_private, g.created_at, gm.role, + (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count + FROM groups g + JOIN group_members gm ON g.id = gm.group_id + WHERE gm.user_id = %s + ORDER BY g.name + """, + (user_id,) + ) + return [dict(row) for row in cur.fetchall()] + finally: + cur.close() + conn.close() + + +def add_group_member(group_id: int, user_id: int, added_by: int): + """Add a member to a group""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + # Check if added_by is admin/moderator + cur.execute( + "SELECT role FROM group_members WHERE group_id = %s AND user_id = %s", + (group_id, added_by) + ) + adder = cur.fetchone() + if not adder or adder["role"] not in ["admin", "moderator"]: + return {"error": "Only admins and moderators can add members"} + + cur.execute( + """ + INSERT INTO group_members (group_id, user_id, role) + VALUES (%s, %s, 'member') + ON CONFLICT (group_id, user_id) DO NOTHING + RETURNING id + """, + (group_id, user_id) + ) + result = cur.fetchone() + conn.commit() + + if result: + return {"success": True} + else: + return {"error": "User is already a member"} + finally: + cur.close() + conn.close() + + +def remove_group_member(group_id: int, user_id: int, removed_by: int): + """Remove a member from a group""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + # Check permissions + cur.execute( + "SELECT role FROM group_members WHERE group_id = %s AND user_id = %s", + (group_id, removed_by) + ) + remover = cur.fetchone() + + # User can remove themselves, or admins/moderators can remove others + if removed_by != user_id: + if not remover or remover["role"] not in ["admin", "moderator"]: + return {"error": "Only admins and moderators can remove members"} + + cur.execute( + "DELETE FROM group_members WHERE group_id = %s AND user_id = %s", + (group_id, user_id) + ) + conn.commit() + return {"success": True} + finally: + cur.close() + conn.close() + + +# ============= Recipe Ratings & Comments ============= + +def add_or_update_rating(recipe_id: int, user_id: int, rating: int): + """Add or update a rating for a recipe""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + INSERT INTO recipe_ratings (recipe_id, user_id, rating) + VALUES (%s, %s, %s) + ON CONFLICT (recipe_id, user_id) + DO UPDATE SET rating = EXCLUDED.rating, updated_at = CURRENT_TIMESTAMP + RETURNING id, recipe_id, user_id, rating, created_at, updated_at + """, + (recipe_id, user_id, rating) + ) + result = cur.fetchone() + conn.commit() + return dict(result) + finally: + cur.close() + conn.close() + + +def get_recipe_rating_stats(recipe_id: int): + """Get rating statistics for a recipe""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + SELECT + COUNT(*) as rating_count, + AVG(rating)::DECIMAL(3,2) as average_rating, + COUNT(CASE WHEN rating = 5 THEN 1 END) as five_star, + COUNT(CASE WHEN rating = 4 THEN 1 END) as four_star, + COUNT(CASE WHEN rating = 3 THEN 1 END) as three_star, + COUNT(CASE WHEN rating = 2 THEN 1 END) as two_star, + COUNT(CASE WHEN rating = 1 THEN 1 END) as one_star + FROM recipe_ratings + WHERE recipe_id = %s + """, + (recipe_id,) + ) + return dict(cur.fetchone()) + finally: + cur.close() + conn.close() + + +def get_user_recipe_rating(recipe_id: int, user_id: int): + """Get user's rating for a recipe""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT rating FROM recipe_ratings WHERE recipe_id = %s AND user_id = %s", + (recipe_id, user_id) + ) + result = cur.fetchone() + return dict(result) if result else None + finally: + cur.close() + conn.close() + + +def add_comment(recipe_id: int, user_id: int, content: str, parent_comment_id: Optional[int] = None): + """Add a comment to a recipe""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + INSERT INTO recipe_comments (recipe_id, user_id, content, parent_comment_id) + VALUES (%s, %s, %s, %s) + RETURNING id, recipe_id, user_id, content, parent_comment_id, created_at + """, + (recipe_id, user_id, content, parent_comment_id) + ) + result = cur.fetchone() + conn.commit() + return dict(result) + finally: + cur.close() + conn.close() + + +def get_recipe_comments(recipe_id: int): + """Get all comments for a recipe""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + SELECT c.id, c.content, c.parent_comment_id, c.created_at, c.updated_at, + u.id AS user_id, u.username, u.display_name + FROM recipe_comments c + JOIN users u ON u.id = c.user_id + WHERE c.recipe_id = %s AND c.is_deleted = FALSE + ORDER BY c.created_at ASC + """, + (recipe_id,) + ) + return [dict(row) for row in cur.fetchall()] + finally: + cur.close() + conn.close() + + +def update_comment(comment_id: int, user_id: int, content: str): + """Update a comment""" + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute( + """ + UPDATE recipe_comments + SET content = %s, updated_at = CURRENT_TIMESTAMP + WHERE id = %s AND user_id = %s AND is_deleted = FALSE + """, + (content, comment_id, user_id) + ) + conn.commit() + return {"success": True} + finally: + cur.close() + conn.close() + + +def delete_comment(comment_id: int, user_id: int): + """Soft delete a comment""" + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute( + "UPDATE recipe_comments SET is_deleted = TRUE WHERE id = %s AND user_id = %s", + (comment_id, user_id) + ) + conn.commit() + return {"success": True} + finally: + cur.close() + conn.close() + + +# ============= Recipe Shares to Groups ============= + +def share_recipe_to_group(recipe_id: int, group_id: int, user_id: int): + """Share a recipe to a group""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + # Check if user is member of group + cur.execute( + "SELECT 1 FROM group_members WHERE group_id = %s AND user_id = %s", + (group_id, user_id) + ) + if not cur.fetchone(): + return {"error": "Not a member of this group"} + + cur.execute( + """ + INSERT INTO recipe_shares (recipe_id, group_id, shared_by) + VALUES (%s, %s, %s) + ON CONFLICT (recipe_id, group_id) DO NOTHING + RETURNING id + """, + (recipe_id, group_id, user_id) + ) + result = cur.fetchone() + conn.commit() + + if result: + return {"success": True} + else: + return {"error": "Recipe already shared to this group"} + finally: + cur.close() + conn.close() + + +def get_group_recipes(group_id: int, user_id: int): + """Get all recipes shared to a group""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + # Verify user is member + cur.execute( + "SELECT 1 FROM group_members WHERE group_id = %s AND user_id = %s", + (group_id, user_id) + ) + if not cur.fetchone(): + return {"error": "Not a member of this group"} + + cur.execute( + """ + SELECT r.*, u.username AS owner_username, u.display_name AS owner_display_name, + rs.shared_at, rs.shared_by, + u2.username AS shared_by_username, u2.display_name AS shared_by_display_name + FROM recipe_shares rs + JOIN recipes r ON r.id = rs.recipe_id + JOIN users u ON u.id = r.user_id + JOIN users u2 ON u2.id = rs.shared_by + WHERE rs.group_id = %s + ORDER BY rs.shared_at DESC + """, + (group_id,) + ) + return [dict(row) for row in cur.fetchall()] + finally: + cur.close() + conn.close() diff --git a/backend/main.py b/backend/main.py index 880a062..84d0c3a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -24,6 +24,7 @@ from auth_utils import ( verify_password, create_access_token, get_current_user, + get_current_user_optional, ACCESS_TOKEN_EXPIRE_MINUTES, ) @@ -64,6 +65,9 @@ from email_utils import ( from oauth_utils import oauth +# Import routers +from routers import friends, chat, groups, ratings_comments + class RecipeBase(BaseModel): name: str @@ -74,6 +78,7 @@ class RecipeBase(BaseModel): ingredients: List[str] = [] steps: List[str] = [] image: Optional[str] = None # Base64-encoded image or image URL + visibility: str = "public" # public, private, friends, groups class RecipeCreate(RecipeBase): @@ -237,10 +242,17 @@ app.add_middleware( allow_headers=["*"], ) +# Include social network routers +app.include_router(friends.router) +app.include_router(chat.router) +app.include_router(groups.router) +app.include_router(ratings_comments.router) + @app.get("/recipes", response_model=List[Recipe]) -def list_recipes(): - rows = list_recipes_db() +def list_recipes(current_user: Optional[dict] = Depends(get_current_user_optional)): + user_id = current_user["user_id"] if current_user else None + rows = list_recipes_db(user_id) recipes = [ Recipe( id=r["id"], @@ -254,6 +266,7 @@ def list_recipes(): image=r.get("image"), user_id=r.get("user_id"), owner_display_name=r.get("owner_display_name"), + visibility=r.get("visibility", "public"), ) for r in rows ] @@ -280,6 +293,7 @@ def create_recipe(recipe_in: RecipeCreate, current_user: dict = Depends(get_curr image=row.get("image"), user_id=row.get("user_id"), owner_display_name=current_user.get("display_name"), + visibility=row.get("visibility", "public"), ) @app.put("/recipes/{recipe_id}", response_model=Recipe) @@ -317,6 +331,7 @@ def update_recipe(recipe_id: int, recipe_in: RecipeUpdate, current_user: dict = image=row.get("image"), user_id=row.get("user_id"), owner_display_name=current_user.get("display_name"), + visibility=row.get("visibility", "public"), ) @@ -350,8 +365,10 @@ def random_recipe( None, description="רשימת מרכיבים (ingredients=ביצה&ingredients=עגבניה...)", ), + current_user: Optional[dict] = Depends(get_current_user_optional), ): - rows = get_recipes_by_filters_db(meal_type, max_time) + user_id = current_user["user_id"] if current_user else None + rows = get_recipes_by_filters_db(meal_type, max_time, user_id) recipes = [ Recipe( @@ -366,6 +383,7 @@ def random_recipe( image=r.get("image"), user_id=r.get("user_id"), owner_display_name=r.get("owner_display_name"), + visibility=r.get("visibility", "public"), ) for r in rows ] diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..5bb3dc2 --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1 @@ +# Router package initialization diff --git a/backend/routers/chat.py b/backend/routers/chat.py new file mode 100644 index 0000000..7bce561 --- /dev/null +++ b/backend/routers/chat.py @@ -0,0 +1,88 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import List, Optional +from auth_utils import get_current_user +from chat_db_utils import ( + create_conversation, + get_conversation, + get_user_conversations, + send_message, + get_messages, +) + +router = APIRouter(prefix="/conversations", tags=["chat"]) + + +class ConversationCreate(BaseModel): + user_ids: List[int] + is_group: bool = False + name: Optional[str] = None + + +class MessageCreate(BaseModel): + content: str + + +@router.post("") +def create_conversation_endpoint( + data: ConversationCreate, + current_user: dict = Depends(get_current_user) +): + """Create a new conversation (private or group chat)""" + user_ids = data.user_ids + if current_user["user_id"] not in user_ids: + user_ids.append(current_user["user_id"]) + + return create_conversation( + user_ids=user_ids, + is_group=data.is_group, + name=data.name, + created_by=current_user["user_id"] + ) + + +@router.get("") +def get_conversations_endpoint( + current_user: dict = Depends(get_current_user) +): + """Get all conversations for current user""" + return get_user_conversations(current_user["user_id"]) + + +@router.get("/{conversation_id}") +def get_conversation_endpoint( + conversation_id: int, + current_user: dict = Depends(get_current_user) +): + """Get conversation details""" + conversation = get_conversation(conversation_id) + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + return conversation + + +@router.post("/{conversation_id}/messages") +def send_message_endpoint( + conversation_id: int, + data: MessageCreate, + current_user: dict = Depends(get_current_user) +): + """Send a message in a conversation""" + result = send_message(conversation_id, current_user["user_id"], data.content) + if "error" in result: + raise HTTPException(status_code=403, detail=result["error"]) + return result + + +@router.get("/{conversation_id}/messages") +def get_messages_endpoint( + conversation_id: int, + limit: int = Query(50, le=100), + before_id: Optional[int] = None, + current_user: dict = Depends(get_current_user) +): + """Get messages from a conversation""" + result = get_messages(conversation_id, current_user["user_id"], limit, before_id) + if isinstance(result, dict) and "error" in result: + raise HTTPException(status_code=403, detail=result["error"]) + return result diff --git a/backend/routers/friends.py b/backend/routers/friends.py new file mode 100644 index 0000000..f82042d --- /dev/null +++ b/backend/routers/friends.py @@ -0,0 +1,152 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import List +from auth_utils import get_current_user +from social_db_utils import ( + send_friend_request, + accept_friend_request, + reject_friend_request, + get_friend_requests, + get_friends, + remove_friend, + search_users, +) +from notification_db_utils import create_notification +from user_db_utils import get_user_by_id + +router = APIRouter(prefix="/friends", tags=["friends"]) + + +class FriendRequestModel(BaseModel): + receiver_id: int + + +@router.post("/request") +def send_friend_request_endpoint( + request: FriendRequestModel, + current_user: dict = Depends(get_current_user) +): + """Send a friend request to another user""" + result = send_friend_request(current_user["user_id"], request.receiver_id) + if "error" in result: + raise HTTPException(status_code=400, detail=result["error"]) + + # Create notification for receiver + create_notification( + user_id=request.receiver_id, + type="friend_request", + message=f"{current_user['display_name']} שלח לך בקשת חברות", + related_id=result.get("id") + ) + + return result + + +@router.get("/requests") +def get_friend_requests_endpoint( + current_user: dict = Depends(get_current_user) +): + """Get pending friend requests""" + return get_friend_requests(current_user["user_id"]) + + +@router.post("/requests/{request_id}/accept") +def accept_friend_request_endpoint( + request_id: int, + current_user: dict = Depends(get_current_user) +): + """Accept a friend request""" + # Get request details before accepting + from social_db_utils import get_db_connection + from psycopg2.extras import RealDictCursor + + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'", + (request_id,) + ) + request_data = cur.fetchone() + + # Verify current user is the receiver + if not request_data: + raise HTTPException(status_code=404, detail="Request not found") + if request_data["receiver_id"] != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Not authorized to accept this request") + finally: + cur.close() + conn.close() + + result = accept_friend_request(request_id) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + + # Create notification for sender that their request was accepted + if request_data: + create_notification( + user_id=request_data["sender_id"], + type="friend_accepted", + message=f"{current_user['display_name']} קיבל את בקשת החברות שלך", + related_id=current_user["user_id"] + ) + + return result + + +@router.post("/requests/{request_id}/reject") +def reject_friend_request_endpoint( + request_id: int, + current_user: dict = Depends(get_current_user) +): + """Reject a friend request""" + # Verify current user is the receiver + from social_db_utils import get_db_connection + from psycopg2.extras import RealDictCursor + + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'", + (request_id,) + ) + request_data = cur.fetchone() + + if not request_data: + raise HTTPException(status_code=404, detail="Request not found") + if request_data["receiver_id"] != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Not authorized to reject this request") + finally: + cur.close() + conn.close() + + return reject_friend_request(request_id) + + +@router.get("") +def get_friends_endpoint( + current_user: dict = Depends(get_current_user) +): + """Get list of user's friends""" + return get_friends(current_user["user_id"]) + + +@router.delete("/{friend_id}") +def remove_friend_endpoint( + friend_id: int, + current_user: dict = Depends(get_current_user) +): + """Remove a friend""" + from fastapi.responses import Response + remove_friend(current_user["user_id"], friend_id) + return Response(status_code=204) + + +@router.get("/search") +def search_users_for_friends_endpoint( + q: str = Query(..., min_length=1), + current_user: dict = Depends(get_current_user) +): + """Search users to add as friends""" + return search_users(q, current_user["user_id"]) diff --git a/backend/routers/groups.py b/backend/routers/groups.py new file mode 100644 index 0000000..3927557 --- /dev/null +++ b/backend/routers/groups.py @@ -0,0 +1,163 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel +from typing import Optional +from auth_utils import get_current_user +from groups_db_utils import ( + create_group, + get_group, + get_user_groups, + add_group_member, + remove_group_member, + share_recipe_to_group, + get_group_recipes, +) +from notification_db_utils import create_notification + +router = APIRouter(prefix="/groups", tags=["groups"]) + + +class GroupCreate(BaseModel): + name: str + description: str = "" + is_private: bool = False + + +@router.post("") +def create_group_endpoint( + data: GroupCreate, + current_user: dict = Depends(get_current_user) +): + """Create a new group""" + return create_group( + name=data.name, + description=data.description, + created_by=current_user["user_id"], + is_private=data.is_private + ) + + +@router.get("") +def get_user_groups_endpoint( + current_user: dict = Depends(get_current_user) +): + """Get all groups user is member of""" + return get_user_groups(current_user["user_id"]) + + +@router.get("/{group_id}") +def get_group_endpoint( + group_id: int, + current_user: dict = Depends(get_current_user) +): + """Get group details""" + group = get_group(group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + return group + + +@router.post("/{group_id}/members/{user_id}") +def add_group_member_endpoint( + group_id: int, + user_id: int, + current_user: dict = Depends(get_current_user) +): + """Add a member to a group""" + # Get group name + group = get_group(group_id) + + result = add_group_member(group_id, user_id, current_user["user_id"]) + if "error" in result: + raise HTTPException(status_code=403, detail=result["error"]) + + # Notify the added user + if group: + create_notification( + user_id=user_id, + type="group_invite", + message=f"{current_user['display_name']} הוסיף אותך לקבוצה '{group['name']}'" , + related_id=group_id + ) + + return result + + +@router.delete("/{group_id}/members/{user_id}") +def remove_group_member_endpoint( + group_id: int, + user_id: int, + current_user: dict = Depends(get_current_user) +): + """Remove a member from a group""" + result = remove_group_member(group_id, user_id, current_user["user_id"]) + if "error" in result: + raise HTTPException(status_code=403, detail=result["error"]) + return Response(status_code=204) + + +@router.post("/{group_id}/recipes/{recipe_id}") +def share_recipe_to_group_endpoint( + group_id: int, + recipe_id: int, + current_user: dict = Depends(get_current_user) +): + """Share a recipe to a group""" + from groups_db_utils import get_db_connection + from psycopg2.extras import RealDictCursor + from db_utils import get_conn + + # Get group members and names + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """SELECT gm.user_id, g.name as group_name + FROM group_members gm + JOIN groups g ON gm.group_id = g.id + WHERE gm.group_id = %s AND gm.user_id != %s""", + (group_id, current_user["user_id"]) + ) + members = cur.fetchall() + finally: + cur.close() + conn.close() + + # Get recipe name + recipe_conn = get_conn() + recipe_cur = recipe_conn.cursor(cursor_factory=RealDictCursor) + try: + recipe_cur.execute("SELECT name FROM recipes WHERE id = %s", (recipe_id,)) + recipe = recipe_cur.fetchone() + finally: + recipe_cur.close() + recipe_conn.close() + + result = share_recipe_to_group(recipe_id, group_id, current_user["user_id"]) + if "error" in result: + raise HTTPException(status_code=403, detail=result["error"]) + + # Notify all group members except the sharer + if members and recipe: + group_name = members[0]["group_name"] if members else "" + for member in members: + create_notification( + user_id=member["user_id"], + type="recipe_shared", + message=f"{current_user['display_name']} שיתף מתכון '{recipe['name']}' בקבוצה '{group_name}'", + related_id=recipe_id + ) + + return result + + +@router.get("/{group_id}/recipes") +def get_group_recipes_endpoint( + group_id: int, + current_user: dict = Depends(get_current_user) +): + """Get all recipes shared to a group""" + result = get_group_recipes(group_id, current_user["user_id"]) + if isinstance(result, dict) and "error" in result: + raise HTTPException(status_code=403, detail=result["error"]) + return result diff --git a/backend/routers/ratings_comments.py b/backend/routers/ratings_comments.py new file mode 100644 index 0000000..119e9a4 --- /dev/null +++ b/backend/routers/ratings_comments.py @@ -0,0 +1,91 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel +from typing import Optional +from auth_utils import get_current_user +from groups_db_utils import ( + add_or_update_rating, + get_recipe_rating_stats, + get_user_recipe_rating, + add_comment, + get_recipe_comments, + update_comment, + delete_comment, +) + +router = APIRouter(tags=["ratings-comments"]) + + +class RatingCreate(BaseModel): + rating: int + + +class CommentCreate(BaseModel): + content: str + parent_comment_id: Optional[int] = None + + +@router.post("/recipes/{recipe_id}/rating") +def rate_recipe_endpoint( + recipe_id: int, + data: RatingCreate, + current_user: dict = Depends(get_current_user) +): + """Add or update rating for a recipe""" + if data.rating < 1 or data.rating > 5: + raise HTTPException(status_code=400, detail="Rating must be between 1 and 5") + return add_or_update_rating(recipe_id, current_user["user_id"], data.rating) + + +@router.get("/recipes/{recipe_id}/rating/stats") +def get_recipe_rating_stats_endpoint(recipe_id: int): + """Get rating statistics for a recipe""" + return get_recipe_rating_stats(recipe_id) + + +@router.get("/recipes/{recipe_id}/rating/mine") +def get_my_recipe_rating_endpoint( + recipe_id: int, + current_user: dict = Depends(get_current_user) +): + """Get current user's rating for a recipe""" + rating = get_user_recipe_rating(recipe_id, current_user["user_id"]) + if not rating: + return {"rating": None} + return rating + + +@router.post("/recipes/{recipe_id}/comments") +def add_comment_endpoint( + recipe_id: int, + data: CommentCreate, + current_user: dict = Depends(get_current_user) +): + """Add a comment to a recipe""" + return add_comment(recipe_id, current_user["user_id"], data.content, data.parent_comment_id) + + +@router.get("/recipes/{recipe_id}/comments") +def get_comments_endpoint(recipe_id: int): + """Get all comments for a recipe""" + return get_recipe_comments(recipe_id) + + +@router.patch("/comments/{comment_id}") +def update_comment_endpoint( + comment_id: int, + data: CommentCreate, + current_user: dict = Depends(get_current_user) +): + """Update a comment""" + return update_comment(comment_id, current_user["user_id"], data.content) + + +@router.delete("/comments/{comment_id}") +def delete_comment_endpoint( + comment_id: int, + current_user: dict = Depends(get_current_user) +): + """Delete a comment""" + delete_comment(comment_id, current_user["user_id"]) + return Response(status_code=204) diff --git a/backend/schema.sql b/backend/schema.sql index 5b61e17..21ab033 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -87,4 +87,42 @@ INSERT INTO users (username, email, password_hash, first_name, last_name, displa VALUES ('admin', 'admin@myrecipes.local', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5lE7UGf3rCvHC', 'Admin', 'User', 'מנהל', TRUE) ON CONFLICT (username) DO NOTHING; +-- Create demo recipes +INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, made_by, user_id, visibility) +VALUES +( + 'שקשוקה ביתית', + 'breakfast', + 25, + ARRAY['מהיר', 'בריא', 'צמחוני'], + ARRAY['4 ביצים', '2 עגבניות גדולות', '1 בצל', '2 שיני שום', 'פלפל אדום', 'כוסברה', 'כמון', 'מלח ופלפל'], + ARRAY['לחתוך את הבצל והשום דק', 'לחמם שמן בסיר ולהזהיב את הבצל', 'להוסיף עגבניות קצוצות ותבלינים', 'לבשל 15 דקות עד שמתעבה', 'לפתוח גומות ולשבור ביצים', 'לכסות ולבשל 5 דקות', 'לקשט בכוסברה ולהגיש עם לחם'], + 'מנהל', + (SELECT id FROM users WHERE username = 'admin'), + 'public' +), +( + 'פסטה ברוטב שמנת ופטריות', + 'lunch', + 30, + ARRAY['מהיר', 'מנת ערב', 'איטלקי'], + ARRAY['500 גרם פסטה', '300 גרם פטריות', '200 מ"ל שמנת מתוקה', '2 שיני שום', 'פרמזן', 'חמאה', 'פטרוזיליה', 'מלח ופלפל'], + ARRAY['להרתיח מים ולבשל את הפסטה לפי ההוראות', 'לחתוך פטריות ושום דק', 'לחמם חמאה ולטגן פטריות 5 דקות', 'להוסיף שום ולטגן דקה', 'להוסיף שמנת ופרמזן ולערבב', 'להוסיף את הפסטה המסוננת לרוטב', 'לערבב היטב ולהגיש עם פרמזן'], + 'מנהל', + (SELECT id FROM users WHERE username = 'admin'), + 'public' +), +( + 'עוגת שוקולד פאדג׳', + 'snack', + 45, + ARRAY['קינוח', 'שוקולד', 'מתוק'], + ARRAY['200 גרם שוקולד מריר', '150 גרם חמאה', '3 ביצים', '1 כוס סוכר', '3/4 כוס קמח', 'אבקת אפייה', 'וניל'], + ARRAY['לחמם תנור ל-180 מעלות', 'להמיס שוקולד וחמאה במיקרו', 'להקציף ביצים עם סוכר', 'להוסיף שוקולד מומס ולערבב', 'להוסיף קמח ואבקת אפייה', 'לשפוך לתבנית משומנת', 'לאפות 30 דקות', 'להוציא ולהגיש עם גלידה'], + 'מנהל', + (SELECT id FROM users WHERE username = 'admin'), + 'public' +) +ON CONFLICT DO NOTHING; + diff --git a/backend/social_db_utils.py b/backend/social_db_utils.py new file mode 100644 index 0000000..f29de4e --- /dev/null +++ b/backend/social_db_utils.py @@ -0,0 +1,189 @@ +import os +import psycopg2 +from psycopg2.extras import RealDictCursor + + +def get_db_connection(): + """Get database connection""" + return psycopg2.connect( + host=os.getenv("DB_HOST", "localhost"), + port=int(os.getenv("DB_PORT", "5432")), + database=os.getenv("DB_NAME", "recipes_db"), + user=os.getenv("DB_USER", "recipes_user"), + password=os.getenv("DB_PASSWORD", "recipes_password"), + ) + + +# ============= Friends System ============= + +def send_friend_request(sender_id: int, receiver_id: int): + """Send a friend request""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + # Check if already friends + cur.execute( + "SELECT 1 FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)", + (sender_id, receiver_id, receiver_id, sender_id) + ) + if cur.fetchone(): + return {"error": "Already friends"} + + # Check if request already exists + cur.execute( + "SELECT * FROM friend_requests WHERE sender_id = %s AND receiver_id = %s AND status = 'pending'", + (sender_id, receiver_id) + ) + existing = cur.fetchone() + if existing: + return dict(existing) + + cur.execute( + """ + INSERT INTO friend_requests (sender_id, receiver_id) + VALUES (%s, %s) + RETURNING id, sender_id, receiver_id, status, created_at + """, + (sender_id, receiver_id) + ) + request = cur.fetchone() + conn.commit() + return dict(request) + finally: + cur.close() + conn.close() + + +def accept_friend_request(request_id: int): + """Accept a friend request and create friendship""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + # Get request details + cur.execute( + "SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'", + (request_id,) + ) + request = cur.fetchone() + if not request: + return {"error": "Request not found or already processed"} + + sender_id = request["sender_id"] + receiver_id = request["receiver_id"] + + # Create bidirectional friendship + cur.execute( + "INSERT INTO friendships (user_id, friend_id) VALUES (%s, %s), (%s, %s) ON CONFLICT DO NOTHING", + (sender_id, receiver_id, receiver_id, sender_id) + ) + + # Update request status + cur.execute( + "UPDATE friend_requests SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s", + (request_id,) + ) + + conn.commit() + return {"success": True} + finally: + cur.close() + conn.close() + + +def reject_friend_request(request_id: int): + """Reject a friend request""" + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute( + "UPDATE friend_requests SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = %s AND status = 'pending'", + (request_id,) + ) + conn.commit() + return {"success": True} + finally: + cur.close() + conn.close() + + +def get_friend_requests(user_id: int): + """Get pending friend requests for a user""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + SELECT fr.id AS request_id, fr.sender_id, fr.receiver_id, fr.status, fr.created_at, + u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email + FROM friend_requests fr + JOIN users u ON u.id = fr.sender_id + WHERE fr.receiver_id = %s AND fr.status = 'pending' + ORDER BY fr.created_at DESC + """, + (user_id,) + ) + return [dict(row) for row in cur.fetchall()] + finally: + cur.close() + conn.close() + + +def get_friends(user_id: int): + """Get list of user's friends""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + SELECT u.id, u.username, u.display_name, u.email, f.created_at AS friends_since + FROM friendships f + JOIN users u ON u.id = f.friend_id + WHERE f.user_id = %s + ORDER BY u.display_name + """, + (user_id,) + ) + return [dict(row) for row in cur.fetchall()] + finally: + cur.close() + conn.close() + + +def remove_friend(user_id: int, friend_id: int): + """Remove a friend""" + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute( + "DELETE FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)", + (user_id, friend_id, friend_id, user_id) + ) + conn.commit() + return {"success": True} + finally: + cur.close() + conn.close() + + +def search_users(query: str, current_user_id: int, limit: int = 20): + """Search for users by username or display name""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + search_pattern = f"%{query}%" + cur.execute( + """ + SELECT u.id, u.username, u.display_name, u.email, + EXISTS(SELECT 1 FROM friendships WHERE user_id = %s AND friend_id = u.id) AS is_friend, + EXISTS(SELECT 1 FROM friend_requests WHERE sender_id = %s AND receiver_id = u.id AND status = 'pending') AS request_sent + FROM users u + WHERE (u.username ILIKE %s OR u.display_name ILIKE %s) AND u.id != %s + ORDER BY u.display_name + LIMIT %s + """, + (current_user_id, current_user_id, search_pattern, search_pattern, current_user_id, limit) + ) + return [dict(row) for row in cur.fetchall()] + finally: + cur.close() + conn.close() diff --git a/frontend/src/App.css b/frontend/src/App.css index d97c31e..245edd9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -174,11 +174,7 @@ body { @media (min-width: 960px) { .layout { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - } - - .layout:has(.pinned-lists-sidebar) { - grid-template-columns: 320px minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr); } } @@ -189,10 +185,18 @@ body { @media (min-width: 960px) { .pinned-lists-sidebar { display: block; - position: sticky; - top: 1rem; - max-height: calc(100vh - 2rem); + position: fixed; + right: 1rem; + top: 5rem; + width: 280px; + max-height: calc(100vh - 6rem); overflow-y: auto; + z-index: 50; + background: var(--card); + border-radius: 16px; + padding: 1rem; + border: 1px solid var(--border-subtle); + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7); } } @@ -261,12 +265,19 @@ body { } .sidebar, +.sidebar-right, .content { display: flex; flex-direction: column; gap: 1rem; } +.sidebar-right { + position: sticky; + top: 1rem; + align-self: start; +} + /* Panels */ .panel { @@ -492,31 +503,31 @@ select { min-height: 260px; display: flex; flex-direction: column; - } .recipe-header { display: flex; justify-content: space-between; align-items: flex-start; - gap: 0.8rem; - margin-bottom: 0.8rem; + gap: 1rem; + margin-bottom: 1rem; } .recipe-header h2 { margin: 0; - font-size: 1.3rem; + font-size: 1.6rem; + line-height: 1.3; } .recipe-subtitle { - margin: 0.2rem 0 0; - font-size: 0.85rem; + margin: 0.3rem 0 0; + font-size: 0.9rem; color: var(--text-muted); } .recipe-made-by { - margin: 0.3rem 0 0; - font-size: 0.8rem; + margin: 0.4rem 0 0; + font-size: 0.85rem; color: var(--accent); font-weight: 500; } @@ -524,25 +535,34 @@ select { .pill-row { display: flex; flex-wrap: wrap; - gap: 0.3rem; + gap: 0.4rem; + align-items: flex-start; } .pill { - padding: 0.25rem 0.6rem; + padding: 0.35rem 0.75rem; border-radius: 999px; background: rgba(15, 23, 42, 0.95); border: 1px solid rgba(148, 163, 184, 0.7); - font-size: 0.78rem; + font-size: 0.8rem; + white-space: nowrap; } /* Recipe Image */ .recipe-image-container { width: 100%; - height: 250px; - border-radius: 12px; + height: 280px; + border-radius: 14px; overflow: hidden; - margin-bottom: 0.8rem; + margin-bottom: 1.2rem; background: rgba(15, 23, 42, 0.5); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +@media (min-width: 960px) { + .recipe-image-container { + height: 320px; + } } .recipe-image { @@ -554,26 +574,34 @@ select { .recipe-body { display: grid; - gap: 0.8rem; + gap: 1.2rem; flex: 1; + margin-bottom: 1rem; } -@media (min-width: 720px) { +@media (min-width: 600px) { .recipe-body { - grid-template-columns: 1fr 1.2fr; + grid-template-columns: 1fr 1fr; } } .recipe-column h3 { - margin: 0 0 0.3rem; - font-size: 0.95rem; + margin: 0 0 0.6rem; + font-size: 1.1rem; + color: var(--accent); + font-weight: 600; } .recipe-column ul, .recipe-column ol { margin: 0; - padding-right: 1rem; - font-size: 0.9rem; + padding-right: 1.2rem; + font-size: 0.95rem; + line-height: 1.6; +} + +.recipe-column li { + margin-bottom: 0.4rem; } .recipe-actions { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c140a67..2d675b0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,9 @@ import RecipeDetails from "./components/RecipeDetails"; import RecipeFormDrawer from "./components/RecipeFormDrawer"; import GroceryLists from "./components/GroceryLists"; import PinnedGroceryLists from "./components/PinnedGroceryLists"; +import Friends from "./components/Friends"; +import Chat from "./components/Chat"; +import Groups from "./components/Groups"; import Modal from "./components/Modal"; import ToastContainer from "./components/ToastContainer"; import ThemeToggle from "./components/ThemeToggle"; @@ -110,23 +113,41 @@ function App() { const token = getToken(); if (token) { try { + console.log("Checking authentication..."); const userData = await getMe(token); + console.log("Auth successful:", userData); setUser(userData); setIsAuthenticated(true); } catch (err) { + console.error("Auth check error:", err); // Only remove token on authentication errors (401), not network errors - if (err.status === 401) { + if (err.status === 401 || err.message.includes('401')) { console.log("Token invalid or expired, logging out"); removeToken(); setIsAuthenticated(false); + setUser(null); + } else if (err.status === 408 || err.name === 'AbortError') { + // Timeout - assume not authenticated + console.warn("Auth check timeout, removing token"); + removeToken(); + setIsAuthenticated(false); + setUser(null); } else { - // Network error or server error - keep user logged in - console.warn("Auth check failed but keeping session:", err.message); - setIsAuthenticated(true); // Assume still authenticated + // Network error or server error - assume not authenticated to avoid being stuck + console.warn("Auth check failed, removing token:", err.message); + removeToken(); + setIsAuthenticated(false); + setUser(null); } + } finally { + // Always set loading to false, even if there was an error + console.log("Setting loadingAuth to false"); + setLoadingAuth(false); } + } else { + console.log("No token found"); + setLoadingAuth(false); } - setLoadingAuth(false); }; checkAuth(); }, []); @@ -452,12 +473,36 @@ function App() { > 🛒 רשימות קניות + + + )}
{currentView === "grocery-lists" ? ( + ) : currentView === "friends" ? ( + + ) : currentView === "chat" ? ( + + ) : currentView === "groups" ? ( + ) : ( <> {isAuthenticated && ( @@ -486,10 +531,41 @@ function App() { )}
+
+ +
+
{error &&
{error}
} - {/* Random Recipe Suggester - Top Left */} + {/* Recipe Details Card */} + +
+ +
+ {/* Random Recipe Suggester - Right Side */}

חיפוש מתכון רנדומלי

@@ -507,64 +583,36 @@ function App() {
-
- - setMaxTimeFilter(e.target.value)} - placeholder="למשל 20" - /> -
+
+ + setMaxTimeFilter(e.target.value)} + placeholder="למשל 20" + /> +
-
- - setIngredientsFilter(e.target.value)} - placeholder="ביצה, עגבניה, פסטה..." - /> -
- +
+ + setIngredientsFilter(e.target.value)} + placeholder="ביצה, עגבניה, פסטה..." + /> +
+ - -
- - {/* Recipe Details Card */} - -
- -
- -
+ +
+ )} diff --git a/frontend/src/authApi.js b/frontend/src/authApi.js index 6aa0919..3b4d07f 100644 --- a/frontend/src/authApi.js +++ b/frontend/src/authApi.js @@ -29,30 +29,61 @@ export async function register(username, email, password, firstName, lastName, d } export async function login(username, password) { - const res = await fetch(`${API_BASE}/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password }), - }); - if (!res.ok) { - const error = await res.json(); - throw new Error(error.detail || "Failed to login"); + let lastError; + const maxRetries = 2; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const res = await fetch(`${API_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || "Failed to login"); + } + return res.json(); + } catch (err) { + lastError = err; + if (attempt < maxRetries - 1) { + // Wait before retry (100ms) + await new Promise(resolve => setTimeout(resolve, 100)); + continue; + } + } } - return res.json(); + throw lastError; } export async function getMe(token) { - const res = await fetch(`${API_BASE}/auth/me`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - if (!res.ok) { - const error = new Error("Failed to get user info"); - error.status = res.status; - throw error; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout + + try { + const res = await fetch(`${API_BASE}/auth/me`, { + headers: { + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!res.ok) { + const error = new Error("Failed to get user info"); + error.status = res.status; + throw error; + } + return res.json(); + } catch (err) { + clearTimeout(timeoutId); + if (err.name === 'AbortError') { + const timeoutError = new Error("Request timeout"); + timeoutError.status = 408; + throw timeoutError; + } + throw err; } - return res.json(); } export async function requestPasswordChangeCode(token) { diff --git a/frontend/src/components/Chat.css b/frontend/src/components/Chat.css new file mode 100644 index 0000000..3d681b2 --- /dev/null +++ b/frontend/src/components/Chat.css @@ -0,0 +1,482 @@ +.chat-container { + display: flex; + height: calc(100vh - 200px); + max-width: 1200px; + margin: 2rem auto; + background: var(--card); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + direction: rtl; +} + +.chat-loading { + text-align: center; + padding: 3rem; + font-size: 1.2rem; + color: var(--text); +} + +/* Sidebar */ +.chat-sidebar { + width: 320px; + border-left: 1px solid var(--border-subtle); + border-right: none; + display: flex; + flex-direction: column; + background: var(--card); +} + +.chat-sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem; + border-bottom: 1px solid var(--border-subtle); + background: var(--card-hover); +} + +.chat-sidebar-header h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--text); +} + +.btn-new-chat { + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.btn-new-chat:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.conversations-list { + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border-subtle) transparent; +} + +.conversations-list::-webkit-scrollbar { + width: 6px; +} + +.conversations-list::-webkit-scrollbar-track { + background: transparent; +} + +.conversations-list::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 3px; +} + +.no-conversations { + padding: 2rem 1rem; + text-align: center; + color: var(--text-muted); + font-style: italic; +} + +.conversation-item { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.conversation-item:hover { + background-color: var(--card-hover); +} + +.conversation-item.active { + background: linear-gradient(90deg, rgba(102, 126, 234, 0.1) 0%, transparent 100%); + border-right: 3px solid #667eea; + border-left: none; +} + +.conversation-name { + font-weight: 600; + margin-bottom: 0.25rem; + color: var(--text); + font-size: 1rem; +} + +.conversation-preview { + font-size: 0.875rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Main Chat Area */ +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg); +} + +.chat-header { + padding: 1.25rem; + border-bottom: 1px solid var(--border-subtle); + background: var(--card); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.chat-header h3 { + margin: 0; + color: var(--text); + font-weight: 600; + font-size: 1.25rem; +} + +.no-selection { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 1.1rem; + background: var(--bg); +} + +/* Messages */ +.messages-container { + flex: 1; + overflow-y: auto; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + background: var(--bg); + scrollbar-width: thin; + scrollbar-color: var(--border-subtle) transparent; +} + +.messages-container::-webkit-scrollbar { + width: 6px; +} + +.messages-container::-webkit-scrollbar-track { + background: transparent; +} + +.messages-container::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 3px; +} + +.no-messages { + text-align: center; + padding: 2rem; + color: var(--text-muted); + font-style: italic; +} + +.message { + display: flex; + flex-direction: column; + max-width: 70%; + animation: slideIn 0.2s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message.mine { + align-self: flex-start; + align-items: flex-start; +} + +.message.theirs { + align-self: flex-end; + align-items: flex-end; +} + +.message-sender { + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 0.25rem; + font-weight: 600; +} + +.message-bubble { + padding: 0.875rem 1.125rem; + border-radius: 18px; + word-wrap: break-word; + white-space: pre-wrap; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + line-height: 1.5; +} + +.message.mine .message-bubble { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-bottom-right-radius: 6px; +} + +.message.theirs .message-bubble { + background: var(--card); + color: var(--text); + border: 1px solid var(--border-subtle); + border-bottom-left-radius: 6px; +} + +.message-time { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.25rem; + opacity: 0.7; +} + +/* Message Input */ +.message-input-form { + display: flex; + gap: 0.75rem; + padding: 1.25rem; + border-top: 1px solid var(--border-subtle); + background: var(--card); + box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.05); +} + +.message-input-form input { + flex: 1; + padding: 0.875rem 1.125rem; + border: 1px solid var(--border-subtle); + border-radius: 24px; + font-size: 1rem; + background: var(--bg); + color: var(--text); + transition: all 0.2s; +} + +.message-input-form input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.message-input-form button { + padding: 0.875rem 2rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 24px; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.message-input-form button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.message-input-form button:disabled { + background: var(--border-subtle); + cursor: not-allowed; + box-shadow: none; + opacity: 0.6; +} + +/* New Chat Form */ +.new-chat-form { + padding: 2rem; + overflow-y: auto; + background: var(--bg); +} + +.new-chat-form h3 { + margin-bottom: 1.5rem; + color: var(--text); + font-weight: 600; + font-size: 1.5rem; +} + +.friends-selection { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.5rem; + max-height: 300px; + overflow-y: auto; + padding: 1rem; + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: var(--card); + scrollbar-width: thin; + scrollbar-color: var(--border-subtle) transparent; +} + +.friends-selection::-webkit-scrollbar { + width: 6px; +} + +.friends-selection::-webkit-scrollbar-track { + background: transparent; +} + +.friends-selection::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 3px; +} + +.friend-checkbox { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + padding: 0.75rem; + border-radius: 8px; + transition: all 0.2s; + color: var(--text); +} + +.friend-checkbox:hover { + background: var(--card-hover); +} + +.friend-checkbox input[type="checkbox"] { + width: 20px; + height: 20px; + cursor: pointer; + accent-color: #667eea; +} + +.group-name-input { + margin-bottom: 1.5rem; +} + +.group-name-input label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--text); +} + +.group-name-input input { + width: 100%; + padding: 0.875rem 1.125rem; + border: 1px solid var(--border-subtle); + border-radius: 12px; + font-size: 1rem; + background: var(--card); + color: var(--text); + transition: all 0.2s; +} + +.group-name-input input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.new-chat-actions { + display: flex; + gap: 0.75rem; +} + +.btn-create { + flex: 1; + padding: 0.875rem; + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + color: white; + border: none; + border-radius: 12px; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s; + box-shadow: 0 2px 8px rgba(17, 153, 142, 0.3); +} + +.btn-create:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(17, 153, 142, 0.4); +} + +.btn-cancel { + flex: 1; + padding: 0.875rem; + background: var(--card); + color: var(--text); + border: 1px solid var(--border-subtle); + border-radius: 12px; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s; +} + +.btn-cancel:hover { + background: var(--card-hover); +} + +/* Responsive */ +@media (max-width: 768px) { + .chat-container { + margin: 0; + border-radius: 0; + height: calc(100vh - 150px); + } + + .chat-sidebar { + width: 280px; + } + + .message { + max-width: 85%; + } + + .message-input-form { + padding: 1rem; + } + + .message-input-form button { + padding: 0.875rem 1.25rem; + } + + .new-chat-form { + padding: 1.5rem; + } +} + +@media (max-width: 480px) { + .chat-sidebar { + width: 100%; + position: absolute; + z-index: 10; + height: 100%; + } + + .chat-main { + width: 100%; + } + + .message { + max-width: 90%; + } +} diff --git a/frontend/src/components/Chat.jsx b/frontend/src/components/Chat.jsx new file mode 100644 index 0000000..967551e --- /dev/null +++ b/frontend/src/components/Chat.jsx @@ -0,0 +1,268 @@ +import { useState, useEffect, useRef } from "react"; +import { + getConversations, + createConversation, + getMessages, + sendMessage, + getFriends, +} from "../socialApi"; +import "./Chat.css"; + +export default function Chat({ showToast }) { + const [conversations, setConversations] = useState([]); + const [selectedConversation, setSelectedConversation] = useState(null); + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(""); + const [loading, setLoading] = useState(true); + const [showNewChat, setShowNewChat] = useState(false); + const [friends, setFriends] = useState([]); + const [selectedFriends, setSelectedFriends] = useState([]); + const [groupName, setGroupName] = useState(""); + const messagesEndRef = useRef(null); + + useEffect(() => { + loadConversations(); + }, []); + + useEffect(() => { + if (selectedConversation) { + loadMessages(selectedConversation.conversation_id); + const interval = setInterval(() => { + loadMessages(selectedConversation.conversation_id, true); + }, 3000); // Poll for new messages every 3 seconds + return () => clearInterval(interval); + } + }, [selectedConversation]); + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + function scrollToBottom() { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + + async function loadConversations() { + try { + setLoading(true); + const data = await getConversations(); + setConversations(data); + } catch (error) { + showToast(error.message, "error"); + } finally { + setLoading(false); + } + } + + async function loadMessages(conversationId, silent = false) { + try { + const data = await getMessages(conversationId); + setMessages(data); + } catch (error) { + if (!silent) showToast(error.message, "error"); + } + } + + async function handleSendMessage(e) { + e.preventDefault(); + if (!newMessage.trim() || !selectedConversation) return; + + try { + await sendMessage(selectedConversation.conversation_id, newMessage); + setNewMessage(""); + await loadMessages(selectedConversation.conversation_id); + } catch (error) { + showToast(error.message, "error"); + } + } + + async function handleStartNewChat() { + try { + const friendsData = await getFriends(); + setFriends(friendsData); + setSelectedFriends([]); + setGroupName(""); + setShowNewChat(true); + } catch (error) { + showToast(error.message, "error"); + } + } + + function toggleFriendSelection(friendId) { + setSelectedFriends((prev) => + prev.includes(friendId) ? prev.filter((id) => id !== friendId) : [...prev, friendId] + ); + } + + async function handleCreateConversation() { + if (selectedFriends.length === 0) { + showToast("בחר לפחות חבר אחד", "error"); + return; + } + + const isGroup = selectedFriends.length > 1; + + // Only validate group name if it's actually a group chat + if (isGroup && !groupName.trim()) { + showToast("הכנס שם קבוצה", "error"); + return; + } + + try { + console.log("Creating conversation with friends:", selectedFriends); + console.log("Is group:", isGroup); + console.log("Group name:", isGroup ? groupName : "(private chat)"); + + const conversation = await createConversation( + selectedFriends, + isGroup, + isGroup ? groupName : null + ); + showToast("השיחה נוצרה בהצלחה!", "success"); + setShowNewChat(false); + setSelectedFriends([]); + setGroupName(""); + await loadConversations(); + setSelectedConversation(conversation); + } catch (error) { + showToast(error.message, "error"); + } + } + + if (loading) { + return
טוען שיחות...
; + } + + return ( +
+
+
+

הודעות

+ +
+ +
+ {conversations.length === 0 ? ( +

אין שיחות עדיין

+ ) : ( + conversations.map((conv) => ( +
setSelectedConversation(conv)} + > +
+ {conv.name || conv.other_member_name || "Conversation"} +
+ {conv.last_message && ( +
{conv.last_message}
+ )} +
+ )) + )} +
+
+ +
+ {showNewChat ? ( +
+

שיחה חדשה

+
+ {friends.length === 0 ? ( +

אין חברים לשוחח איתם. הוסף חברים תחילה!

+ ) : ( + friends.map((friend) => ( + + )) + )} +
+ + {selectedFriends.length > 1 && ( +
+ + setGroupName(e.target.value)} + placeholder="הכנס שם קבוצה..." + /> +
+ )} + +
+ + +
+
+ ) : selectedConversation ? ( + <> +
+

{selectedConversation.name || selectedConversation.other_member_name || "שיחה"}

+
+ +
+ {messages.length === 0 ? ( +

אין הודעות עדיין. התחל את השיחה!

+ ) : ( + messages.map((msg) => ( +
+ {!msg.is_mine && ( +
{msg.sender_username || msg.sender_email}
+ )} +
{msg.content}
+
+ {new Date(msg.created_at).toLocaleTimeString('he-IL', { + hour: "2-digit", + minute: "2-digit", + })} +
+
+ )) + )} +
+
+ +
+ setNewMessage(e.target.value)} + placeholder="הקלד הודעה..." + autoFocus + /> + +
+ + ) : ( +
+

בחר שיחה או התחל שיחה חדשה

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Friends.css b/frontend/src/components/Friends.css new file mode 100644 index 0000000..e2b968b --- /dev/null +++ b/frontend/src/components/Friends.css @@ -0,0 +1,205 @@ +.friends-container { + max-width: 800px; + margin: 2rem auto; + padding: 2rem; +} + +.friends-container h2 { + margin-bottom: 1.5rem; + font-size: 2rem; +} + +.friends-loading { + text-align: center; + padding: 2rem; + font-size: 1.2rem; +} + +.friends-search { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.friends-search-input { + flex: 1; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +.friends-search-btn { + padding: 0.75rem 1.5rem; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s; +} + +.friends-search-btn:hover { + background-color: #0056b3; +} + +.friends-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + border-bottom: 2px solid #eee; +} + +.friends-tabs button { + padding: 0.75rem 1.5rem; + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + color: #666; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: all 0.2s; +} + +.friends-tabs button:hover { + color: #007bff; +} + +.friends-tabs button.active { + color: #007bff; + border-bottom-color: #007bff; + font-weight: 600; +} + +.friends-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.friends-empty { + text-align: center; + padding: 2rem; + color: #666; + font-size: 1.1rem; +} + +.friend-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border: 1px solid #ddd; + border-radius: 8px; + background-color: white; + transition: box-shadow 0.2s; +} + +.friend-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.friend-info { + flex: 1; +} + +.friend-name { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.friend-email { + color: #666; + font-size: 0.9rem; + margin-bottom: 0.25rem; +} + +.friend-since { + color: #999; + font-size: 0.85rem; +} + +.friend-actions { + display: flex; + gap: 0.5rem; +} + +.friend-btn-add, +.friend-btn-accept { + padding: 0.5rem 1rem; + background-color: #28a745; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s; +} + +.friend-btn-add:hover, +.friend-btn-accept:hover { + background-color: #218838; +} + +.friend-btn-remove, +.friend-btn-reject { + padding: 0.5rem 1rem; + background-color: #dc3545; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s; +} + +.friend-btn-remove:hover, +.friend-btn-reject:hover { + background-color: #c82333; +} + +.friend-status { + padding: 0.5rem 1rem; + color: #666; + font-size: 0.9rem; + font-style: italic; +} + +.request-card { + background-color: #f8f9fa; + border-color: #007bff; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .friends-search-input { + background-color: #333; + color: white; + border-color: #555; + } + + .friends-tabs { + border-bottom-color: #555; + } + + .friend-card { + background-color: #2a2a2a; + border-color: #444; + } + + .request-card { + background-color: #1a3a52; + border-color: #007bff; + } + + .friend-email { + color: #aaa; + } + + .friend-since { + color: #888; + } +} diff --git a/frontend/src/components/Friends.jsx b/frontend/src/components/Friends.jsx new file mode 100644 index 0000000..3eec94e --- /dev/null +++ b/frontend/src/components/Friends.jsx @@ -0,0 +1,242 @@ +import { useState, useEffect } from "react"; +import { + getFriends, + getFriendRequests, + acceptFriendRequest, + rejectFriendRequest, + sendFriendRequest, + removeFriend, + searchUsers, +} from "../socialApi"; +import "./Friends.css"; + +export default function Friends({ showToast }) { + const [friends, setFriends] = useState([]); + const [requests, setRequests] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState("friends"); // friends | requests | search + + useEffect(() => { + loadData(); + }, []); + + async function loadData() { + try { + setLoading(true); + const [friendsData, requestsData] = await Promise.all([ + getFriends(), + getFriendRequests(), + ]); + setFriends(friendsData); + setRequests(requestsData); + } catch (error) { + showToast(error.message, "error"); + } finally { + setLoading(false); + } + } + + async function handleSearch(e) { + e.preventDefault(); + if (!searchQuery.trim()) return; + + try { + const results = await searchUsers(searchQuery); + setSearchResults(results); + setActiveTab("search"); + } catch (error) { + showToast(error.message, "error"); + } + } + + async function handleSendRequest(userId) { + try { + await sendFriendRequest(userId); + showToast("Friend request sent!", "success"); + // Update search results to reflect sent request + setSearchResults( + searchResults.map((user) => + user.id === userId ? { ...user, request_sent: true } : user + ) + ); + } catch (error) { + showToast(error.message, "error"); + } + } + + async function handleAcceptRequest(requestId) { + try { + await acceptFriendRequest(requestId); + showToast("Friend request accepted!", "success"); + await loadData(); + } catch (error) { + showToast(error.message, "error"); + } + } + + async function handleRejectRequest(requestId) { + try { + await rejectFriendRequest(requestId); + showToast("Friend request rejected", "info"); + setRequests(requests.filter((req) => req.request_id !== requestId)); + } catch (error) { + showToast(error.message, "error"); + } + } + + async function handleRemoveFriend(friendId) { + if (!confirm("Remove this friend?")) return; + + try { + await removeFriend(friendId); + showToast("Friend removed", "info"); + setFriends(friends.filter((friend) => friend.user_id !== friendId)); + } catch (error) { + showToast(error.message, "error"); + } + } + + if (loading) { + return
Loading...
; + } + + return ( +
+

Friends

+ + {/* Search Bar */} +
+ setSearchQuery(e.target.value)} + className="friends-search-input" + /> + +
+ + {/* Tabs */} +
+ + + {searchResults.length > 0 && ( + + )} +
+ + {/* Friends List */} + {activeTab === "friends" && ( +
+ {friends.length === 0 ? ( +

No friends yet. Search for users to add!

+ ) : ( + friends.map((friend) => ( +
+
+
{friend.username || friend.email}
+
{friend.email}
+
+ Friends since {new Date(friend.friends_since).toLocaleDateString()} +
+
+ +
+ )) + )} +
+ )} + + {/* Friend Requests */} + {activeTab === "requests" && ( +
+ {requests.length === 0 ? ( +

No pending friend requests

+ ) : ( + requests.map((request) => ( +
+
+
+ {request.sender_username || request.sender_email} +
+
{request.sender_email}
+
+ Sent {new Date(request.created_at).toLocaleDateString()} +
+
+
+ + +
+
+ )) + )} +
+ )} + + {/* Search Results */} + {activeTab === "search" && ( +
+ {searchResults.length === 0 ? ( +

No users found

+ ) : ( + searchResults.map((user) => ( +
+
+
{user.username || user.email}
+
{user.email}
+
+ {user.is_friend ? ( + Friends + ) : user.request_sent ? ( + Request Sent + ) : ( + + )} +
+ )) + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/GroceryLists.jsx b/frontend/src/components/GroceryLists.jsx index 8467a0d..5e0bab2 100644 --- a/frontend/src/components/GroceryLists.jsx +++ b/frontend/src/components/GroceryLists.jsx @@ -21,6 +21,7 @@ function GroceryLists({ user, onShowToast }) { const [userSearch, setUserSearch] = useState(""); const [searchResults, setSearchResults] = useState([]); const [sharePermission, setSharePermission] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(null); // New list form const [newListName, setNewListName] = useState(""); @@ -167,7 +168,12 @@ function GroceryLists({ user, onShowToast }) { }; const handleDeleteList = async (listId) => { - if (!confirm("האם אתה בטוח שברצונך למחוק רשימת קניות זו?")) return; + setShowDeleteModal(listId); + }; + + const confirmDeleteList = async () => { + const listId = showDeleteModal; + setShowDeleteModal(null); try { await deleteGroceryList(listId); @@ -234,11 +240,21 @@ function GroceryLists({ user, onShowToast }) { can_edit: sharePermission, }); - setShares([...shares, share]); + // Check if user already has share - update it, otherwise add new + const existingShareIndex = shares.findIndex(s => s.shared_with_user_id === userId); + if (existingShareIndex >= 0) { + const updatedShares = [...shares]; + updatedShares[existingShareIndex] = share; + setShares(updatedShares); + onShowToast(`הרשאות עודכנו עבור ${share.display_name}`, "success"); + } else { + setShares([...shares, share]); + onShowToast(`רשימה שותפה עם ${share.display_name}`, "success"); + } + setUserSearch(""); setSearchResults([]); setSharePermission(false); - onShowToast(`רשימה שותפה עם ${share.display_name}`, "success"); } catch (error) { onShowToast(error.message, "error"); } @@ -298,7 +314,10 @@ function GroceryLists({ user, onShowToast }) { onClick={() => handleSelectList(list)} >
-

{list.name}

+
+

{list.name}

+ {list.is_pinned && 📌} +

{list.is_owner ? "שלי" : `של ${list.owner_display_name}`} {" · "} @@ -501,18 +520,51 @@ function GroceryLists({ user, onShowToast }) {

) : (
-

בחר רשימת קניות כדי להציג את הפרטים

+
+ 📝 +

אין רשימה נבחרת

+

בחר רשימת קניות מהצד כדי להציג ולערוך את הפרטים

+
)}
+ {/* Delete Confirmation Modal */} + {showDeleteModal && ( +
setShowDeleteModal(null)}> +
e.stopPropagation()}> +
+

⚠️ מחיקת רשימה

+
+
+

האם אתה בטוח שברצונך למחוק רשימת קניות זו?

+

פעולה זו אינה ניתנת לביטול!

+
+
+ + +
+
+
+ )} + {/* Share Modal */} {showShareModal && ( -
setShowShareModal(null)}> +
setShowShareModal(null)}>
e.stopPropagation()}>
-

שתף רשימה: {showShareModal.name}

+
+ 🔗 +
+

שיתוף רשימה

+

{showShareModal.name}

+
+
@@ -520,51 +572,68 @@ function GroceryLists({ user, onShowToast }) {
- handleSearchUsers(e.target.value)} - /> -
-

משותף עם:

+

🤝 משותף עם:

{shares.length === 0 ? ( -

הרשימה לא משותפת עם אף אחד

+
+ 👥 +

הרשימה לא משותפת עם אף אחד

+
) : (
    {shares.map((share) => (
  • -
    - {share.display_name} - @{share.username} - {share.can_edit && עורך} +
    👤
    +
    +
    + {share.display_name} + @{share.username} +
    + {share.can_edit && ✏️ עורך}
    +
    + +
    + {groups.length === 0 ? ( +

    No groups yet. Create one!

    + ) : ( + groups.map((group) => ( +
    { + setSelectedGroup(group); + setActiveTab("recipes"); + }} + > +
    + {group.is_private ? "🔒 " : "🌐 "} + {group.name} +
    +
    + {group.member_count} members · {group.recipe_count || 0} recipes +
    +
    + )) + )} +
    +
+ +
+ {activeTab === "create" ? ( +
+

Create New Group

+
+
+ + setNewGroupName(e.target.value)} + placeholder="Family Recipes, Vegan Friends, etc." + required + /> +
+ +
+ +