From 133f5a3943360c63724bbeba115ecf25d6e04ff2 Mon Sep 17 00:00:00 2001 From: pinjinx Date: Fri, 16 Jan 2026 22:16:04 +0530 Subject: [PATCH 1/2] feat: Implement profile edit, avatar upload, and user merging logic --- backend/app/api/router.py | 7 + backend/app/api/v1/profile.py | 110 ++++ backend/app/database/supabase/services.py | 149 +++++- backend/app/models/database/supabase.py | 23 + .../database/01_create_integration_tables.sql | 72 ++- frontend/src/components/pages/ProfilePage.tsx | 476 +++++++++++++----- frontend/src/components/ui/skills.tsx | 90 ++++ frontend/src/lib/api.ts | 2 +- 8 files changed, 801 insertions(+), 128 deletions(-) create mode 100644 backend/app/api/v1/profile.py create mode 100644 frontend/src/components/ui/skills.tsx diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 67cd1e56..e08d7a0f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -2,6 +2,7 @@ from .v1.auth import router as auth_router from .v1.health import router as health_router from .v1.integrations import router as integrations_router +from .v1.profile import router as profile_router api_router = APIRouter() @@ -23,4 +24,10 @@ tags=["Integrations"] ) +api_router.include_router( + profile_router, + prefix="/v1/profile", + tags=["Profile"] +) + __all__ = ["api_router"] diff --git a/backend/app/api/v1/profile.py b/backend/app/api/v1/profile.py new file mode 100644 index 00000000..b4424747 --- /dev/null +++ b/backend/app/api/v1/profile.py @@ -0,0 +1,110 @@ +import logging +from fastapi import APIRouter, HTTPException, Depends,status +from app.database.supabase.services import get_user_details, validate_user,update_user_details +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from app.models.database.supabase import UserProfileEdit + + +router = APIRouter() +logger = logging.getLogger(__name__) + + +security = HTTPBearer() + + +async def get_user( + credentials: HTTPAuthorizationCredentials = Depends(security), +): + token = credentials.credentials + print(f"Extracted token: {token}") + result = await validate_user(token) + if not result: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token"+token, + ) + return result + + +@router.get("/") +async def fetch_details(user=Depends(get_user),): + """Fetch user profile details.""" + try: + (id, name) = user + if not id: + raise HTTPException(status_code=401, detail="Unauthorized") + user_details = await get_user_details(id) + return {"user": { + "id": user_details.get("id"), + "email": user_details.get("email"), + "display_name": user_details.get("display_name"), + "avatar_url": user_details.get("avatar_url"), + "bio": user_details.get("bio"), + "discord":user_details.get("discord_username"), + "github":user_details.get("github_username"), + "slack":user_details.get("slack_username"), + "skills": user_details.get("skills") or [], + }} + except Exception as e: + logger.error(f"Fetching profile details failed: {e}") + raise HTTPException( + status_code=503, + detail={ + "service": "frontend_services", + "status": "http exception", + "error": str(e) + } + ) from e + + +@router.patch("/edit") +async def edit_details( + profile_data: UserProfileEdit, + user=Depends(get_user) +): + """ + Edit user profile details. + Only updates the fields provided in the request body. + """ + try: + (user_id, _) = user + if not user_id: + raise HTTPException(status_code=401, detail="Unauthorized") + update_data = profile_data.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException( + status_code=400, + detail="No valid fields provided for update" + ) + + updated_user = await update_user_details(user_id, update_data) + + if not updated_user: + raise HTTPException(status_code=404, detail="User not found or update failed") + + + return {"user": { + "id": updated_user.get("id"), + "email": updated_user.get("email"), + "display_name": updated_user.get("display_name"), + "avatar_url": updated_user.get("avatar_url"), + "bio": updated_user.get("bio"), + "discord": updated_user.get("discord_username"), + "github": updated_user.get("github_username"), + "slack": updated_user.get("slack_username"), + "skills": updated_user.get("skills") or [], + }} + + except HTTPException as he: + raise he + except Exception as e: + logger.error(f"Updating profile details failed: {e}") + raise HTTPException( + status_code=503, + detail={ + "service": "frontend_services", + "status": "http exception", + "error": str(e) + } + ) from e \ No newline at end of file diff --git a/backend/app/database/supabase/services.py b/backend/app/database/supabase/services.py index 44755ba0..614c118d 100644 --- a/backend/app/database/supabase/services.py +++ b/backend/app/database/supabase/services.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Tuple from datetime import datetime import uuid from app.database.supabase.client import get_supabase_client @@ -80,6 +80,153 @@ async def ensure_user_exists( logger.error(f"Error ensuring user exists: {str(e)}") return None +async def get_user_details( + user_id: str, +) -> Optional[Dict[str, Any]]: + """ + Return profile details of a user. + If a duplicate account exists (via shared Discord ID), merges data + to provide a full profile, prioritizing the current user_id's data. + """ + try: + # 1. Fetch the Primary User (The one currently logged in) + # Note: Removed .neq("email", None) so we can fetch pure Discord users too if needed + response = ( + await supabase + .table("users") + .select("*") + .eq("id", user_id) + .limit(1) + .execute() + ) + + if not response.data: + logger.warning(f"No user found for id: {user_id}") + return None + + primary_user = response.data[0] + + # 2. Check for linked Discord ID + # We check both common column names just in case, prioritize discord_id + discord_id = primary_user.get("discord_id") + + if discord_id: + # 3. Search for a 'Ghost' or Duplicate row + # Same Discord ID, but different User UUID + duplicate_check = ( + await supabase + .table("users") + .select("*") + .eq("discord_id", discord_id) + .neq("id", user_id) + .limit(1) + .execute() + ) + + # 4. If a duplicate exists, merge them + if duplicate_check.data: + secondary_user = duplicate_check.data[0] + logger.info(f"Duplicate Discord user found: {secondary_user['id']}. Merging profiles.") + + # MERGE STRATEGY: + # Start with the Current (Primary) User. + # If a field in Primary is None/Empty, grab it from Secondary. + merged_user = primary_user.copy() + + for key, value in secondary_user.items(): + # If primary user doesn't have a value for this field (None or empty string/list) + # but the secondary user does, we adopt the secondary user's value. + if not merged_user.get(key) and value: + merged_user[key] = value + + # Special handling for Skills (Dictionaries) + # If both have skills, we might want to ensure we don't lose the old ones + if not merged_user.get("skills") and secondary_user.get("skills"): + merged_user["skills"] = secondary_user["skills"] + + return merged_user + + logger.info(f"User found: {user_id}") + return primary_user + + except Exception as e: + logger.error(f"Error fetching user details for {user_id}: {e}", exc_info=True) + return None + +async def update_user_details(user_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Update user profile details. + + Args: + user_id: The UUID of the user to update + updates: A dictionary of fields to update + + Returns: + The updated user object or None if failed + """ + try: + key_mapping = { + "discord": "discord_username", + "github": "github_username", + "slack": "slack_username", + } + + db_payload = {} + + for key, value in updates.items(): + if value is not None: + db_key = key_mapping.get(key, key) + db_payload[db_key] = value + + if not db_payload: + return None + + response = ( + await supabase + .table("users") + .update(db_payload) + .eq("id", user_id) + .execute() + ) + + if response.data: + logger.info(f"Updated profile for user: {user_id}") + return response.data[0] + return None + + except Exception as e: + logger.error(f"Error updating user details for {user_id}: {e}") + raise e + + +async def validate_user( + token: str, +) -> Optional[Tuple[str, Optional[str]]]: + response = await supabase.auth.get_user(jwt=token) + print(f"Auth response: {response}") + if not response or not response.user: + return None + auth_user = response.user + auth_user_id = auth_user.id + + user_metadata = auth_user.user_metadata or {} + display_name = user_metadata.get("display_name") + user_check = ( + await supabase + .table("users") + .select("id") + .eq("id", auth_user_id) + .limit(1) + .execute() + ) + if not user_check.data: + logger.error(f"Auth user {auth_user_id} not found in public.users") + return None + + return auth_user_id, display_name + + + async def store_interaction( user_uuid: str, diff --git a/backend/app/models/database/supabase.py b/backend/app/models/database/supabase.py index 7e04d0fc..4aba99c0 100644 --- a/backend/app/models/database/supabase.py +++ b/backend/app/models/database/supabase.py @@ -226,3 +226,26 @@ class IndexedRepository(BaseModel): last_error: Optional[str] = None model_config = ConfigDict(from_attributes=True) + +class SkillsModel(BaseModel): + languages: List[str] = [] + frameworks: List[str] = [] + +class UserProfileEdit(BaseModel): + display_name: Optional[str] = None + bio: Optional[str] = None + avatar_url: Optional[str] = None + discord: Optional[str] = None + github: Optional[str] = None + slack: Optional[str] = None + skills: Optional[SkillsModel] = None + + class Config: + json_schema_extra = { + "example": { + "display_name": "Dev Guru", + "bio": "Full stack developer interested in AI", + "skills": ["python", "fastapi", "react"], + "github": "devguru_code" + } + } \ No newline at end of file diff --git a/backend/database/01_create_integration_tables.sql b/backend/database/01_create_integration_tables.sql index a3cabd66..cab5654e 100644 --- a/backend/database/01_create_integration_tables.sql +++ b/backend/database/01_create_integration_tables.sql @@ -33,6 +33,77 @@ CREATE TRIGGER update_organization_integrations_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + + +-- Function to create users in the public.users table upon registration +CREATE OR REPLACE FUNCTION create_users_at_db() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + INSERT INTO public.users ( + id, + email, + display_name, + avatar_url, + created_at, + updated_at, + is_verified + ) + VALUES ( + NEW.id, + NEW.email, + NEW.raw_user_meta_data->>'display_name', + NEW.raw_user_meta_data->>'avatar_url', + NEW.created_at, + NEW.created_at, + NEW.email_confirmed_at IS NOT NULL + ); + + RETURN NEW; +END; +$$; + + + +-- Create trigger to automatically add authenticated users to the table +DROP TRIGGER IF EXISTS add_user_to_table ON auth.users; +CREATE TRIGGER add_user_to_table + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION create_users_at_db(); + + +CREATE OR REPLACE FUNCTION verify_users_at_db() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + UPDATE public.users + SET + is_verified = TRUE, + verified_at = NEW.confirmed_at, + updated_at = NOW() + WHERE id = NEW.id; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS verify_user_on_registration ON auth.users; +CREATE TRIGGER verify_user_on_registration +AFTER UPDATE ON auth.users +FOR EACH ROW +WHEN ( + OLD.email_confirmed_at IS NULL + AND NEW.email_confirmed_at IS NOT NULL +) +EXECUTE FUNCTION verify_users_at_db(); + + -- Enable Row Level Security (RLS) ALTER TABLE organization_integrations ENABLE ROW LEVEL SECURITY; @@ -62,4 +133,3 @@ CREATE POLICY "Users can delete their own integrations" -- Add helpful comments COMMENT ON TABLE organization_integrations IS 'Stores registered organizations (just metadata, no tokens)'; COMMENT ON COLUMN organization_integrations.config IS 'Platform-specific data: organization_link, discord_guild_id, etc.'; - diff --git a/frontend/src/components/pages/ProfilePage.tsx b/frontend/src/components/pages/ProfilePage.tsx index fd48d2f9..b3356ca6 100644 --- a/frontend/src/components/pages/ProfilePage.tsx +++ b/frontend/src/components/pages/ProfilePage.tsx @@ -1,31 +1,263 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; // Added useRef import { motion } from 'framer-motion'; import { toast } from 'react-hot-toast'; -import { User, Mail, Building, Globe, Github, Twitter, Edit, Camera, Save, DoorClosed } from 'lucide-react'; +import { User, Mail, Edit, Camera, Save, MessageSquare, CheckCircle, XCircle, Trash2, Loader2, Code, Layers } from 'lucide-react'; +import { supabase } from "../../lib/supabaseClient"; +import { API_BASE_URL } from '../../lib/api'; +import SkillSection from '../ui/skills'; + + +interface SkillsData { + languages: string[]; + frameworks: string[]; +} + +interface UserProfile { + display_name: string; + email: string; + bio: string; + avatar_url: string; + discord: string; + github: string; + slack: string; + skills: SkillsData; +} + +const GithubIcon = ({ size = 20, className = "" }: { size?: number, className?: string }) => ( + + + +); + +const SlackIcon = ({ size = 20, className = "" }: { size?: number, className?: string }) => ( + + + +); const ProfilePage = () => { const [isEditing, setIsEditing] = useState(false); - const [profile, setProfile] = useState({ - name: 'Sarah Chen', - role: 'Core Maintainer', - company: 'TechCorp Inc.', - email: 'sarah.chen@example.com', - website: 'https://sarahchen.dev', - github: '@sarahchen', - twitter: '@sarahchen_dev', - bio: 'Open source enthusiast and community builder. Working on developer tools and AI-powered solutions.', + const [loading, setLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const fileInputRef = useRef(null); + + const [newLanguage, setNewLanguage] = useState(""); + const [newFramework, setNewFramework] = useState(""); + + const [profile, setProfile] = useState({ + display_name: '', + email: '', + bio: '', + avatar_url: '', + discord: '', + github: '', + slack: '', + skills: { languages: [], frameworks: [] }, }); - const handleSave = () => { - setIsEditing(false); - toast.success('Profile updated successfully!'); + useEffect(() => { + const fetchProfileData = async () => { + try { + const { data: { session }, error: sessionError } = await supabase.auth.getSession(); + if (sessionError || !session) return; + + const token = session.access_token; + const response = await fetch(`${API_BASE_URL}/v1/profile/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) throw new Error('Failed to fetch profile data'); + + const data = await response.json(); + + if (data && data.user) { + let parsedSkills: SkillsData = { languages: [], frameworks: [] }; + + if (data.user.skills && typeof data.user.skills === 'object' && !Array.isArray(data.user.skills)) { + parsedSkills = { + languages: Array.isArray(data.user.skills.languages) ? data.user.skills.languages : [], + frameworks: Array.isArray(data.user.skills.frameworks) ? data.user.skills.frameworks : [] + }; + } + + setProfile(prev => ({ + ...prev, + display_name: data.user.display_name || '', + email: data.user.email || '', + bio: data.user.bio || '', + avatar_url: data.user.avatar_url || '', + discord: data.user.discord || '', + github: data.user.github || '', + slack: data.user.slack || '', + skills: parsedSkills, + })); + } + + } catch (error) { + console.error('Error loading profile:', error); + toast.error('Failed to load profile data'); + } finally { + setLoading(false); + } + }; + fetchProfileData(); + }, []); + + + const handleAvatarUpload = async (event: React.ChangeEvent) => { + try { + if (!event.target.files || event.target.files.length === 0) { + return; + } + + const file = event.target.files[0]; + const fileExt = file.name.split('.').pop(); + const fileName = `${Math.random()}.${fileExt}`; + const filePath = `${fileName}`; + + setUploadingAvatar(true); + const { error: uploadError } = await supabase.storage + .from('avatars') + .upload(filePath, file); + + if (uploadError) { + throw uploadError; + } + const { data: { publicUrl } } = supabase.storage + .from('avatars') + .getPublicUrl(filePath); + setProfile(prev => ({ ...prev, avatar_url: publicUrl })); + toast.success("Image uploaded! Click 'Save Changes' to apply."); + + } catch (error: any) { + console.error('Error uploading avatar:', error); + toast.error('Error uploading image'); + } finally { + setUploadingAvatar(false); + } }; + const handleRemoveAvatar = () => { + setProfile(prev => ({ ...prev, avatar_url: '' })); + toast.success("Avatar removed. Click 'Save Changes' to apply."); + }; + + const triggerFileInput = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const { data: { session } } = await supabase.auth.getSession(); + if (!session) { + toast.error("You must be logged in to save changes."); + return; + } + const token = session.access_token; + + const payload = { + display_name: profile.display_name, + bio: profile.bio, + avatar_url: profile.avatar_url, + discord: profile.discord, + github: profile.github, + slack: profile.slack, + skills: profile.skills + }; + + const response = await fetch(`${API_BASE_URL}/v1/profile/edit`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to update profile'); + } + + toast.success('Profile updated successfully!'); + setIsEditing(false); + } catch (error: any) { + console.error('Error saving profile:', error); + toast.error(error.message || 'Failed to save changes'); + } finally { + setIsSaving(false); + } + }; + + const addSkill = (type: 'languages' | 'frameworks', value: string) => { + if (value.trim()) { + if (!profile.skills[type].includes(value.trim())) { + setProfile(prev => ({ + ...prev, + skills: { + ...prev.skills, + [type]: [...prev.skills[type], value.trim()] + } + })); + } + if (type === 'languages') setNewLanguage(""); + else setNewFramework(""); + } + }; + + const removeSkill = (type: 'languages' | 'frameworks', skillToRemove: string) => { + setProfile(prev => ({ + ...prev, + skills: { + ...prev.skills, + [type]: prev.skills[type].filter(s => s !== skillToRemove) + } + })); + }; + + const renderSocialField = (icon: React.ReactNode, value: string, field: keyof UserProfile, placeholder: string) => ( +
+ {icon} + {isEditing ? ( + setProfile({ ...profile, [field]: e.target.value })} + className="bg-transparent text-white focus:outline-none border-b border-gray-700 focus:border-green-500 w-full pb-1 transition-colors" + placeholder={placeholder} + /> + ) : ( +
+ {value ? ( + + + Connected + + ) : ( + + + Not Connected + + )} +
+ )} +
+ ); + + if (loading) return
Loading profile...
; + return (
@@ -33,138 +265,144 @@ const ProfilePage = () => { setIsEditing(!isEditing)} - className="px-4 py-2 bg-green-500 hover:bg-green-600 rounded-lg transition-colors flex items-center" + onClick={isEditing ? handleSave : () => setIsEditing(true)} + disabled={isSaving || uploadingAvatar} + className={`px-4 py-2 rounded-lg transition-colors flex items-center ${ + isEditing + ? "bg-green-500 hover:bg-green-600 text-white" + : "bg-gray-800 hover:bg-gray-700 text-gray-200" + }`} > - {isEditing ? ( - <> - - Save Changes - - ) : ( - <> - - Edit Profile - - )} + {isSaving ? "Saving..." : isEditing ? <>Save Changes : <>Edit Profile}
-
- - - -
+
+ +
-
+
+ {/* The Image */} Profile - - - + + {uploadingAvatar && ( +
+ +
+ )} + + + {isEditing && ( + <> + + + {/* Upload Button */} + + + + + {/* Remove Button */} + {profile.avatar_url && ( + + + + )} + + )}
+ {/* Display Name */}
- +
setProfile({ ...profile, name: e.target.value })} + value={profile.display_name} + onChange={(e) => setProfile({ ...profile, display_name: e.target.value })} disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" + className={`bg-transparent text-white focus:outline-none w-full ${isEditing ? 'border-b border-gray-700 pb-1 focus:border-green-500' : ''}`} + placeholder="Enter display name" />
+ {/* Email */}
- setProfile({ ...profile, email: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> +
-
- -
- - setProfile({ ...profile, company: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> -
-
+ {/* Languages Section */} + } + items={profile.skills.languages} + type="languages" + inputValue={newLanguage} + setInputValue={setNewLanguage} + isEditing={isEditing} + onAdd={addSkill} + onRemove={removeSkill} + /> + + {/* Frameworks Section */} + } + items={profile.skills.frameworks} + type="frameworks" + inputValue={newFramework} + setInputValue={setNewFramework} + isEditing={isEditing} + onAdd={addSkill} + onRemove={removeSkill} + />
- -
- - setProfile({ ...profile, website: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> -
+ + {renderSocialField(, profile.github, 'github', 'Username')}
-
- -
- - setProfile({ ...profile, github: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> -
+ + {renderSocialField(, profile.discord, 'discord', 'ID/Tag')}
-
- -
- - setProfile({ ...profile, twitter: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> -
+ + {renderSocialField(, profile.slack, 'slack', 'ID')}
@@ -172,31 +410,19 @@ const ProfilePage = () => {