Spaces:
Running on Zero
Running on Zero
| from datetime import datetime, timezone | |
| from typing import Any, Dict, Optional | |
| from fastapi import APIRouter, Body, Header, HTTPException | |
| import requests | |
| from app.core.config import ( | |
| SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_TIMEOUT_SECONDS, | |
| PHONE_PATTERN, PROFILE_SELECT_FIELDS, supabase_configured, LOGGER, | |
| ) | |
| from app.security.auth import require_authenticated_user, _supabase_headers, _normalize_spaces | |
| from app.services.supabase_client import supabase_get_profile_by_user_id, supabase_get_profile_by_field | |
| from app.api.helpers import ( | |
| _resolve_profile_name_fields, _compose_full_name, _missing_schema_hint, | |
| ) | |
| router = APIRouter(prefix="/profile", tags=["profile"]) | |
| def profile_update( | |
| payload: Dict[str, Any] = Body(...), | |
| authorization: Optional[str] = Header(default=None), | |
| ) -> Dict[str, Any]: | |
| if not supabase_configured(): | |
| raise HTTPException(status_code=503, detail="Supabase is not configured.") | |
| request_user = require_authenticated_user(authorization) | |
| if request_user is None: | |
| raise HTTPException(status_code=401, detail="Authentication is required.") | |
| requester_id = str(request_user.get("id") or "").strip() | |
| if not requester_id: | |
| raise HTTPException(status_code=401, detail="Authenticated user id is missing.") | |
| current_profile = supabase_get_profile_by_user_id(requester_id) or {} | |
| current_first_name, current_last_name, _ = _resolve_profile_name_fields( | |
| current_profile if isinstance(current_profile, dict) else None, | |
| fallback_full_name=(current_profile or {}).get("full_name") if isinstance(current_profile, dict) else None, | |
| ) | |
| name_fields_provided = any(key in payload for key in ["first_name", "firstName", "last_name", "lastName", "full_name", "name"]) | |
| requested_first_name, requested_last_name, _ = _resolve_profile_name_fields(payload) | |
| email = _normalize_spaces(str(payload.get("email") or "")).lower() | |
| phone_number = _normalize_spaces(str(payload.get("phone") or payload.get("phone_number") or "")) | |
| profile_patch: Dict[str, Any] = {} | |
| if name_fields_provided: | |
| next_first_name = requested_first_name or current_first_name | |
| next_last_name = requested_last_name or current_last_name | |
| next_full_name = _compose_full_name(next_first_name, next_last_name) | |
| if not next_full_name: | |
| raise HTTPException(status_code=400, detail="Name update requires at least first_name or full_name.") | |
| profile_patch["first_name"] = next_first_name | |
| profile_patch["last_name"] = next_last_name | |
| profile_patch["full_name"] = next_full_name | |
| if email: | |
| if "@" not in email: | |
| raise HTTPException(status_code=400, detail="email format is invalid.") | |
| other_with_email = supabase_get_profile_by_field("email", email, case_insensitive=True) | |
| if isinstance(other_with_email, dict) and str(other_with_email.get("id") or "") != requester_id: | |
| raise HTTPException(status_code=409, detail="email is already in use.") | |
| profile_patch["email"] = email | |
| if phone_number: | |
| if not PHONE_PATTERN.fullmatch(phone_number): | |
| raise HTTPException(status_code=400, detail="phone must be 7-15 digits and may start with +.") | |
| other_with_phone = supabase_get_profile_by_field("phone_number", phone_number) | |
| if isinstance(other_with_phone, dict) and str(other_with_phone.get("id") or "") != requester_id: | |
| raise HTTPException(status_code=409, detail="phone is already in use.") | |
| profile_patch["phone_number"] = phone_number | |
| if not profile_patch: | |
| raise HTTPException(status_code=400, detail="No profile fields were provided.") | |
| response = requests.patch( | |
| f"{SUPABASE_URL}/rest/v1/profiles", | |
| params={"id": f"eq.{requester_id}", "select": PROFILE_SELECT_FIELDS}, | |
| json=profile_patch, | |
| headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY, content_type="application/json", prefer="return=representation"), | |
| timeout=SUPABASE_TIMEOUT_SECONDS, | |
| ) | |
| if response.status_code not in {200, 204}: | |
| raise HTTPException(status_code=400, detail=f"Failed to update profile: {_missing_schema_hint(response.text)}") | |
| updated_rows = response.json() if response.text else [] | |
| updated_profile = ( | |
| updated_rows[0] | |
| if isinstance(updated_rows, list) and updated_rows and isinstance(updated_rows[0], dict) | |
| else (supabase_get_profile_by_user_id(requester_id) or current_profile) | |
| ) | |
| auth_patch: Dict[str, Any] = {} | |
| user_metadata_patch: Dict[str, Any] = {} | |
| if "email" in profile_patch: | |
| auth_patch["email"] = profile_patch["email"] | |
| if "first_name" in profile_patch: | |
| user_metadata_patch["first_name"] = profile_patch["first_name"] | |
| if "last_name" in profile_patch: | |
| user_metadata_patch["last_name"] = profile_patch["last_name"] | |
| if "full_name" in profile_patch: | |
| user_metadata_patch["full_name"] = profile_patch["full_name"] | |
| if "phone_number" in profile_patch: | |
| user_metadata_patch["phone_number"] = profile_patch["phone_number"] | |
| user_metadata_patch["phone"] = profile_patch["phone_number"] | |
| if user_metadata_patch: | |
| auth_patch["user_metadata"] = user_metadata_patch | |
| if auth_patch: | |
| try: | |
| auth_response = requests.put( | |
| f"{SUPABASE_URL}/auth/v1/admin/users/{requester_id}", | |
| json=auth_patch, | |
| headers={"apikey": SUPABASE_SERVICE_ROLE_KEY, "Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}", "Content-Type": "application/json"}, | |
| timeout=SUPABASE_TIMEOUT_SECONDS, | |
| ) | |
| if auth_response.status_code not in {200, 201}: | |
| LOGGER.warning("Auth metadata update failed for %s: %s", requester_id, auth_response.text[:220]) | |
| except requests.RequestException as exc: | |
| LOGGER.warning("Auth metadata update request failed for %s: %s", requester_id, exc) | |
| return {"status": "ok", "profile": updated_profile, "message": "Profile updated successfully."} | |