- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
1124 lines
35 KiB
Python
1124 lines
35 KiB
Python
"""
|
|
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 []}
|