diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 67cd1e56..dcd38d61 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.users import router as users_router api_router = APIRouter() @@ -23,4 +24,10 @@ tags=["Integrations"] ) +api_router.include_router( + users_router, + prefix="/v1/users", + tags=["Users"] +) + __all__ = ["api_router"] diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 00000000..62758154 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,139 @@ +"""User profile API endpoints.""" +from fastapi import APIRouter, HTTPException, Depends, status +from uuid import UUID +from pydantic import BaseModel +from typing import Optional, List +from app.core.dependencies import get_current_user +from app.services.auth.management import get_user_by_id, update_user_profile +import logging + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class UserProfileResponse(BaseModel): + """Response model for user profile.""" + id: str + email: Optional[str] = None + display_name: str + avatar_url: Optional[str] = None + bio: Optional[str] = None + location: Optional[str] = None + github_username: Optional[str] = None + discord_username: Optional[str] = None + slack_username: Optional[str] = None + preferred_languages: Optional[List[str]] = None + + +class UserProfileUpdateRequest(BaseModel): + """Request model for updating user profile.""" + display_name: Optional[str] = None + bio: Optional[str] = None + location: Optional[str] = None + avatar_url: Optional[str] = None + preferred_languages: Optional[List[str]] = None + + +@router.get("/me", response_model=UserProfileResponse) +async def get_current_user_profile(user_id: UUID = Depends(get_current_user)): + """Get the current user's profile information.""" + try: + user = await get_user_by_id(str(user_id)) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User profile not found" + ) + + return UserProfileResponse( + id=str(user.id), + email=user.email, + display_name=user.display_name, + avatar_url=user.avatar_url, + bio=user.bio, + location=user.location, + github_username=user.github_username, + discord_username=user.discord_username, + slack_username=user.slack_username, + preferred_languages=user.preferred_languages + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error fetching user profile: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to fetch user profile" + ) from e + + +@router.put("/me", response_model=UserProfileResponse) +async def update_current_user_profile( + request: UserProfileUpdateRequest, + user_id: UUID = Depends(get_current_user) +): + """Update the current user's profile information.""" + try: + # Build update dict from request, excluding None values + updates = {} + if request.display_name is not None: + updates["display_name"] = request.display_name + if request.bio is not None: + updates["bio"] = request.bio + if request.location is not None: + updates["location"] = request.location + if request.avatar_url is not None: + updates["avatar_url"] = request.avatar_url + if request.preferred_languages is not None: + updates["preferred_languages"] = request.preferred_languages + + if not updates: + # No updates provided, just return current profile + user = await get_user_by_id(str(user_id)) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User profile not found" + ) + return UserProfileResponse( + id=str(user.id), + email=user.email, + display_name=user.display_name, + avatar_url=user.avatar_url, + bio=user.bio, + location=user.location, + github_username=user.github_username, + discord_username=user.discord_username, + slack_username=user.slack_username, + preferred_languages=user.preferred_languages + ) + + updated_user = await update_user_profile(str(user_id), **updates) + + if not updated_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Failed to update user profile" + ) + + return UserProfileResponse( + id=str(updated_user.id), + email=updated_user.email, + display_name=updated_user.display_name, + avatar_url=updated_user.avatar_url, + bio=updated_user.bio, + location=updated_user.location, + github_username=updated_user.github_username, + discord_username=updated_user.discord_username, + slack_username=updated_user.slack_username, + preferred_languages=updated_user.preferred_languages + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating user profile: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update user profile" + ) from e diff --git a/backend/database/02_create_users_table.sql b/backend/database/02_create_users_table.sql new file mode 100644 index 00000000..060de5ff --- /dev/null +++ b/backend/database/02_create_users_table.sql @@ -0,0 +1,97 @@ +-- Create users table to store extended user profile information +CREATE TABLE IF NOT EXISTS public.users ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Contact & Identity + email TEXT, + + -- Platform Integrations + discord_id TEXT UNIQUE, + discord_username TEXT, + github_id TEXT UNIQUE, + github_username TEXT, + slack_id TEXT UNIQUE, + slack_username TEXT, + + -- Profile Information + display_name TEXT NOT NULL DEFAULT 'User', + avatar_url TEXT, + bio TEXT, + location TEXT, + + -- Verification + is_verified BOOLEAN NOT NULL DEFAULT false, + verification_token TEXT, + verification_token_expires_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + + -- Stats & Metadata + skills JSONB DEFAULT '{}', + github_stats JSONB DEFAULT '{}', + last_active_discord TIMESTAMPTZ, + last_active_github TIMESTAMPTZ, + last_active_slack TIMESTAMPTZ, + total_interactions_count INTEGER NOT NULL DEFAULT 0, + preferred_languages TEXT[] DEFAULT '{}' +); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_users_email ON public.users(email); +CREATE INDEX IF NOT EXISTS idx_users_discord_id ON public.users(discord_id); +CREATE INDEX IF NOT EXISTS idx_users_github_id ON public.users(github_id); +CREATE INDEX IF NOT EXISTS idx_users_slack_id ON public.users(slack_id); +CREATE INDEX IF NOT EXISTS idx_users_github_username ON public.users(github_username); + +-- Create trigger to automatically update updated_at +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON public.users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Enable Row Level Security (RLS) +ALTER TABLE public.users ENABLE ROW LEVEL SECURITY; + +-- Create RLS policies for users table +-- Users can view their own profile +CREATE POLICY "Users can view their own profile" + ON public.users + FOR SELECT + USING (auth.uid() = id); + +-- Users can update their own profile +CREATE POLICY "Users can update their own profile" + ON public.users + FOR UPDATE + USING (auth.uid() = id) + WITH CHECK (auth.uid() = id); + +-- Function to handle new user creation from auth.users +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.users (id, email, display_name, avatar_url) + VALUES ( + NEW.id, + NEW.email, + COALESCE(NEW.raw_user_meta_data->>'display_name', split_part(NEW.email, '@', 1), 'User'), + NEW.raw_user_meta_data->>'avatar_url' + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Trigger to create user profile when auth user is created +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION public.handle_new_user(); + +-- Add helpful comments +COMMENT ON TABLE public.users IS 'Extended user profile information synced with auth.users'; +COMMENT ON COLUMN public.users.display_name IS 'User display name from signup or profile update'; +COMMENT ON COLUMN public.users.skills IS 'User skills and expertise areas'; +COMMENT ON COLUMN public.users.github_stats IS 'Cached GitHub statistics'; +COMMENT ON COLUMN public.users.preferred_languages IS 'Array of preferred programming languages'; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f2cf10ed..a3c9f237 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -96,6 +96,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1585,6 +1586,7 @@ "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1655,6 +1657,7 @@ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", @@ -1907,6 +1910,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2124,6 +2128,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -2352,7 +2357,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-array": { "version": "3.2.4", @@ -2697,6 +2703,7 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4024,6 +4031,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4223,6 +4231,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4235,6 +4244,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4884,6 +4894,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4998,6 +5009,7 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5194,6 +5206,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/frontend/src/components/pages/ProfilePage.tsx b/frontend/src/components/pages/ProfilePage.tsx index fd48d2f9..f85cc247 100644 --- a/frontend/src/components/pages/ProfilePage.tsx +++ b/frontend/src/components/pages/ProfilePage.tsx @@ -1,26 +1,99 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; 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, MapPin, Edit, Camera, Save, X } from 'lucide-react'; +import { apiClient, UserProfile, UserProfileUpdateRequest } from '../../lib/api'; 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 = () => { + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [profile, setProfile] = useState(null); + const [editedProfile, setEditedProfile] = useState({}); + + useEffect(() => { + loadProfile(); + }, []); + + const loadProfile = async () => { + setIsLoading(true); + try { + const userProfile = await apiClient.getUserProfile(); + setProfile(userProfile); + // Initialize edited profile with current values + setEditedProfile({ + display_name: userProfile.display_name, + bio: userProfile.bio || '', + location: userProfile.location || '', + avatar_url: userProfile.avatar_url || '', + preferred_languages: userProfile.preferred_languages || [] + }); + } catch (error) { + console.error('Error loading profile:', error); + toast.error('Failed to load profile'); + } finally { + setIsLoading(false); + } + }; + + const handleSave = async () => { + if (!profile) return; + + setIsSaving(true); + try { + const updatedProfile = await apiClient.updateUserProfile(editedProfile); + setProfile(updatedProfile); + setIsEditing(false); + toast.success('Profile updated successfully!'); + } catch (error) { + console.error('Error updating profile:', error); + toast.error('Failed to update profile'); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + // Reset edited profile to current profile values + if (profile) { + setEditedProfile({ + display_name: profile.display_name, + bio: profile.bio || '', + location: profile.location || '', + avatar_url: profile.avatar_url || '', + preferred_languages: profile.preferred_languages || [] + }); + } setIsEditing(false); - toast.success('Profile updated successfully!'); }; + if (isLoading) { + return ( +
+
+
+

