Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/app/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -23,4 +24,10 @@
tags=["Integrations"]
)

api_router.include_router(
profile_router,
prefix="/v1/profile",
tags=["Profile"]
)

__all__ = ["api_router"]
115 changes: 115 additions & 0 deletions backend/app/api/v1/profile.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions backend/app/database/supabase/scripts/create_db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ CREATE TABLE users (

display_name TEXT NOT NULL,
avatar_url TEXT,
avatar_path TEXT,
bio TEXT,
location TEXT,

Expand Down
148 changes: 147 additions & 1 deletion backend/app/database/supabase/services.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions backend/app/models/database/supabase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"
}
}
Loading