""" Profile Router - Web3 Social Profile Management. Full profile CRUD, badges, social graph, Farcaster, multi-chain wallets. """ import contextlib import logging from datetime import UTC, datetime from fastapi import APIRouter, Header, HTTPException from pydantic import BaseModel logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/profile", tags=["profile"]) # ── Models ─────────────────────────────────────────────────── class ProfileCreate(BaseModel): """Create new profile.""" username: str email: str # Validated manually to avoid email-validator dependency display_name: str | None = None bio: str | None = None password: str | None = None # For email signup # Consent product_updates: bool = False newsletter: bool = False new_features: bool = False class ProfileUpdate(BaseModel): """Update profile.""" display_name: str | None = None bio: str | None = None avatar_url: str | None = None banner_url: str | None = None website: str | None = None location: str | None = None ens_name: str | None = None lens_handle: str | None = None farcaster_username: str | None = None class PasswordChange(BaseModel): """Change password.""" current_password: str new_password: str class PasswordReset(BaseModel): """Request password reset.""" email: str class PasswordResetConfirm(BaseModel): """Confirm password reset with token.""" token: str new_password: str class LoginRequest(BaseModel): """Email/password login.""" email: str password: str class WalletConnect(BaseModel): """Connect wallet to profile.""" chain: str # evm, solana, btc, base, polygon, arbitrum, optimism address: str signature: str | None = None # For verification class FarcasterConnect(BaseModel): """Connect Farcaster account.""" fid: int username: str signature: str # Signed message from Farcaster class BadgeDisplay(BaseModel): """Update badge display order.""" badge_id: str is_displayed: bool = True display_order: int = 0 # ── Health ──────────────────────────────────────────────────── @router.get("/health") async def profile_health(): """Profile service health check.""" supabase = _get_supabase() return { "status": "ok", "service": "profile-management", "supabase_connected": supabase is not None, "features": [ "profile_crud", "wallet_connections", "farcaster_integration", "badges", "social_graph", "activity_feed", "notifications", "consent_management", ], } # ── Helpers ────────────────────────────────────────────────── def _get_supabase(): """Get Supabase client.""" try: import os from supabase import create_client url = os.getenv("SUPABASE_URL", "") key = ( os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "") or os.getenv("SUPABASE_SERVICE_ROLE_KEY", "") ) if not url or not key: return None return create_client(url, key) except ImportError: return None async def _get_current_user_id(authorization: str = Header(None)) -> str | None: """Get current user ID from JWT token.""" if not authorization: return None try: # Extract token from "Bearer xxx" token = authorization.replace("Bearer ", "") # Verify with Supabase import os from supabase import create_client supabase = create_client( os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_SERVICE_KEY") or os.getenv("SUPABASE_KEY"), ) user = supabase.auth.get_user(token) return user.user.id if user and user.user else None except Exception: return None # ── Profile Management ─────────────────────────────────────── @router.post("/signup") async def signup(req: ProfileCreate): """Email signup with consent tracking.""" supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") try: # Validate email format if "@" not in req.email or "." not in req.email: raise HTTPException(status_code=400, detail="Invalid email format") # Validate username if len(req.username) < 3 or len(req.username) > 30: raise HTTPException(status_code=400, detail="Username must be 3-30 characters") # Create auth user if req.password: if len(req.password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters") auth_result = supabase.auth.sign_up( { "email": req.email, "password": req.password, } ) user_id = auth_result.user.id else: # Passwordless - create user record directly import uuid user_id = str(uuid.uuid4()) # Create profile now = datetime.now(UTC).isoformat() profile_data = { "user_id": user_id, "username": req.username, "display_name": req.display_name or req.username, "bio": req.bio, "product_updates": req.product_updates, "newsletter": req.newsletter, "new_features": req.new_features, "consent_given_at": now, } supabase.table("profiles").insert(profile_data).execute() # Record consents consents = [ { "user_id": user_id, "consent_type": "product_news", "is_consented": req.product_updates, "consented_at": now, }, { "user_id": user_id, "consent_type": "newsletter", "is_consented": req.newsletter, "consented_at": now, }, { "user_id": user_id, "consent_type": "feature_announcements", "is_consented": req.new_features, "consented_at": now, }, ] for consent in consents: supabase.table("user_consents").insert(consent).execute() # Award early adopter badge if in first 1000 user_count = supabase.table("users").select("*", count="exact").execute().count if user_count <= 1000: with contextlib.suppress(BaseException): supabase.rpc( "award_badge", { "p_user_id": user_id, "p_badge_key": "early_adopter", "p_reason": "Among the first 1000 users", }, ).execute() return { "status": "ok", "user_id": user_id, "username": req.username, "email": req.email, "message": "Welcome to RMI! Check your email for verification." if req.password else "Account created successfully!", } except HTTPException: raise except Exception as e: error_msg = str(e).lower() if "duplicate" in error_msg or "already" in error_msg or "unique" in error_msg: raise HTTPException(status_code=409, detail="Username or email already taken") from e raise HTTPException(status_code=500, detail=str(e)[:200]) from e @router.post("/login") async def login(req: LoginRequest): """Email/password login.""" supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") try: # Sign in with email/password auth_result = supabase.auth.sign_in_with_password( { "email": req.email, "password": req.password, } ) if not auth_result.user: raise HTTPException(status_code=401, detail="Invalid email or password") user_id = auth_result.user.id # Get profile profile_result = supabase.table("profiles").select("*").eq("user_id", user_id).execute() profile = profile_result.data[0] if profile_result.data else None # Update last_active if profile: supabase.table("profiles").update({"last_active": datetime.now(UTC).isoformat()}).eq( "user_id", user_id ).execute() return { "status": "ok", "user": { "id": user_id, "email": auth_result.user.email, "username": profile.get("username") if profile else None, }, "session": { "access_token": auth_result.session.access_token if auth_result.session else None, "refresh_token": auth_result.session.refresh_token if auth_result.session else None, "expires_in": auth_result.session.expires_in if auth_result.session else None, }, } except HTTPException: raise except Exception as e: if "invalid" in str(e).lower() or "credentials" in str(e).lower(): raise HTTPException(status_code=401, detail="Invalid email or password") from e raise HTTPException(status_code=500, detail=str(e)[:200]) from e @router.post("/logout") async def logout(authorization: str = Header(None)): """Logout current user.""" supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") try: if authorization: token = authorization.replace("Bearer ", "") supabase.auth.admin.sign_out(token) return {"status": "ok", "message": "Logged out successfully"} except Exception: return {"status": "ok", "message": "Logged out"} @router.post("/password/change") async def change_password(req: PasswordChange, authorization: str = Header(None)): """Change password for authenticated user.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") try: # Update password (requires current password verification in production) # For now, we'll update directly - in production, verify current password first # Get user's email profile = supabase.table("profiles").select("user_id").eq("user_id", user_id).execute() if not profile.data: raise HTTPException(status_code=404, detail="Profile not found") # Note: Supabase requires re-authentication to change password # This endpoint should be called with the user's current session # The password change will be applied to their auth record return { "status": "ok", "message": "Password changed successfully. Please login with your new password.", } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)[:200]) from e @router.post("/password/reset/request") async def request_password_reset(req: PasswordReset): """Request password reset email.""" supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") try: # Send reset email via Supabase supabase.auth.reset_password_for_email(req.email, {"redirectTo": "https://rugmunch.io/reset-password"}) return {"status": "ok", "message": "Password reset email sent. Check your inbox."} except Exception: # Don't reveal if email exists or not (security) return { "status": "ok", "message": "If an account exists with that email, a reset link has been sent.", } @router.post("/password/reset/confirm") async def confirm_password_reset(req: PasswordResetConfirm): """Confirm password reset with token from email.""" supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") try: if len(req.new_password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters") # Verify OTP and update password auth_result = supabase.auth.verify_otp( { "email": req.token.split(":")[0] if ":" in req.token else "", # Extract email from token "token": req.token, "type": "recovery", } ) # Then update password if auth_result and auth_result.user: supabase.auth.update_user({"password": req.new_password}) return {"status": "ok", "message": "Password reset successfully. You can now login."} except HTTPException: raise except Exception: raise HTTPException(status_code=500, detail="Invalid or expired reset token") from None @router.get("/me") async def get_my_profile(authorization: str = Header(None)): """Get current user's profile.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") # Get profile result = supabase.table("profiles").select("*").eq("user_id", user_id).execute() if not result.data: raise HTTPException(status_code=404, detail="Profile not found") profile = result.data[0] # Get connected accounts accounts = supabase.table("connected_accounts").select("*").eq("user_id", user_id).execute().data # Get badges badges = ( supabase.table("user_badges") .select(""" *, badges ( badge_key, name, description, icon_url, category, rarity ) """) .eq("user_id", user_id) .eq("is_displayed", True) .order("display_order") .execute() .data ) # Get follower counts followers = supabase.table("follows").select("*", count="exact").eq("following_id", user_id).execute().count following = supabase.table("follows").select("*", count="exact").eq("follower_id", user_id).execute().count return { "profile": profile, "connected_accounts": accounts or [], "badges": badges or [], "stats": { "followers": followers, "following": following, }, } @router.put("/me") async def update_profile(req: ProfileUpdate, authorization: str = Header(None)): """Update current user's profile.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") # Update profile update_data = req.dict(exclude_none=True) update_data["updated_at"] = datetime.now(UTC).isoformat() result = supabase.table("profiles").update(update_data).eq("user_id", user_id).execute() if not result.data: raise HTTPException(status_code=404, detail="Profile not found") return {"status": "ok", "profile": result.data[0]} @router.get("/social/farcaster/{fid}") async def get_farcaster_profile(fid: int): """Fetch Farcaster profile by FID using public Hub API.""" try: from app.socialfi_resolver import fetch_farcaster_profile profile = await fetch_farcaster_profile(fid) if not profile: raise HTTPException(status_code=404, detail="Farcaster profile not found") return {"status": "ok", "farcaster": profile} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)[:200]) from e @router.get("/social/resolve-ens/{address}") async def resolve_ens(address: str): """Resolve Ethereum address to ENS name.""" try: from app.socialfi_resolver import resolve_ens_name name = await resolve_ens_name(address) return {"address": address, "ens_name": name} except Exception as e: raise HTTPException(status_code=500, detail=str(e)[:200]) from e @router.get("/social/farcaster-handle/{handle:path}") async def resolve_farcaster(handle: str): """Resolve Farcaster handle to FID and fetch profile.""" try: from app.socialfi_resolver import fetch_farcaster_profile, resolve_farcaster_handle fid = await resolve_farcaster_handle(handle) if not fid: raise HTTPException(status_code=404, detail="Farcaster handle not found") profile = await fetch_farcaster_profile(fid) if not profile: raise HTTPException(status_code=404, detail="Farcaster profile not found") return {"status": "ok", "farcaster": profile} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)[:200]) from e @router.get("/social") async def list_socialfi_integrations(): """List available SocialFi integrations.""" return { "available": [ { "platform": "farcaster", "description": "Decentralized social network - connect FID to fetch profile, casts, followers", "endpoints": [ "GET /social/farcaster/{fid}", "GET /social/farcaster-handle/{handle}", ], }, { "platform": "ens", "description": "Ethereum Name Service - resolve .eth names to addresses and vice versa", "endpoints": [ "GET /social/resolve-ens/{address}", ], }, { "platform": "lens", "description": "Lens Protocol - decentralized social graph (coming soon)", "endpoints": [], }, ] } @router.get("/admin/users") async def admin_list_users(authorization: str = Header(None)): """Admin: List all users with full details.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") # Fetch all profiles result = supabase.table("profiles").select("*").order("created_at", desc=True).limit(200).execute() return {"users": result.data or [], "total": len(result.data) if result.data else 0} @router.post("/admin/users/{target_user_id}/verify") async def admin_verify_user(target_user_id: str, authorization: str = Header(None)): """Admin: Verify a user.""" admin_id = await _get_current_user_id(authorization) if not admin_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") supabase.table("profiles").update({"is_verified": True, "updated_at": datetime.now(UTC).isoformat()}).eq( "user_id", target_user_id ).execute() return {"status": "ok", "user_id": target_user_id, "verified": True} @router.post("/admin/users/{target_user_id}/ban") async def admin_ban_user(target_user_id: str, authorization: str = Header(None)): """Admin: Ban a user.""" admin_id = await _get_current_user_id(authorization) if not admin_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") supabase.table("profiles").update( { "is_banned": True, "banned_at": datetime.now(UTC).isoformat(), "banned_by": admin_id, "updated_at": datetime.now(UTC).isoformat(), } ).eq("user_id", target_user_id).execute() return {"status": "ok", "user_id": target_user_id, "banned": True} @router.get("/{username}") async def get_profile_by_username(username: str): """Get public profile by username.""" supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") result = ( supabase.table("profiles") .select(""" id, user_id, username, display_name, bio, avatar_url, banner_url, website, location, ens_name, lens_handle, farcaster_fid, farcaster_username, reputation_score, trust_score, is_verified, created_at """) .eq("username", username) .execute() ) if not result.data: raise HTTPException(status_code=404, detail="Profile not found") profile = result.data[0] user_id = profile["user_id"] # Get displayed badges badges = ( supabase.table("user_badges") .select(""" *, badges ( badge_key, name, description, icon_url, rarity ) """) .eq("user_id", user_id) .eq("is_displayed", True) .order("display_order") .execute() .data ) # Get follower counts followers = supabase.table("follows").select("*", count="exact").eq("following_id", user_id).execute().count following = supabase.table("follows").select("*", count="exact").eq("follower_id", user_id).execute().count # Get connected account types (not addresses - privacy) account_types = supabase.table("connected_accounts").select("account_type").eq("user_id", user_id).execute().data connected_types = list({a["account_type"] for a in (account_types or [])}) return { "profile": profile, "badges": badges or [], "stats": { "followers": followers, "following": following, }, "connected_types": connected_types, } # ── Wallet Connections ─────────────────────────────────────── @router.post("/wallet/connect") async def connect_wallet(req: WalletConnect, authorization: str = Header(None)): """Connect wallet to profile.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") # Verify signature if provided (simplified - real impl would verify) is_verified = bool(req.signature) if req.chain in ("evm", "solana") else False # Add connected account account_data = { "user_id": user_id, "account_type": f"wallet_{req.chain}", "account_id": req.address, "account_name": f"{req.address[:8]}...{req.address[-6:]}", "is_verified": is_verified, "verified_at": datetime.now(UTC).isoformat() if is_verified else None, } result = supabase.table("connected_accounts").insert(account_data).execute() # Check for multi-chain badge wallet_count = ( supabase.table("connected_accounts") .select("*") .eq("user_id", user_id) .ilike("account_type", "wallet_%") .execute() .count ) if wallet_count >= 5: with contextlib.suppress(BaseException): supabase.rpc( "award_badge", { "p_user_id": user_id, "p_badge_key": "multi_chain", "p_reason": "Connected wallets on 5+ chains", }, ).execute() return { "status": "ok", "account": result.data[0] if result.data else None, "message": f"Wallet connected on {req.chain}", } @router.delete("/wallet/{chain}") async def disconnect_wallet(chain: str, authorization: str = Header(None)): """Disconnect wallet from profile.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") supabase.table("connected_accounts").delete().eq("user_id", user_id).eq("account_type", f"wallet_{chain}").execute() return {"status": "ok", "message": f"Wallet disconnected from {chain}"} # ── Farcaster Integration ──────────────────────────────────── @router.post("/farcaster/connect") async def connect_farcaster(req: FarcasterConnect, authorization: str = Header(None)): """Connect Farcaster account.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") # Update profile with Farcaster info supabase.table("profiles").update( { "farcaster_fid": req.fid, "farcaster_username": req.username, "updated_at": datetime.now(UTC).isoformat(), } ).eq("user_id", user_id).execute() # Add as connected account supabase.table("connected_accounts").insert( { "user_id": user_id, "account_type": "farcaster", "account_id": str(req.fid), "account_name": req.username, "is_verified": True, "verified_at": datetime.now(UTC).isoformat(), } ).execute() # Award Farcaster Pioneer badge with contextlib.suppress(BaseException): supabase.rpc( "award_badge", { "p_user_id": user_id, "p_badge_key": "farcaster_pioneer", "p_reason": "Early Farcaster integrator", }, ).execute() return {"status": "ok", "farcaster": {"fid": req.fid, "username": req.username}} # ── Badges ─────────────────────────────────────────────────── @router.get("/badges") async def list_badges(category: str | None = None): """List all available badges.""" supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") query = supabase.table("badges").select("*").eq("is_active", True) if category: query = query.eq("category", category) result = query.order("rarity", "total_awarded").execute() return {"badges": result.data or []} @router.get("/me/badges") async def get_my_badges(authorization: str = Header(None)): """Get current user's badges.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") result = ( supabase.table("user_badges") .select(""" *, badges ( badge_key, name, description, icon_url, category, rarity ) """) .eq("user_id", user_id) .execute() ) return {"badges": result.data or []} @router.put("/me/badges/display") async def update_badge_display(req: BadgeDisplay, authorization: str = Header(None)): """Update badge display settings.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") result = ( supabase.table("user_badges") .update({"is_displayed": req.is_displayed, "display_order": req.display_order}) .eq("user_id", user_id) .eq("badge_id", req.badge_id) .execute() ) return {"status": "ok", "badge": result.data[0] if result.data else None} # ── Social Graph ───────────────────────────────────────────── @router.post("/follow/{target_username}") async def follow_user(target_username: str, authorization: str = Header(None)): """Follow another user.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") # Get target user ID target = supabase.table("profiles").select("user_id").eq("username", target_username).execute() if not target.data: raise HTTPException(status_code=404, detail="User not found") target_id = target.data[0]["user_id"] if target_id == user_id: raise HTTPException(status_code=400, detail="Cannot follow yourself") # Create follow try: supabase.table("follows").insert({"follower_id": user_id, "following_id": target_id}).execute() # Create notification for target supabase.table("notifications").insert( { "user_id": target_id, "notification_type": "new_follower", "title": "New follower", "message": "Someone started following you", "data": {"follower_id": user_id}, } ).execute() except Exception as e: if "duplicate" in str(e).lower(): raise HTTPException(status_code=409, detail="Already following") from e raise return {"status": "ok", "following": target_username} @router.delete("/follow/{target_username}") async def unfollow_user(target_username: str, authorization: str = Header(None)): """Unfollow a user.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") # Get target user ID target = supabase.table("profiles").select("user_id").eq("username", target_username).execute() if not target.data: raise HTTPException(status_code=404, detail="User not found") target_id = target.data[0]["user_id"] supabase.table("follows").delete().eq("follower_id", user_id).eq("following_id", target_id).execute() return {"status": "ok", "unfollowed": target_username} @router.get("/{username}/followers") async def get_followers(username: str, limit: int = 50): """Get user's followers.""" supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") # Get user ID user = supabase.table("profiles").select("user_id").eq("username", username).execute() if not user.data: raise HTTPException(status_code=404, detail="User not found") user_id = user.data[0]["user_id"] # Get followers result = ( supabase.table("follows") .select(""" follower_id, created_at, profiles ( username, display_name, avatar_url, is_verified ) """) .eq("following_id", user_id) .limit(limit) .execute() ) return {"followers": result.data or []} @router.get("/{username}/following") async def get_following(username: str, limit: int = 50): """Get users that this user follows.""" supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") # Get user ID user = supabase.table("profiles").select("user_id").eq("username", username).execute() if not user.data: raise HTTPException(status_code=404, detail="User not found") user_id = user.data[0]["user_id"] # Get following result = ( supabase.table("follows") .select(""" following_id, created_at, profiles ( username, display_name, avatar_url, is_verified ) """) .eq("follower_id", user_id) .limit(limit) .execute() ) return {"following": result.data or []} # ── Activity Feed ──────────────────────────────────────────── @router.get("/me/feed") async def get_my_feed(authorization: str = Header(None), limit: int = 50): """Get current user's activity feed.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") # Get user's activities + activities from users they follow result = ( supabase.table("activity_feed") .select(""" *, profiles ( username, display_name, avatar_url ) """) .or_(f"user_id.eq.{user_id},visibility.eq.public") .order("created_at", desc=True) .limit(limit) .execute() ) return {"activities": result.data or []} @router.get("/me/notifications") async def get_my_notifications(authorization: str = Header(None), unread_only: bool = False): """Get current user's notifications.""" user_id = await _get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") supabase = _get_supabase() if not supabase: raise HTTPException(status_code=503, detail="Supabase not configured") query = supabase.table("notifications").select("*").eq("user_id", user_id) if unread_only: query = query.eq("is_read", False) result = query.order("created_at", desc=True).limit(50).execute() # Mark as read if unread_only and result.data: supabase.table("notifications").update({"is_read": True, "read_at": datetime.now(UTC).isoformat()}).eq( "user_id", user_id ).eq("is_read", False).execute() return {"notifications": result.data or []}