Loading profile...

+
+
+ ); + } + + if (!profile) { + return ( +
+
+

Failed to load profile

+ +
+
+ ); + } + 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 - - )} - -
- -
-
+ {!isEditing ? ( setIsEditing(true)} + className="px-4 py-2 bg-green-500 hover:bg-green-600 rounded-lg transition-colors flex items-center" > - + + Edit Profile + ) : ( +
+ + + Cancel + + + + {isSaving ? 'Saving...' : 'Save Changes'} + +
+ )} +
+ +
+
+ {isEditing && ( + + + + )}
Profile - - - + {isEditing && ( + + + + )}
- +
- - setProfile({ ...profile, name: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> + + {isEditing ? ( + setEditedProfile({ ...editedProfile, display_name: e.target.value })} + className="flex-1 bg-gray-800 text-white px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500" + placeholder="Enter your name" + /> + ) : ( + {profile.display_name} + )}
- +
- - setProfile({ ...profile, email: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> + + {profile.email || 'Not provided'}
- +
- - setProfile({ ...profile, company: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> + + {isEditing ? ( + setEditedProfile({ ...editedProfile, location: e.target.value })} + className="flex-1 bg-gray-800 text-white px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500" + placeholder="City, Country" + /> + ) : ( + {profile.location || 'Not specified'} + )}
-
- -
- - setProfile({ ...profile, website: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> + {profile.github_username && ( +
+ +
+ + + + {profile.github_username} +
-
+ )} -
- -
- - setProfile({ ...profile, github: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> + {profile.discord_username && ( +
+ +
+ + + + {profile.discord_username} +
-
+ )} -
- -
- - setProfile({ ...profile, twitter: e.target.value })} - disabled={!isEditing} - className="bg-transparent text-white focus:outline-none disabled:opacity-50" - /> + {profile.slack_username && ( +
+ +
+ + + + {profile.slack_username} +
-
+ )}
-