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..190c2747 --- /dev/null +++ b/backend/app/api/v1/profile.py @@ -0,0 +1,115 @@ +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 + 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) + if not user_details: + raise HTTPException(status_code=404, detail="User not found") + 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"), + "avatar_path": user_details.get("avatar_path"), + "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 {"languages": [], "frameworks": []}, + }} + except HTTPException as he: + raise he + 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"), + "avatar_path": updated_user.get("avatar_path"), + "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/scripts/create_db.sql b/backend/app/database/supabase/scripts/create_db.sql index 8ec0bbb4..8a8876ee 100644 --- a/backend/app/database/supabase/scripts/create_db.sql +++ b/backend/app/database/supabase/scripts/create_db.sql @@ -23,6 +23,7 @@ CREATE TABLE users ( display_name TEXT NOT NULL, avatar_url TEXT, + avatar_path TEXT, bio TEXT, location TEXT, diff --git a/backend/app/database/supabase/services.py b/backend/app/database/supabase/services.py index 44755ba0..a99913c8 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,152 @@ 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. + primary_value = merged_user.get(key) + if primary_value in (None, "", [], {}) and value not in (None, "", [], {}): + 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) + 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..52f26a8c 100644 --- a/backend/app/models/database/supabase.py +++ b/backend/app/models/database/supabase.py @@ -21,6 +21,7 @@ class User(BaseModel): slack_username (Optional[str]): Slack username, if linked. display_name (str): Display name of the user. avatar_url (Optional[str]): URL to the user's avatar image. + avatar_path (Optional[str]): Path to the user's avatar image on supabase. bio (Optional[str]): Short biography or description of the user. location (Optional[str]): User's location. is_verified (bool): Indicates if the user is verified. @@ -50,6 +51,7 @@ class User(BaseModel): display_name: str avatar_url: Optional[str] = None + avatar_path: Optional[str] = None bio: Optional[str] = None location: Optional[str] = None @@ -226,3 +228,30 @@ 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 + avatar_path: 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": { + "languages": ["python"], + "frameworks": ["fastapi", "react"] + }, + "github": "devguru_code" + } + } diff --git a/backend/database/01_create_integration_tables.sql b/backend/database/01_create_integration_tables.sql index a3cabd66..156cd90f 100644 --- a/backend/database/01_create_integration_tables.sql +++ b/backend/database/01_create_integration_tables.sql @@ -33,6 +33,81 @@ 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 +SET search_path = public, auth +AS $$ +BEGIN + INSERT INTO public.users ( + id, + email, + display_name, + avatar_url, + created_at, + updated_at, + is_verified, + verified_at + ) + 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, + NEW.email_confirmed_at + ); + + 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 +SET search_path = public, auth +AS $$ +BEGIN + UPDATE public.users + SET + is_verified = TRUE, + verified_at = NEW.email_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; @@ -63,3 +138,52 @@ CREATE POLICY "Users can delete their own integrations" 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.'; + + +--Add Buckets for storing the avatar paths +insert into storage.buckets (id, name, public) +values ('avatars', 'avatars', true) +on conflict (id) do nothing; + +-- 2. Public read access (anyone can view avatars) +drop policy if exists "Public read access for avatars" on storage.objects; +create policy "Public read access for avatars" +on storage.objects +for select +using ( + bucket_id = 'avatars' +); + + +-- 3. Authenticated users can upload avatars +drop policy if exists "Authenticated upload avatars" on storage.objects; + +create policy "Authenticated upload avatars" +on storage.objects +for insert +with check ( + bucket_id = 'avatars' + and auth.role() = 'authenticated' +); + +-- 4. Users can update ONLY their own avatars +drop policy if exists "Users update own avatars" on storage.objects; + +create policy "Users update own avatars" +on storage.objects +for update +using ( + bucket_id = 'avatars' + and owner = auth.uid() +); + +-- 5. Users can delete ONLY their own avatars +drop policy if exists "Users delete own avatars" on storage.objects; + +create policy "Users delete own avatars" +on storage.objects +for delete +using ( + bucket_id = 'avatars' + and owner = auth.uid() +); diff --git a/frontend/src/components/pages/ProfilePage.tsx b/frontend/src/components/pages/ProfilePage.tsx index fd48d2f9..c446fac7 100644 --- a/frontend/src/components/pages/ProfilePage.tsx +++ b/frontend/src/components/pages/ProfilePage.tsx @@ -1,208 +1,603 @@ -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'; -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 handleSave = () => { - setIsEditing(false); - toast.success('Profile updated successfully!'); - }; - - return ( - ( + -
-

Profile

- setIsEditing(!isEditing)} - className="px-4 py-2 bg-green-500 hover:bg-green-600 rounded-lg transition-colors flex items-center" - > - {isEditing ? ( - <> - - Save Changes - - ) : ( - <> - - Edit Profile - - )} - -
- -
-
- - - -
+ + +); -
-
-
- Profile - - - -
-
- -
-
-
- -
- - setProfile({ ...profile, name: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> -
-
- -
- -
- - setProfile({ ...profile, email: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> -
-
+const SlackIcon = ({ + size = 20, + className = '', +}: { + size?: number; + className?: string; +}) => ( + + + +); + +const ProfilePage = () => { + const [isEditing, setIsEditing] = useState(false); + 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: '', + avatar_path: '', + discord: '', + github: '', + slack: '', + skills: { languages: [], frameworks: [] }, + }); + + useEffect(() => { + const fetchProfileData = async () => { + try { + const { + data: { session }, + error: sessionError, + } = await supabase.auth.getSession(); + if (sessionError || !session) { + toast.error('Please log in to view your profile'); + 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 || '', + avatar_path: data.user.avatar_path || '', + 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; + } + if (profile.avatar_path) { + await supabase.storage + .from('avatars') + .remove([profile.avatar_path]); + } + + 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, + avatar_path: filePath, + })); + 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 = async () => { + if (!profile.avatar_path) return; + const { error } = await supabase.storage + .from('avatars') + .remove([profile.avatar_path]); + if (error) { + throw error; + } + setProfile((prev) => ({ ...prev, avatar_url: '' ,avatar_path: ''})); + 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.'); + setIsSaving(false); + return; + } + const token = session.access_token; -
- -
- - { + 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, company: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> + value={value as string} + onChange={(e) => + 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...
+ ); -
-
- -
- - setProfile({ ...profile, website: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> -
-
+ return ( + +
+

Profile

+ 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' + }`} + > + {isSaving ? ( + 'Saving...' + ) : isEditing ? ( + <> + + Save Changes + + ) : ( + <> + + Edit Profile + + )} + +
-
- -
- - setProfile({ ...profile, github: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> -
-
+
+
-
- -
- - setProfile({ ...profile, twitter: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> +
+
+
+ {/* The Image */} + Profile + + {uploadingAvatar && ( +
+ +
+ )} + + {isEditing && ( + <> + + + {/* Upload Button */} + + + + + {/* Remove Button */} + {profile.avatar_url && ( + + + + )} + + )} +
+
+ +
+
+ {/* Display Name */} +
+ +
+ + + setProfile({ + ...profile, + display_name: e.target.value, + }) + } + disabled={!isEditing} + 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 */} +
+ +
+ + +
+
+ + {/* 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} + /> +
+ +
+
+ + {renderSocialField( + , + profile.github, + 'github', + 'Username' + )} +
+
+ + {renderSocialField( + , + profile.discord, + 'discord', + 'ID/Tag' + )} +
+
+ + {renderSocialField( + , + profile.slack, + 'slack', + 'ID' + )} +
+
+
+ +
+ +