diff --git a/backend/Dockerfile b/backend/Dockerfile index be1624f..e58a72b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* -# Install Python dependencies +# Install Python dependencies (API image — no TensorFlow). +# TF lives only in the ml_service image; the API calls it over HTTP +# via ML_SERVICE_URL. When the env var is unset the in-process +# fallback still works if tensorflow happens to be installed. COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..eda83c7 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,81 @@ +""" +Centralized application settings via pydantic-settings. + +All environment variables are declared once here. Other modules import +``settings`` (the singleton instance) rather than calling ``os.getenv`` +directly. +""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +from pydantic import model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +def _extract_jwt_role(jwt_token: str) -> str | None: + """Best-effort JWT payload parse to read the Supabase key role claim.""" + try: + parts = jwt_token.split(".") + if len(parts) < 2: + return None + payload = parts[1] + padding = "=" * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode( + (payload + padding).encode("utf-8") + ).decode("utf-8") + return json.loads(decoded).get("role") + except Exception: + return None + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=str(Path(__file__).resolve().parent / ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + # ---- Supabase ---- + supabase_url: str + supabase_anon_key: str + supabase_service_key: str + + # ---- Application ---- + environment: str = "development" + cors_origins: str = "" + admin_secret: str = "" + + # ---- ML service ---- + ml_service_url: str = "" + + # ---- Phase 3B serving controls ---- + padly_group_neural_ranking_enabled: bool = False + padly_group_neural_kill_switch: bool = False + padly_stable_group_listing_writes_enabled: bool = False + + # ---- Derived helpers ---- + @property + def is_dev(self) -> bool: + return self.environment.lower() in ("development", "dev", "local") + + # ---- Validators ---- + @model_validator(mode="after") + def _validate_supabase_keys(self) -> "Settings": + if self.supabase_service_key == self.supabase_anon_key: + raise ValueError( + "Invalid configuration: SUPABASE_SERVICE_KEY matches SUPABASE_ANON_KEY" + ) + role = _extract_jwt_role(self.supabase_service_key) + if role != "service_role": + raise ValueError( + f"Invalid SUPABASE_SERVICE_KEY role: expected 'service_role', " + f"got '{role or 'unknown'}'" + ) + return self + + +settings = Settings() diff --git a/backend/app/db.py b/backend/app/db.py index 7549b25..175f807 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,54 +1,19 @@ -from supabase import create_client, Client -from dotenv import load_dotenv -import os -from pathlib import Path -import base64 -import json - -# Load the .env file from the backend directory (one level up from app/) -env_path = Path(__file__).parent.parent / '.env' -load_dotenv(dotenv_path=env_path) - -# Environment variables -SUPABASE_URL = os.getenv("SUPABASE_URL") -SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY") -SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY") -VERIFY_JWT = os.getenv("VERIFY_JWT", "false").lower() == "true" -SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET") +""" +Supabase client singletons. +Reads credentials from ``app.config.settings`` (which loads the ``.env`` +file automatically via pydantic-settings). No direct ``os.getenv`` calls +are needed here. +""" -def _extract_jwt_role(jwt_token: str) -> str | None: - """Best-effort JWT payload parse to validate expected Supabase key role.""" - try: - parts = jwt_token.split('.') - if len(parts) < 2: - return None - payload = parts[1] - # JWT uses URL-safe base64 without padding. - padding = '=' * (-len(payload) % 4) - decoded = base64.urlsafe_b64decode((payload + padding).encode('utf-8')).decode('utf-8') - return json.loads(decoded).get('role') - except Exception: - return None - -# Validation -if not SUPABASE_URL: - raise ValueError("Missing SUPABASE_URL in environment") -if not SUPABASE_ANON_KEY: - raise ValueError("Missing SUPABASE_ANON_KEY in environment") -if not SUPABASE_SERVICE_KEY: - raise ValueError("Missing SUPABASE_SERVICE_KEY in environment") +from supabase import create_client, Client -if SUPABASE_SERVICE_KEY == SUPABASE_ANON_KEY: - raise ValueError("Invalid configuration: SUPABASE_SERVICE_KEY matches SUPABASE_ANON_KEY") +from app.config import settings -service_role = _extract_jwt_role(SUPABASE_SERVICE_KEY) -if service_role != "service_role": - raise ValueError( - f"Invalid SUPABASE_SERVICE_KEY role: expected 'service_role', got '{service_role or 'unknown'}'" - ) +SUPABASE_URL: str = settings.supabase_url +SUPABASE_ANON_KEY: str = settings.supabase_anon_key +SUPABASE_SERVICE_KEY: str = settings.supabase_service_key -# Create clients # Service role client (admin operations, bypasses RLS) supabase_admin: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) diff --git a/backend/app/dependencies/auth.py b/backend/app/dependencies/auth.py index a4ee19f..4d3200c 100644 --- a/backend/app/dependencies/auth.py +++ b/backend/app/dependencies/auth.py @@ -3,10 +3,11 @@ Handles JWT token extraction from request headers """ -import os from fastapi import Header, HTTPException from typing import Optional, Any +from app.config import settings + async def require_admin_key( x_admin_secret: Optional[str] = Header(None) @@ -20,7 +21,7 @@ async def require_admin_key( Raises: HTTPException 401: if header is missing or incorrect """ - expected = os.getenv("ADMIN_SECRET") + expected = settings.admin_secret if not expected: raise HTTPException( status_code=503, diff --git a/backend/app/main.py b/backend/app/main.py index b39f081..9b01414 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,12 +4,10 @@ to find housing and compatible roommates. """ -import os - from fastapi import FastAPI - -_is_dev = os.getenv("ENVIRONMENT", "development").lower() in ("development", "dev", "local") from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings from app.routes import ( users_router, listings_router, @@ -21,20 +19,20 @@ matches_router, recommendations_router, interactions_router, + groups_router, guest_interactions_router, options_router, + roommate_intros_router, ) -from app.routes.roommate_intros import router as roommate_intros_router -from app.routes.groups import router as groups_router # Initialize FastAPI application app = FastAPI( title="Padly API", version="1.0.0", description="Backend API for Padly - Housing and Roommate Matching Platform", - docs_url="/docs" if _is_dev else None, - redoc_url="/redoc" if _is_dev else None, - openapi_url="/openapi.json" if _is_dev else None, + docs_url="/docs" if settings.is_dev else None, + redoc_url="/redoc" if settings.is_dev else None, + openapi_url="/openapi.json" if settings.is_dev else None, ) # Configure CORS (Starlette returns 400 on failed preflight — usually wrong Origin) @@ -45,9 +43,10 @@ "https://padly.tech", "https://www.padly.tech", ] -_extra = os.getenv("CORS_ORIGINS", "").strip() -if _extra: - _cors_origins.extend(o.strip() for o in _extra.split(",") if o.strip()) +if settings.cors_origins.strip(): + _cors_origins.extend( + o.strip() for o in settings.cors_origins.split(",") if o.strip() + ) app.add_middleware( CORSMiddleware, diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py index 9dc17ab..df9088f 100644 --- a/backend/app/routes/__init__.py +++ b/backend/app/routes/__init__.py @@ -12,6 +12,7 @@ from .matches import router as matches_router from .recommendations import router as recommendations_router from .interactions import router as interactions_router +from .groups import router as groups_router from .guest_interactions import router as guest_interactions_router from .options import router as options_router from .roommate_intros import router as roommate_intros_router @@ -27,6 +28,7 @@ "matches_router", "recommendations_router", "interactions_router", + "groups_router", "guest_interactions_router", "options_router", "roommate_intros_router", diff --git a/backend/app/routes/groups.py b/backend/app/routes/groups.py deleted file mode 100644 index ca1ca9c..0000000 --- a/backend/app/routes/groups.py +++ /dev/null @@ -1,2527 +0,0 @@ -""" -Roommate Groups API Routes -CRUD operations for roommate groups and member management -""" - -from fastapi import APIRouter, Depends, HTTPException, Query -from typing import Optional, List, Dict, Any -from app.dependencies.auth import get_user_token, require_user_token, resolve_auth_user -from app.dependencies.supabase import get_admin_client -from app.models import RoommateGroupCreate, RoommateGroupUpdate, RoommateGroupResponse -from app.services.controlled_vocab import validate_city_name, validate_neighborhoods -from app.services.behavior_features import build_group_behavior_vector -from app.services.location_matching import cities_match, filter_listings_for_location -from app.services.preferences_contract import ( - normalize_lease_type, - resolve_furnished_preference, - target_furnished_from_preference, -) -from app.ai.recommender import score_listings -from pydantic import BaseModel -from datetime import datetime -import os - -router = APIRouter(prefix="/api/roommate-groups", tags=["Roommate Groups"]) - - -def _env_bool(name: str, default: bool = False) -> bool: - value = os.getenv(name) - if value is None: - return default - return str(value).strip().lower() in {"1", "true", "yes", "on"} - - -def _fetch_active_listings_for_group_location(supabase: Any, group: Dict[str, Any]) -> List[Dict[str, Any]]: - """Load all active listings, then apply metro-aware location matching in Python.""" - page_size = 1000 - page = 0 - listings: List[Dict[str, Any]] = [] - - while True: - batch = ( - supabase.table("listings") - .select("*") - .eq("status", "active") - .range(page * page_size, page * page_size + page_size - 1) - .execute() - .data - or [] - ) - if not batch: - break - listings.extend(batch) - if len(batch) < page_size: - break - page += 1 - - return filter_listings_for_location( - listings, - target_city=group.get("target_city"), - target_state=group.get("target_state_province"), - target_country=group.get("target_country"), - ) - - -# ============================================================================= -# Pydantic Models for Requests/Responses -# ============================================================================= - - -def _normalize_group_preference_payload(payload: Dict[str, Any]) -> Dict[str, Any]: - """ - Keep personal-style and group-legacy field names in sync. - - Personal-style: - - budget_min / budget_max / move_in_date / required_bedrooms - Group-legacy: - - budget_per_person_min / budget_per_person_max / target_move_in_date / target_bedrooms - """ - out = dict(payload) - - # Budget aliases - if out.get("budget_min") is not None and out.get("budget_per_person_min") is None: - out["budget_per_person_min"] = out["budget_min"] - if out.get("budget_per_person_min") is not None and out.get("budget_min") is None: - out["budget_min"] = out["budget_per_person_min"] - if out.get("budget_max") is not None and out.get("budget_per_person_max") is None: - out["budget_per_person_max"] = out["budget_max"] - if out.get("budget_per_person_max") is not None and out.get("budget_max") is None: - out["budget_max"] = out["budget_per_person_max"] - - # Move-in aliases - if out.get("move_in_date") is not None and out.get("target_move_in_date") is None: - out["target_move_in_date"] = out["move_in_date"] - if out.get("target_move_in_date") is not None and out.get("move_in_date") is None: - out["move_in_date"] = out["target_move_in_date"] - - # Bedroom aliases - if out.get("required_bedrooms") is not None and out.get("target_bedrooms") is None: - out["target_bedrooms"] = out["required_bedrooms"] - if out.get("target_bedrooms") is not None and out.get("required_bedrooms") is None: - out["required_bedrooms"] = out["target_bedrooms"] - - # Furnished tri-state alignment. - furnished_pref = resolve_furnished_preference( - out.get("furnished_preference"), - out.get("target_furnished"), - ) - if furnished_pref is not None: - out["furnished_preference"] = furnished_pref - out["target_furnished"] = target_furnished_from_preference(furnished_pref) - out["furnished_is_hard"] = furnished_pref == "required" - - # Lease-type alignment (frontend canonical values). - if "target_lease_type" in out and out.get("target_lease_type") is not None: - normalized_lease_type = normalize_lease_type(out.get("target_lease_type")) - if normalized_lease_type is not None: - out["target_lease_type"] = normalized_lease_type - - # Keep arrays/dicts clean. - if out.get("preferred_neighborhoods") is not None: - values = [str(v).strip() for v in (out.get("preferred_neighborhoods") or []) if str(v).strip()] - # Preserve order, remove duplicates. - out["preferred_neighborhoods"] = list(dict.fromkeys(values)) - if out.get("lifestyle_preferences") is not None and not isinstance(out.get("lifestyle_preferences"), dict): - out["lifestyle_preferences"] = {} - - return out - - -def _to_json_serializable_payload(payload: Dict[str, Any]) -> Dict[str, Any]: - """Convert decimal/date-like values so Supabase receives JSON-serializable fields.""" - from decimal import Decimal - from datetime import date as date_type, datetime as datetime_type - - out = dict(payload) - for key, value in list(out.items()): - if isinstance(value, Decimal): - out[key] = float(value) - elif isinstance(value, date_type) and not isinstance(value, datetime_type): - out[key] = value.isoformat() - return out - - -def _normalize_group_record_for_response(group: Dict[str, Any]) -> Dict[str, Any]: - """ - Normalize a fetched group record so response payloads always expose both - personal-style and legacy group aliases. - """ - if not isinstance(group, dict): - return group - return _normalize_group_preference_payload(group) - - -def _build_group_update_from_aggregate_prefs(aggregate_prefs: Dict[str, Any]) -> Dict[str, Any]: - """ - Build a roommate_groups update payload from aggregated member preferences. - """ - allowed_fields = [ - "target_country", - "target_state_province", - "target_city", - "budget_min", - "budget_max", - "budget_per_person_min", - "budget_per_person_max", - "move_in_date", - "target_move_in_date", - "required_bedrooms", - "target_bedrooms", - "target_bathrooms", - "target_furnished", - "furnished_preference", - "furnished_is_hard", - "target_utilities_included", - "target_deposit_amount", - "gender_policy", - "target_lease_type", - "target_lease_duration_months", - "target_house_rules", - "preferred_neighborhoods", - "lifestyle_preferences", - ] - - update_data: Dict[str, Any] = {} - nullable_passthrough_fields = { - "target_furnished", - "target_utilities_included", - "target_deposit_amount", - "target_house_rules", - "target_lease_type", - "target_lease_duration_months", - } - - for key in allowed_fields: - if key not in aggregate_prefs: - continue - if aggregate_prefs[key] is not None or key in nullable_passthrough_fields: - update_data[key] = aggregate_prefs[key] - - update_data = _normalize_group_preference_payload(update_data) - return _to_json_serializable_payload(update_data) - - -def _aggregate_and_persist_group_preferences(group_id: str) -> Dict[str, Any]: - """ - Recompute aggregate preferences for a group and persist them. - """ - from app.services.group_preferences_aggregator import calculate_aggregate_group_preferences - - supabase = get_admin_client() - aggregate_prefs = calculate_aggregate_group_preferences(group_id) - update_data = _build_group_update_from_aggregate_prefs(aggregate_prefs) - - if not update_data: - return {"status": "skipped"} - - supabase.table("roommate_groups").update(update_data).eq("id", group_id).execute() - return { - "status": "success", - "updated_fields": list(update_data.keys()), - "aggregate_prefs": { - "budget_min": update_data.get("budget_min"), - "budget_max": update_data.get("budget_max"), - "move_in_date": update_data.get("move_in_date"), - "required_bedrooms": update_data.get("required_bedrooms"), - "target_bathrooms": update_data.get("target_bathrooms"), - "furnished_preference": update_data.get("furnished_preference"), - "gender_policy": update_data.get("gender_policy"), - }, - } - -class GroupMemberInvite(BaseModel): - """Model for inviting a user to a group""" - user_email: str - message: Optional[str] = None - - -class GroupMemberResponse(BaseModel): - """Model for group member response""" - id: str - group_id: str - user_id: str - is_creator: bool - status: str - joined_at: datetime - # User details (joined from users table) - user_email: Optional[str] = None - user_name: Optional[str] = None - - -class GroupWithMembers(BaseModel): - """Model for group with member details""" - id: str - creator_user_id: str - group_name: str - description: Optional[str] - target_city: str - budget_per_person_min: Optional[float] - budget_per_person_max: Optional[float] - target_move_in_date: Optional[str] - target_group_size: Optional[int] - status: str - created_at: datetime - updated_at: datetime - members: List[GroupMemberResponse] - member_count: int - - -# ============================================================================= -# Group CRUD Endpoints -# ============================================================================= - -@router.get("", response_model=dict) -async def list_groups( - token: str = Depends(require_user_token), - status: Optional[str] = Query(None, description="Filter by status (active, inactive, matched)"), - city: Optional[str] = Query(None, description="Filter by target city"), - my_groups: bool = Query(False, description="Only show groups I'm a member of"), - limit: int = Query(100, ge=1, le=500), - offset: int = Query(0, ge=0) -): - """ - List roommate groups with optional filters. - - Query params: - - status: Filter by group status - - city: Filter by target city - - my_groups: Only show groups the current user is a member of - - limit: Max results (default: 100) - - offset: Pagination offset - """ - supabase = get_admin_client() - - # Build query - query = supabase.table('roommate_groups').select('*') - - # Apply filters - if status: - query = query.eq('status', status) - if city: - query = query.ilike('target_city', f'%{city}%') - - # Order and paginate - query = query.order('created_at', desc=True).range(offset, offset + limit - 1) - - response = query.execute() - groups = response.data - - # If my_groups is requested, filter by membership - if my_groups and token: - # Get current user from token - auth_user = resolve_auth_user(supabase, token) - if auth_user: - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - - if user_record.data: - user_id = user_record.data[0]['id'] - - # Get groups where user is an ACCEPTED member (not pending or rejected) - member_response = supabase.table('group_members')\ - .select('group_id')\ - .eq('user_id', user_id)\ - .eq('status', 'accepted')\ - .execute() - - member_group_ids = [m['group_id'] for m in member_response.data] - groups = [g for g in groups if g['id'] in member_group_ids] - else: - # User not found in users table, return empty - groups = [] - - # Calculate actual accepted member count for each group - if groups: - group_ids = [g['id'] for g in groups] - - # Get all accepted members for these groups - members_response = supabase.table('group_members')\ - .select('group_id')\ - .in_('group_id', group_ids)\ - .eq('status', 'accepted')\ - .execute() - - # Count members per group - member_counts = {} - for member in members_response.data: - gid = member['group_id'] - member_counts[gid] = member_counts.get(gid, 0) + 1 - - # Update each group with the accurate count - for group in groups: - group['current_member_count'] = member_counts.get(group['id'], 0) - normalized = _normalize_group_record_for_response(group) - group.clear() - group.update(normalized) - - return { - "status": "success", - "count": len(groups), - "data": groups - } - -# ============================================================================= -# User's Pending Join Requests -# ============================================================================= - -@router.get("/my-pending-requests", response_model=dict) -async def get_my_pending_requests(token: str = Depends(require_user_token)): - """ - Get all pending join requests for the current user. - - Returns list of groups where the user has a pending join request. - """ - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - - user_id = user_record.data[0]['id'] - - # Get all pending memberships for this user - pending_response = supabase.table('group_members')\ - .select('group_id, joined_at, roommate_groups(id, group_name, target_city, status)')\ - .eq('user_id', user_id)\ - .eq('status', 'pending')\ - .execute() - - pending_requests = [] - for membership in pending_response.data: - group_data = membership.get('roommate_groups', {}) if membership.get('roommate_groups') else {} - pending_requests.append({ - 'group_id': membership['group_id'], - 'group_name': group_data.get('group_name'), - 'target_city': group_data.get('target_city'), - 'group_status': group_data.get('status'), - 'requested_at': membership.get('joined_at') - }) - - return { - "status": "success", - "count": len(pending_requests), - "data": pending_requests - } - - -# Group Discovery & User-to-Group Matching -# ============================================================================= - -@router.get("/discover", response_model=dict) -async def discover_groups( - city: str = Query(..., description="Target city"), - budget_min: Optional[float] = Query(None, description="Minimum budget per person"), - budget_max: Optional[float] = Query(None, description="Maximum budget per person"), - move_in_date: Optional[str] = Query(None, description="Target move-in date (ISO format)"), - min_compatibility_score: int = Query(50, ge=0, le=100, description="Minimum compatibility score"), - limit: int = Query(20, ge=1, le=100, description="Max results"), - token: str = Depends(require_user_token) -): - """ - Discover compatible roommate groups based on user preferences. - - Returns groups ranked by compatibility score with detailed reasons. - """ - from app.services.user_group_matching import find_compatible_groups - - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - user_id = auth_user.id - - # Get user from database - user_db_response = supabase.table("users").select("*").eq("id", user_id).single().execute() - - if not user_db_response.data: - raise HTTPException(status_code=404, detail="User profile not found") - - user = user_db_response.data - - # Get or build user preferences - prefs_response = supabase.table("personal_preferences").select("*").eq("user_id", user_id).execute() - - if prefs_response.data: - user_prefs = prefs_response.data[0] - else: - # Use query parameters as preferences if no stored preferences - user_prefs = {"target_city": city, "budget_min": budget_min, "budget_max": budget_max, "move_in_date": move_in_date} - - # Override with query params if provided - if budget_min is not None: - user_prefs["budget_min"] = budget_min - if budget_max is not None: - user_prefs["budget_max"] = budget_max - if move_in_date is not None: - user_prefs["move_in_date"] = move_in_date - - # Ensure city is set - if not user_prefs.get("target_city"): - user_prefs["target_city"] = city - - # Find compatible groups - compatible_groups = await find_compatible_groups(user_id=user_id, user_prefs=user_prefs, min_score=min_compatibility_score, limit=limit) - - # Format response - formatted_groups = [] - for group in compatible_groups: - # Get member details - members_response = supabase.table("group_members").select("*, users(id, full_name, company_name, school_name, verification_status)").eq("group_id", group["id"]).eq("status", "accepted").execute() - - members = [] - for member_data in members_response.data: - user_data = member_data.get("users", {}) if isinstance(member_data.get("users"), dict) else {} - members.append({"id": user_data.get("id"), "full_name": user_data.get("full_name"), "company_name": user_data.get("company_name"), "school_name": user_data.get("school_name"), "verification_status": user_data.get("verification_status"), "is_creator": member_data.get("is_creator", False)}) - - # Calculate open spots - current_count = group.get("current_member_count", len(members)) - target_size = group.get("target_group_size") - open_spots = (target_size - current_count) if target_size else None - - formatted_groups.append({"id": group["id"], "group_name": group["group_name"], "description": group.get("description"), "target_city": group["target_city"], "budget_per_person_min": group.get("budget_per_person_min"), "budget_per_person_max": group.get("budget_per_person_max"), "target_move_in_date": str(group["target_move_in_date"]) if group.get("target_move_in_date") else None, "target_group_size": target_size, "current_member_count": current_count, "open_spots": open_spots, "members": members, "compatibility": group["compatibility"], "created_at": str(group["created_at"]) if group.get("created_at") else None}) - - return {"status": "success", "count": len(formatted_groups), "groups": formatted_groups} - - -@router.get("/{group_id}/pending-requests", response_model=dict) -async def get_pending_requests(group_id: str, token: str = Depends(require_user_token)): - """Get pending join requests for a group (creator only). - - Returns list of users who requested to join with compatibility scores. - """ - from app.services.user_group_matching import calculate_user_group_compatibility - - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - - current_user_id = user_record.data[0]['id'] - - # Verify user is group creator - group_response = supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = _normalize_group_record_for_response(group_response.data) - - if group["creator_user_id"] != current_user_id: - raise HTTPException(status_code=403, detail="Only group creator can view pending requests") - - # Get pending members - pending_response = supabase.table("group_members").select("*, users(id, email, full_name, company_name, school_name, verification_status, profile_picture_url)").eq("group_id", group_id).eq("status", "pending").execute() - - # Calculate compatibility for each pending user - requests = [] - for member_data in pending_response.data: - user_data = member_data.get("users", {}) if isinstance(member_data.get("users"), dict) else {} - user_id = user_data.get("id") - - if not user_id: - continue - - # Get user preferences - prefs_response = supabase.table("personal_preferences").select("*").eq("user_id", user_id).execute() - - user_prefs = prefs_response.data[0] if prefs_response.data else {} - - # Calculate compatibility - compatibility = calculate_user_group_compatibility(user_data, user_prefs, group) - - requests.append({"user_id": user_id, "full_name": user_data.get("full_name"), "company_name": user_data.get("company_name"), "school_name": user_data.get("school_name"), "verification_status": user_data.get("verification_status"), "profile_picture_url": user_data.get("profile_picture_url"), "requested_at": str(member_data.get("joined_at")) if member_data.get("joined_at") else None, "user_preferences": {"budget_min": user_prefs.get("budget_min"), "budget_max": user_prefs.get("budget_max"), "target_city": user_prefs.get("target_city"), "move_in_date": str(user_prefs.get("move_in_date")) if user_prefs.get("move_in_date") else None, "lifestyle_preferences": user_prefs.get("lifestyle_preferences", {})}, "compatibility": compatibility}) - - # Sort by compatibility score (highest first) - requests.sort(key=lambda r: r["compatibility"]["score"], reverse=True) - - return {"status": "success", "count": len(requests), "requests": requests} - - - -@router.get("/{group_id}", response_model=dict) -async def get_group( - group_id: str, - token: str = Depends(require_user_token), - include_members: bool = Query(True, description="Include member details") -): - """ - Get a single roommate group by ID. - Optionally includes member details. - """ - supabase = get_admin_client() - - # Get group - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - - # Include members if requested - if include_members: - members_response = supabase.table('group_members')\ - .select('*, users(id, email, full_name)')\ - .eq('group_id', group_id)\ - .execute() - - # Format member data - members = [] - for member in members_response.data: - user_data = member.get('users', {}) if isinstance(member.get('users'), dict) else {} - members.append({ - 'id': member.get('id'), - 'group_id': member.get('group_id'), - 'user_id': member.get('user_id'), - 'is_creator': member.get('is_creator', False), - 'status': member.get('status', 'unknown'), - 'joined_at': member.get('joined_at'), - 'user_name': user_data.get('full_name') - }) - - group['members'] = members - group['member_count'] = len([m for m in members if m.get('status') == 'accepted']) - - return { - "status": "success", - "data": group - } - - -@router.post("", response_model=dict) -async def create_group( - group_data: RoommateGroupCreate, - token: str = Depends(require_user_token) -): - """ - Create a new roommate group. - Requires authentication. - Creates the group and adds the creator as the first member. - """ - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found. Please complete your profile first.") - - user_id = user_record.data[0]['id'] - - # Create group - group_dict = group_data.model_dump(exclude_none=True) - group_dict = _normalize_group_preference_payload(group_dict) - group_dict['creator_user_id'] = user_id - - try: - group_dict["target_city"] = validate_city_name(group_dict.get("target_city", "")) - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) - - if "preferred_neighborhoods" in group_dict: - try: - group_dict["preferred_neighborhoods"] = validate_neighborhoods( - group_dict.get("target_city", ""), - group_dict.get("preferred_neighborhoods"), - ) - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) - - # Convert non-JSON-serializable types - group_dict = _to_json_serializable_payload(group_dict) - - group_response = supabase.table('roommate_groups')\ - .insert(group_dict)\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=500, detail="Failed to create group") - - created_group = _normalize_group_record_for_response(group_response.data[0]) - group_id = created_group['id'] - - # Add creator as first member - member_data = { - 'group_id': group_id, - 'user_id': user_id, - 'is_creator': True, - 'status': 'accepted' - } - - supabase.table('group_members').insert(member_data).execute() - - matching_result = await _maybe_trigger_legacy_stable_matching( - target_city=created_group.get("target_city"), - reason="group_created", - ) - - return { - "status": "success", - "message": "Group created successfully", - "data": created_group, - "matching": matching_result - } - - -@router.put("/{group_id}", response_model=dict) -async def update_group( - group_id: str, - group_data: RoommateGroupUpdate, - token: str = Depends(require_user_token) -): - """ - Update a roommate group. - Requires authentication. - Only the creator can update the group. - """ - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - - user_id = user_record.data[0]['id'] - - # Check if user is creator (only creator can update) - member_response = supabase.table('group_members')\ - .select('is_creator')\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .eq('status', 'accepted')\ - .execute() - - if not member_response.data: - raise HTTPException(status_code=403, detail="You are not a member of this group") - - if not member_response.data[0].get('is_creator'): - raise HTTPException(status_code=403, detail="Only the group creator can edit the group") - - # Get the group - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = _normalize_group_record_for_response(group_response.data) - - # Update group - update_dict = group_data.model_dump(exclude_none=True) - update_dict = _normalize_group_preference_payload(update_dict) - - if not update_dict: - raise HTTPException(status_code=400, detail="No data provided for update") - - if "target_city" in update_dict: - try: - update_dict["target_city"] = validate_city_name(update_dict.get("target_city", "")) - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) - - if "preferred_neighborhoods" in update_dict: - effective_city = update_dict.get("target_city") or group.get("target_city") - try: - update_dict["preferred_neighborhoods"] = validate_neighborhoods( - effective_city or "", - update_dict.get("preferred_neighborhoods"), - ) - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) - - update_dict = _to_json_serializable_payload(update_dict) - - updated_response = supabase.table('roommate_groups')\ - .update(update_dict)\ - .eq('id', group_id)\ - .execute() - - # Legacy stable re-run (Phase 3B default OFF) - preference_fields = [ - 'budget_per_person_min', 'budget_per_person_max', - 'target_move_in_date', 'target_city' - ] - - matching_result = {'status': 'skipped', 'message': 'No preference changes detected'} - - if any(field in update_dict for field in preference_fields): - target_city = update_dict.get('target_city') or group.get('target_city') - matching_result = await _maybe_trigger_legacy_stable_matching( - target_city=target_city, - reason="group_updated", - ) - - return { - "status": "success", - "message": "Group updated successfully", - "data": _normalize_group_record_for_response(updated_response.data[0]) if updated_response.data else None, - "matching": matching_result - } - - -@router.delete("/{group_id}", response_model=dict) -async def delete_group( - group_id: str, - token: str = Depends(require_user_token) -): - """ - Delete a roommate group. - Requires authentication. - Only the creator can delete the group. - """ - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - - user_id = user_record.data[0]['id'] - - # Check if user is creator - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - - if group['creator_user_id'] != user_id: - raise HTTPException(status_code=403, detail="Only the group creator can delete this group") - - # Delete group members first (in case cascade doesn't work) - supabase.table('group_members')\ - .delete()\ - .eq('group_id', group_id)\ - .execute() - - # Delete group - supabase.table('roommate_groups')\ - .delete()\ - .eq('id', group_id)\ - .execute() - - return { - "status": "success", - "message": "Group deleted successfully" - } - - -# ============================================================================= -# Member Management Endpoints -# ============================================================================= - -@router.get("/{group_id}/members", response_model=dict) -async def get_group_members( - group_id: str, - token: str = Depends(require_user_token), - status_filter: Optional[str] = Query(None, description="Filter by member status") -): - """ - Get all members of a group. - Includes user details. - """ - supabase = get_admin_client() - - # Check if group exists - group_response = supabase.table('roommate_groups')\ - .select('id')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - # Get members - query = supabase.table('group_members')\ - .select('*, users(id, email, full_name, profile_picture_url)')\ - .eq('group_id', group_id) - - if status_filter: - query = query.eq('status', status_filter) - - members_response = query.execute() - - # Format member data - members = [] - for member in members_response.data: - user_data = member.get('users', {}) if isinstance(member.get('users'), dict) else {} - members.append({ - 'id': member.get('id'), - 'group_id': member.get('group_id'), - 'user_id': member.get('user_id'), - 'is_creator': member.get('is_creator', False), - 'status': member.get('status', 'unknown'), - 'joined_at': member.get('joined_at'), - 'user_name': user_data.get('full_name'), - 'user_picture': user_data.get('profile_picture_url') - }) - - return { - "status": "success", - "count": len(members), - "data": members - } - - -@router.post("/{group_id}/invite", response_model=dict) -async def invite_to_group( - group_id: str, - invite_data: GroupMemberInvite, - token: str = Depends(require_user_token) -): - """ - Invite a user to join the group by email. - Requires authentication. - Only group members can invite others. - """ - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Resolve auth UUID → app profile UUID - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - inviter_id = user_record.data[0]['id'] - - # Check if group exists and user is a member - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - - # Check if inviter is a member - is_creator = group['creator_user_id'] == inviter_id - - if not is_creator: - member_response = supabase.table('group_members')\ - .select('*')\ - .eq('group_id', group_id)\ - .eq('user_id', inviter_id)\ - .eq('status', 'accepted')\ - .execute() - - if not member_response.data: - raise HTTPException(status_code=403, detail="Only group members can invite others") - - # Find user by email - invited_user_response = supabase.table('users')\ - .select('id, email, full_name')\ - .eq('email', invite_data.user_email)\ - .execute() - - if not invited_user_response.data: - raise HTTPException(status_code=404, detail=f"User with email {invite_data.user_email} not found") - - invited_user_id = invited_user_response.data[0]['id'] - - # Check if user is already a member or has pending invite - existing_member = supabase.table('group_members')\ - .select('*')\ - .eq('group_id', group_id)\ - .eq('user_id', invited_user_id)\ - .execute() - - if existing_member.data: - status = existing_member.data[0]['status'] - if status == 'accepted': - raise HTTPException(status_code=400, detail="User is already a member of this group") - elif status == 'pending': - raise HTTPException(status_code=400, detail="User already has a pending invitation") - elif status == 'rejected': - # Update status back to pending - supabase.table('group_members')\ - .update({'status': 'pending'})\ - .eq('id', existing_member.data[0]['id'])\ - .execute() - - return { - "status": "success", - "message": "Invitation re-sent successfully", - "data": { - "group_id": group_id, - "invited_user_email": invite_data.user_email, - "status": "pending" - } - } - - # Create invitation - member_data = { - 'group_id': group_id, - 'user_id': invited_user_id, - 'is_creator': False, - 'status': 'pending' - } - - invite_response = supabase.table('group_members')\ - .insert(member_data)\ - .execute() - - # TODO: Send email notification to invited user - - return { - "status": "success", - "message": "Invitation sent successfully", - "data": { - "group_id": group_id, - "invited_user_email": invite_data.user_email, - "invited_user_name": invited_user_response.data[0].get('full_name'), - "status": "pending" - } - } - - -@router.post("/{group_id}/request-join", response_model=dict) -async def request_join_group( - group_id: str, - token: str = Depends(require_user_token) -): - """ - Request to join a group (self-invite). - Creates a pending invitation for the current user. - Requires authentication. - """ - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found. Please complete your profile first.") - - user_id = user_record.data[0]['id'] - - # Check if user is already an accepted member of ANY group (including solo groups) - # Users must leave their current group (even solo) before joining another - existing_group_membership = supabase.table('group_members')\ - .select('group_id, roommate_groups(group_name, is_solo)')\ - .eq('user_id', user_id)\ - .eq('status', 'accepted')\ - .execute() - - if existing_group_membership.data and len(existing_group_membership.data) > 0: - existing_group = existing_group_membership.data[0] - group_info = existing_group.get('roommate_groups', {}) - is_solo = group_info.get('is_solo', False) - group_name = group_info.get('group_name', 'a group') - - if is_solo: - raise HTTPException( - status_code=400, - detail="You are currently in a solo group. Please leave your solo group first before requesting to join another group." - ) - else: - raise HTTPException( - status_code=400, - detail=f"You are already a member of '{group_name}'. You can only be in one group at a time. Please leave your current group first." - ) - - # Check if group exists - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - - # Check if user already has a pending request for THIS group - existing_member = supabase.table('group_members')\ - .select('*')\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .execute() - - if existing_member.data: - status = existing_member.data[0]['status'] - if status == 'accepted': - raise HTTPException(status_code=400, detail="You are already a member of this group") - elif status == 'pending': - raise HTTPException(status_code=400, detail="You already have a pending request to join") - elif status == 'rejected': - # Update status back to pending - supabase.table('group_members')\ - .update({'status': 'pending'})\ - .eq('id', existing_member.data[0]['id'])\ - .execute() - - return { - "status": "success", - "message": "Join request sent successfully", - "data": { - "group_id": group_id, - "group_name": group['group_name'], - "status": "pending" - } - } - - # Check if group is full - current_members = supabase.table('group_members')\ - .select('user_id')\ - .eq('group_id', group_id)\ - .eq('status', 'accepted')\ - .execute() - - target_size = group.get('target_group_size') - if target_size is not None and len(current_members.data) >= target_size: - raise HTTPException(status_code=400, detail="Group is already full") - - # Create join request (pending invitation) - member_data = { - 'group_id': group_id, - 'user_id': user_id, - 'is_creator': False, - 'status': 'pending' - } - - supabase.table('group_members')\ - .insert(member_data)\ - .execute() - - return { - "status": "success", - "message": "Join request sent successfully. You can accept it from the Invitations page.", - "data": { - "group_id": group_id, - "group_name": group['group_name'], - "status": "pending" - } - } - - -@router.post("/{group_id}/join", response_model=dict) -async def join_group( - group_id: str, - token: str = Depends(require_user_token) -): - """ - Accept invitation and join a group. - Requires authentication. - User must have a pending invitation. - """ - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - user_id = auth_user.id - - # Check if group exists - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - - # Check if user has pending invitation - member_response = supabase.table('group_members')\ - .select('*')\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .execute() - - if not member_response.data: - raise HTTPException(status_code=400, detail="You don't have an invitation to this group") - - member = member_response.data[0] - - if member['status'] == 'accepted': - raise HTTPException(status_code=400, detail="You are already a member of this group") - - if member['status'] == 'rejected': - raise HTTPException(status_code=400, detail="You previously rejected this invitation") - - # Check if group is full - target_size = group.get('target_group_size') - if target_size is not None: - current_members = supabase.table('group_members')\ - .select('user_id')\ - .eq('group_id', group_id)\ - .eq('status', 'accepted')\ - .execute() - - if len(current_members.data) >= target_size: - raise HTTPException(status_code=400, detail="Group is already full") - - # Accept invitation - supabase.table('group_members')\ - .update({'status': 'accepted'})\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .execute() - - # 🔥 AGGREGATE MEMBER PREFERENCES into group preferences - aggregation_result = {'status': 'skipped'} - try: - aggregation_result = _aggregate_and_persist_group_preferences(group_id) - except Exception as e: - aggregation_result = {'status': 'error', 'message': str(e)} - - matching_result = await _maybe_trigger_legacy_stable_matching( - target_city=group.get("target_city"), - reason="group_joined", - ) - - # Get updated group info - updated_group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - updated_group = updated_group_response.data if updated_group_response.data else group - - return { - "status": "success", - "message": "Successfully joined the group", - "data": { - "group_id": group_id, - "group_name": group['group_name'], - "current_member_count": updated_group.get('current_member_count', 1) - }, - "preference_aggregation": aggregation_result, - "matching": matching_result - } - - - -@router.post("/{group_id}/reject", response_model=dict) -async def reject_invitation( - group_id: str, - token: str = Depends(require_user_token) -): - """ - Reject a group invitation. - Requires authentication. - """ - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - - user_id = user_record.data[0]['id'] - - # Check if user has pending invitation - member_response = supabase.table('group_members')\ - .select('*')\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .eq('status', 'pending')\ - .execute() - - if not member_response.data: - raise HTTPException(status_code=400, detail="No pending invitation found") - - # Reject invitation - supabase.table('group_members')\ - .update({'status': 'rejected'})\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .execute() - - return { - "status": "success", - "message": "Invitation rejected" - } - - -@router.post("/{group_id}/accept-request/{user_id}", response_model=dict) -async def accept_join_request( - group_id: str, - user_id: str, - token: str = Depends(require_user_token) -): - """ - Accept a user's request to join the group (creator only). - - This allows group creators to approve solo users who want to join their group. - """ - supabase = get_admin_client() - - # Get current user (group creator) - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - - current_user_id = user_record.data[0]['id'] - - # Check if group exists and current user is creator - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - - if group['creator_user_id'] != current_user_id: - raise HTTPException(status_code=403, detail="Only the group creator can accept join requests") - - # Check if target user has a pending request - member_response = supabase.table('group_members')\ - .select('*')\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .eq('status', 'pending')\ - .execute() - - if not member_response.data: - raise HTTPException(status_code=400, detail="No pending join request found for this user") - - # Check if group is full - target_size = group.get('target_group_size') - if target_size is not None: - current_members = supabase.table('group_members')\ - .select('user_id')\ - .eq('group_id', group_id)\ - .eq('status', 'accepted')\ - .execute() - - if len(current_members.data) >= target_size: - raise HTTPException(status_code=400, detail="Group is already full") - - # Accept the request - supabase.table('group_members')\ - .update({'status': 'accepted'})\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .execute() - - # Cancel all OTHER pending requests for this user (they can only be in one group) - supabase.table('group_members')\ - .delete()\ - .eq('user_id', user_id)\ - .eq('status', 'pending')\ - .neq('group_id', group_id)\ - .execute() - - # Get user info for response - user_info_response = supabase.table('users')\ - .select('email, full_name')\ - .eq('id', user_id)\ - .single()\ - .execute() - - user_info = user_info_response.data if user_info_response.data else {} - - matching_result = await _maybe_trigger_legacy_stable_matching( - target_city=group.get("target_city"), - reason="join_request_accepted", - ) - - return { - "status": "success", - "message": f"Join request accepted. {user_info.get('full_name', 'User')} is now a member.", - "data": { - "group_id": group_id, - "user_id": user_id, - "user_name": user_info.get('full_name'), - "status": "accepted" - }, - "matching": matching_result - } - - -@router.post("/{group_id}/reject-request/{user_id}", response_model=dict) -async def reject_join_request( - group_id: str, - user_id: str, - token: str = Depends(require_user_token) -): - """ - Reject a user's request to join the group (creator only). - - This allows group creators to decline solo users who want to join their group. - """ - supabase = get_admin_client() - - # Get current user (group creator) - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - - current_user_id = user_record.data[0]['id'] - - # Check if group exists and current user is creator - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - - if group['creator_user_id'] != current_user_id: - raise HTTPException(status_code=403, detail="Only the group creator can reject join requests") - - # Check if target user has a pending request - member_response = supabase.table('group_members')\ - .select('*')\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .eq('status', 'pending')\ - .execute() - - if not member_response.data: - raise HTTPException(status_code=400, detail="No pending join request found for this user") - - # Get user info for response - user_info_response = supabase.table('users')\ - .select('email, full_name')\ - .eq('id', user_id)\ - .single()\ - .execute() - - user_info = user_info_response.data if user_info_response.data else {} - - # Reject the request (set status to rejected) - supabase.table('group_members')\ - .update({'status': 'rejected'})\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .execute() - - return { - "status": "success", - "message": f"Join request from {user_info.get('full_name', 'user')} has been rejected.", - "data": { - "group_id": group_id, - "user_id": user_id, - "user_name": user_info.get('full_name'), - "status": "rejected" - } - } - - -@router.delete("/{group_id}/leave", response_model=dict) -async def leave_group( - group_id: str, - token: str = Depends(require_user_token) -): - """ - Leave a group. - Requires authentication. - If creator leaves: - - If other members exist, ownership transfers to another member - - If no other members, the group is deleted - """ - import random - - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - - user_id = user_record.data[0]['id'] - - # Check if group exists and get group info - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - - # Check if user is member - member_response = supabase.table('group_members')\ - .select('*')\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .execute() - - if not member_response.data: - raise HTTPException(status_code=400, detail="You are not a member of this group") - - member = member_response.data[0] - - # Handle creator leaving - if member['is_creator']: - # Get all other accepted members - other_members_response = supabase.table('group_members')\ - .select('user_id')\ - .eq('group_id', group_id)\ - .eq('status', 'accepted')\ - .neq('user_id', user_id)\ - .execute() - - other_members = other_members_response.data or [] - - if len(other_members) == 0: - # Creator is the only member - delete the group - # First delete all group_members entries (including pending requests) - supabase.table('group_members')\ - .delete()\ - .eq('group_id', group_id)\ - .execute() - - # Then delete the group itself - supabase.table('roommate_groups')\ - .delete()\ - .eq('id', group_id)\ - .execute() - - return { - "status": "success", - "message": "Successfully left the group. The group was deleted as you were the only member.", - "data": { - "group_id": group_id, - "group_name": group['group_name'], - "group_deleted": True - } - } - else: - # Transfer ownership to a random remaining member - new_creator = random.choice(other_members) - new_creator_id = new_creator['user_id'] - - # Update the new creator's is_creator flag - supabase.table('group_members')\ - .update({'is_creator': True})\ - .eq('group_id', group_id)\ - .eq('user_id', new_creator_id)\ - .execute() - - # Update group's creator_user_id - supabase.table('roommate_groups')\ - .update({'creator_user_id': new_creator_id})\ - .eq('id', group_id)\ - .execute() - - # Remove the old creator from the group - supabase.table('group_members')\ - .delete()\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .execute() - - # Get new creator's name for response - new_creator_user = supabase.table('users')\ - .select('full_name, email')\ - .eq('id', new_creator_id)\ - .single()\ - .execute() - - new_creator_name = new_creator_user.data.get('full_name') or new_creator_user.data.get('email') if new_creator_user.data else 'another member' - - return { - "status": "success", - "message": f"Successfully left the group. Ownership transferred to {new_creator_name}.", - "data": { - "group_id": group_id, - "group_name": group['group_name'], - "ownership_transferred": True, - "new_creator_id": new_creator_id - } - } - - # Regular member leaving - just remove them - supabase.table('group_members')\ - .delete()\ - .eq('group_id', group_id)\ - .eq('user_id', user_id)\ - .execute() - - # 🔄 AGGREGATE PREFERENCES after member leaves - aggregation_result = {'status': 'skipped'} - try: - aggregation_result = _aggregate_and_persist_group_preferences(group_id) - except Exception as e: - aggregation_result = {'status': 'error', 'message': str(e)} - - matching_result = await _maybe_trigger_legacy_stable_matching( - target_city=group.get("target_city"), - reason="member_left", - ) - - return { - "status": "success", - "message": "Successfully left the group", - "data": { - "group_id": group_id, - "group_name": group['group_name'] - }, - "aggregation": aggregation_result, - "matching": matching_result - } - - -@router.delete("/{group_id}/members/{member_user_id}", response_model=dict) -async def remove_member( - group_id: str, - member_user_id: str, - token: str = Depends(require_user_token) -): - """ - Remove a member from the group. - Requires authentication. - Only the creator can remove members. - """ - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the current user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - - current_user_id = user_record.data[0]['id'] - - # Check if current user is creator - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - - if group['creator_user_id'] != current_user_id: - raise HTTPException(status_code=403, detail="Only the group creator can remove members") - - # Check if target user is member - member_response = supabase.table('group_members')\ - .select('*')\ - .eq('group_id', group_id)\ - .eq('user_id', member_user_id)\ - .execute() - - if not member_response.data: - raise HTTPException(status_code=400, detail="User is not a member of this group") - - member = member_response.data[0] - - if member['is_creator']: - raise HTTPException(status_code=400, detail="Cannot remove the group creator") - - # Remove member using composite key - supabase.table('group_members')\ - .delete()\ - .eq('group_id', group_id)\ - .eq('user_id', member_user_id)\ - .execute() - - # 🔥 RE-AGGREGATE MEMBER PREFERENCES (now with one less member) - aggregation_result = {'status': 'skipped'} - try: - aggregation_result = _aggregate_and_persist_group_preferences(group_id) - except Exception as e: - aggregation_result = {'status': 'error', 'message': str(e)} - - matching_result = await _maybe_trigger_legacy_stable_matching( - target_city=group.get("target_city"), - reason="member_removed", - ) - - return { - "status": "success", - "message": "Member removed successfully", - "aggregation": aggregation_result, - "matching": matching_result - } - - -# ============================================================================= -# Legacy stable-matching retirement helpers -# ============================================================================= - -async def _maybe_trigger_legacy_stable_matching( - target_city: Optional[str], - reason: str, -) -> Dict[str, Any]: - """ - Stable matching has been retired. Preserve response shape for legacy callers. - """ - return { - "status": "retired", - "city": target_city, - "message": f"Legacy stable matching has been removed (reason={reason}).", - } - - -def _safe_float(value: Any, default: float = 0.0) -> float: - try: - if value is None: - return default - return float(value) - except (TypeError, ValueError): - return default - - -def _safe_int(value: Any, default: int = 0) -> int: - try: - if value is None: - return default - return int(float(value)) - except (TypeError, ValueError): - return default - - -def _resolve_current_user_id(token: str) -> str: - """Resolve authenticated user to internal users.id UUID.""" - supabase = get_admin_client() - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - user_record = supabase.table("users").select("id").eq("auth_id", auth_user_id).limit(1).execute() - if user_record.data: - return user_record.data[0]["id"] - - fallback_record = supabase.table("users").select("id").eq("id", auth_user_id).limit(1).execute() - if fallback_record.data: - return fallback_record.data[0]["id"] - - raise HTTPException(status_code=404, detail="User profile not found") - - -def _require_group_membership(group_id: str, user_id: str) -> None: - """Require accepted group membership for protected group ranking surfaces.""" - supabase = get_admin_client() - membership = ( - supabase.table("group_members") - .select("group_id") - .eq("group_id", group_id) - .eq("user_id", user_id) - .eq("status", "accepted") - .limit(1) - .execute() - ) - if not membership.data: - raise HTTPException(status_code=403, detail="You are not a member of this group") - - -def _build_user_payload_from_group( - group: Dict[str, Any], - behavior: Optional[Dict[str, Any]], -) -> Dict[str, Any]: - """Map group constraints + behavior context into recommender user payload.""" - group_size = _safe_int( - group.get("target_group_size") or group.get("current_member_count") or 2, - default=2, - ) - group_size = max(1, group_size) - required_beds = _safe_int(group.get("required_bedrooms"), default=0) - desired_beds = max(group_size, required_beds) if required_beds else float(group_size) - - furnished_preference = str(group.get("furnished_preference") or "").strip().lower() - wants_furnished = None - if furnished_preference in {"required", "preferred"}: - wants_furnished = 1 - elif group.get("target_furnished") is True: - wants_furnished = 1 - elif group.get("target_furnished") is False: - wants_furnished = 0 - - vector = (behavior or {}).get("vector") or {} - return { - "budget_min": _safe_float(group.get("budget_per_person_min"), default=0.0) or None, - "budget_max": _safe_float(group.get("budget_per_person_max"), default=0.0) or None, - "desired_beds": desired_beds, - "desired_baths": _safe_float(group.get("target_bathrooms"), default=0.0) or None, - "wants_furnished": wants_furnished, - "liked_mean_price": vector.get("liked_mean_price"), - "liked_mean_beds": vector.get("liked_mean_beds"), - "liked_mean_sqfeet": vector.get("liked_mean_sqfeet"), - "behavior_sample_size": _safe_int((behavior or {}).get("sample_size"), default=0), - } - - -def _build_legacy_rule_rankings( - group: Dict[str, Any], - eligible_listings: List[Dict[str, Any]], - limit: int, -) -> List[Dict[str, Any]]: - """Phase 1 legacy ranking: soft rule score over hard-filtered candidates.""" - from app.services.stable_matching.scoring import calculate_group_score - - group_size = _safe_int(group.get("target_group_size"), default=2) - group_size = max(1, group_size) - - ranked = [] - for listing in eligible_listings: - score = float(calculate_group_score(group, listing)) - listing_copy = dict(listing) - listing_copy["match_score"] = round(score, 2) - listing_copy["match_percent"] = f"{round(score)}%" - listing_price = _safe_float(listing.get("price_per_month"), default=0.0) - listing_copy["price_per_person"] = round(listing_price / group_size, 2) - ranked.append(listing_copy) - - ranked.sort(key=lambda x: (-x.get("match_score", 0.0), x.get("price_per_month", float("inf")))) - return ranked[:limit] - -@router.get("/{group_id}/matches", response_model=dict) -async def get_group_matches( - group_id: str, - token: Optional[str] = Depends(get_user_token) -): - """ - Phase 3B compatibility endpoint. - - Primary source: neural-ranked listings (hard-filtered). - Fallback: deterministic rule-ranked listings. - - Returns legacy `data` shape so older clients keep working. - """ - def to_legacy_data(ranked_listings: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - out: List[Dict[str, Any]] = [] - for idx, listing in enumerate(ranked_listings, start=1): - listing_id = listing.get("id") or listing.get("listing_id") - normalized_listing = dict(listing) - normalized_listing["id"] = listing_id - out.append( - { - "id": listing_id, - "listing_id": listing_id, - "group_rank": idx, - "listing_rank": None, - "group_score": listing.get("match_score"), - "listing_score": None, - "is_stable": False, - "algorithm_version": listing.get("algorithm_version"), - "score_breakdown": listing.get("score_breakdown"), - "explainability": listing.get("explainability"), - "listing": normalized_listing, - } - ) - return out - - if token: - try: - response = await get_neural_ranked_listings_for_group( - group_id=group_id, - limit=50, - shadow_compare=False, - force_enable=True, - token=token, - ) - data = to_legacy_data(response.get("ranked_listings", [])) - return { - "status": "success", - "mode": "neural_cutover", - "group_id": group_id, - "count": len(data), - "data": data, - "fallback_used": False, - } - except HTTPException as exc: - # Keep serving path alive with deterministic ranking fallback. - if exc.status_code not in {403, 503, 500}: - raise - - fallback = await get_ranked_listings_for_group(group_id=group_id, limit=50, token=token) - data = to_legacy_data(fallback.get("ranked_listings", [])) - return { - "status": "success", - "mode": "legacy_rule_fallback", - "group_id": group_id, - "count": len(data), - "data": data, - "fallback_used": True, - } - - -@router.get("/{group_id}/eligible-listings", response_model=dict) -async def get_eligible_listings_for_group( - group_id: str, - limit: int = Query(50, ge=1, le=200, description="Max listings to return"), - token: Optional[str] = Depends(get_user_token) -): - """ - Get listings that match the group's HARD CONSTRAINTS only. - - This endpoint returns all listings that are feasible for the group based on: - - Location match (same city) - - Budget compatibility (per-person price within group's budget range) - - Move-in date compatibility (within ±30 days of target date) - - Required attributes (furnished, utilities, pets, parking, etc.) - - Unlike /matches (which now serves ranked recommendations), this returns - ALL listings that pass hard constraints - useful for browsing/exploration. - - Returns listings sorted by price (lowest first). - """ - from app.services.stable_matching import ( - build_feasible_pairs, - get_feasibility_statistics - ) - - supabase = get_admin_client() - - # Get group details - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - target_city = group.get('target_city') - - if not target_city: - raise HTTPException( - status_code=400, - detail="Group must have a target city to find eligible listings" - ) - - all_listings = _fetch_active_listings_for_group_location(supabase, group) - - if not all_listings: - return { - "status": "success", - "group_id": group_id, - "group_constraints": { - "target_city": target_city, - "budget_min": group.get('budget_per_person_min'), - "budget_max": group.get('budget_per_person_max'), - "target_move_in_date": str(group.get('target_move_in_date')) if group.get('target_move_in_date') else None - }, - "count": 0, - "listings": [], - "message": f"No active listings found in {target_city}" - } - - # Build feasible pairs (hard constraint filtering) - feasible_pairs, rejection_reasons = build_feasible_pairs( - groups=[group], - listings=all_listings, - date_delta_days=30, - include_rejection_reasons=True - ) - - # Extract listing IDs that passed hard constraints - eligible_listing_ids = set(listing_id for _, listing_id in feasible_pairs) - - # Filter and enrich listings - eligible_listings = [] - for listing in all_listings: - if listing['id'] in eligible_listing_ids: - # Calculate per-person price - price = float(listing.get('price_per_month', 0)) - listing['price_per_person'] = round(price / 2, 2) # 2-person groups - eligible_listings.append(listing) - - # Sort by price (lowest first) - eligible_listings.sort(key=lambda x: x.get('price_per_month', float('inf'))) - - # Apply limit - eligible_listings = eligible_listings[:limit] - - # Get statistics - stats = get_feasibility_statistics([group], all_listings, feasible_pairs) - - return { - "status": "success", - "group_id": group_id, - "group_constraints": { - "target_city": target_city, - "budget_min": group.get('budget_per_person_min'), - "budget_max": group.get('budget_per_person_max'), - "target_move_in_date": str(group.get('target_move_in_date')) if group.get('target_move_in_date') else None, - "target_furnished": group.get('target_furnished'), - "target_utilities_included": group.get('target_utilities_included') - }, - "stats": { - "total_listings_in_city": stats['total_listings'], - "eligible_count": stats['total_feasible_pairs'], - "rejected_count": stats['listings_with_no_options'] - }, - "count": len(eligible_listings), - "listings": eligible_listings - } - - -@router.get("/{group_id}/ranked-listings", response_model=dict) -async def get_ranked_listings_for_group( - group_id: str, - limit: int = Query(50, ge=1, le=200, description="Max ranked listings to return"), - token: Optional[str] = Depends(get_user_token) -): - """ - Return listings for a group using: - 1) Hard-constraint filtering (feasible pairs) - 2) Soft rule-based scoring (0-100) for ranking - - This endpoint is the deterministic fallback (Phase 1 hard + soft rules surface). - """ - from app.services.stable_matching import ( - build_feasible_pairs, - get_feasibility_statistics - ) - from app.services.stable_matching.scoring import calculate_group_score - - supabase = get_admin_client() - - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .limit(1)\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data[0] - target_city = group.get('target_city') - if not target_city: - raise HTTPException(status_code=400, detail="Group must have a target city") - - all_listings = _fetch_active_listings_for_group_location(supabase, group) - if not all_listings: - return { - "status": "success", - "group_id": group_id, - "count": 0, - "ranked_listings": [], - "message": f"No active listings found in {target_city}" - } - - feasible_pairs, _ = build_feasible_pairs( - groups=[group], - listings=all_listings, - date_delta_days=30, - include_rejection_reasons=True - ) - eligible_listing_ids = set(listing_id for _, listing_id in feasible_pairs) - - group_size = int(group.get("target_group_size") or group.get("current_member_count") or 2) - group_size = max(group_size, 1) - - ranked = [] - for listing in all_listings: - if listing['id'] not in eligible_listing_ids: - continue - - score = float(calculate_group_score(group, listing)) - listing_copy = dict(listing) - listing_copy["match_score"] = round(score, 2) - listing_copy["match_percent"] = f"{round(score)}%" - - listing_price = float(listing.get("price_per_month") or 0.0) - listing_copy["price_per_person"] = round(listing_price / group_size, 2) - ranked.append(listing_copy) - - ranked.sort(key=lambda x: (-x.get("match_score", 0.0), x.get("price_per_month", float('inf')))) - ranked = ranked[:limit] - - stats = get_feasibility_statistics([group], all_listings, feasible_pairs) - - return { - "status": "success", - "group_id": group_id, - "group_constraints": { - "target_city": target_city, - "budget_min": group.get('budget_per_person_min'), - "budget_max": group.get('budget_per_person_max'), - "target_move_in_date": str(group.get('target_move_in_date')) if group.get('target_move_in_date') else None, - "target_lease_type": group.get('target_lease_type'), - "target_lease_duration_months": group.get('target_lease_duration_months'), - "target_bathrooms": group.get('target_bathrooms'), - "target_furnished": group.get('target_furnished'), - "target_utilities_included": group.get('target_utilities_included'), - "target_deposit_amount": group.get('target_deposit_amount'), - "target_house_rules": group.get('target_house_rules'), - }, - "stats": { - "total_listings_in_city": stats['total_listings'], - "eligible_count": stats['total_feasible_pairs'], - "returned_count": len(ranked), - }, - "count": len(ranked), - "ranked_listings": ranked - } - - -@router.get("/{group_id}/neural-ranked-listings", response_model=dict) -async def get_neural_ranked_listings_for_group( - group_id: str, - limit: int = Query(50, ge=1, le=200, description="Max ranked listings to return"), - shadow_compare: bool = Query( - False, - description="When true, include side-by-side comparison with legacy rule rankings.", - ), - force_enable: bool = Query( - False, - description="Bypass feature flag check for testing while in Phase 3A.", - ), - token: str = Depends(require_user_token), -): - """ - Phase 3A Group -> Listing neural-ranked feed. - - Foundation endpoint capabilities: - - hard-filter guardrail before any scoring - - neural/rule/behavior blend ranking (with ML fallback) - - optional shadow comparison against legacy rule ranking - - feature-flag + kill-switch controls (both default OFF) - """ - from app.services.stable_matching import build_feasible_pairs, get_feasibility_statistics - - # Phase 3A controls: default OFF until explicit enable. - ranking_enabled = _env_bool("PADLY_GROUP_NEURAL_RANKING_ENABLED", default=False) - kill_switch = _env_bool("PADLY_GROUP_NEURAL_KILL_SWITCH", default=False) - - if kill_switch: - raise HTTPException( - status_code=503, - detail="Group neural ranking is temporarily disabled by kill switch.", - ) - if not ranking_enabled and not force_enable: - raise HTTPException( - status_code=503, - detail=( - "Group neural ranking is not enabled yet. " - "Set PADLY_GROUP_NEURAL_RANKING_ENABLED=true or use force_enable for testing." - ), - ) - - supabase = get_admin_client() - current_user_id = _resolve_current_user_id(token) - _require_group_membership(group_id=group_id, user_id=current_user_id) - - group_response = ( - supabase.table("roommate_groups") - .select("*") - .eq("id", group_id) - .limit(1) - .execute() - ) - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - group = group_response.data[0] - - target_city = group.get("target_city") - if not target_city: - raise HTTPException(status_code=400, detail="Group must have a target city") - - all_listings = _fetch_active_listings_for_group_location(supabase, group) - if not all_listings: - return { - "status": "success", - "mode": "group_neural_ranked", - "group_id": group_id, - "count": 0, - "ranked_listings": [], - "message": f"No active listings found in {target_city}", - } - - # Hard-constraint guardrail: score ONLY listings that pass feasibility checks. - feasible_pairs, _ = build_feasible_pairs( - groups=[group], - listings=all_listings, - date_delta_days=30, - include_rejection_reasons=True, - ) - eligible_listing_ids = {listing_id for _, listing_id in feasible_pairs} - eligible_listings = [l for l in all_listings if l.get("id") in eligible_listing_ids] - feasibility_stats = get_feasibility_statistics([group], all_listings, feasible_pairs) - - if not eligible_listings: - return { - "status": "success", - "mode": "group_neural_ranked", - "group_id": group_id, - "stats": { - "total_listings_in_city": feasibility_stats["total_listings"], - "eligible_count": feasibility_stats["total_feasible_pairs"], - "returned_count": 0, - }, - "count": 0, - "ranked_listings": [], - "message": "No hard-eligible listings found for this group.", - } - - warnings: List[str] = [] - behavior = None - try: - behavior = build_group_behavior_vector(group_id=group_id, days=180, max_events_per_user=2000) - except Exception as e: - warnings.append(f"Behavior vector unavailable, using neutral behavior prior: {e}") - - user_payload = _build_user_payload_from_group(group=group, behavior=behavior) - scored = score_listings(user_payload, eligible_listings, top_n=limit) - - # Guardrail invariant: ranked output must be subset of hard-eligible listing IDs. - invalid_ids = [str(item.get("id")) for item in scored if item.get("id") not in eligible_listing_ids] - if invalid_ids: - raise HTTPException( - status_code=500, - detail=f"Hard-filter guardrail violation. Ranked non-eligible listings: {invalid_ids[:5]}", - ) - - group_size = _safe_int( - group.get("target_group_size") or group.get("current_member_count") or 2, - default=2, - ) - group_size = max(1, group_size) - - ranked_listings = [] - for item in scored: - listing = dict(item) - listing["listing_id"] = str(item.get("id") or "") - listing_price = _safe_float(item.get("price_per_month"), default=0.0) - listing["price_per_person"] = round(listing_price / group_size, 2) - ranked_listings.append(listing) - - shadow_comparison = None - if shadow_compare: - legacy_ranked = _build_legacy_rule_rankings(group=group, eligible_listings=eligible_listings, limit=limit) - neural_ids = [str(x.get("id")) for x in ranked_listings] - legacy_ids = [str(x.get("id")) for x in legacy_ranked] - neural_top = neural_ids[:limit] - legacy_top = legacy_ids[:limit] - overlap = len(set(neural_top).intersection(set(legacy_top))) - denom = len(set(neural_top).union(set(legacy_top))) or 1 - shadow_comparison = { - "enabled": True, - "top_k": limit, - "overlap_count": overlap, - "overlap_rate": round(overlap / max(1, min(len(neural_top), len(legacy_top))), 4), - "jaccard_top_k": round(overlap / denom, 4), - "legacy_only_top_ids": [x for x in legacy_top if x not in set(neural_top)][:10], - "neural_only_top_ids": [x for x in neural_top if x not in set(legacy_top)][:10], - } - - return { - "status": "success", - "mode": "group_neural_ranked", - "group_id": group_id, - "feature_flags": { - "group_neural_ranking_enabled": ranking_enabled, - "group_neural_kill_switch": kill_switch, - "force_enable": force_enable, - }, - "group_constraints": { - "target_city": target_city, - "budget_min": group.get("budget_per_person_min"), - "budget_max": group.get("budget_per_person_max"), - "target_move_in_date": str(group.get("target_move_in_date")) if group.get("target_move_in_date") else None, - }, - "stats": { - "total_listings_in_city": feasibility_stats["total_listings"], - "eligible_count": feasibility_stats["total_feasible_pairs"], - "returned_count": len(ranked_listings), - }, - "behavior_context": { - "sample_size": user_payload.get("behavior_sample_size"), - "has_behavior_signal": any( - user_payload.get(key) is not None - for key in ("liked_mean_price", "liked_mean_beds", "liked_mean_sqfeet") - ), - }, - "warnings": warnings, - "count": len(ranked_listings), - "ranked_listings": ranked_listings, - "shadow_comparison": shadow_comparison, - } - - -@router.post("/{group_id}/confirm-match", response_model=dict) -async def confirm_match_as_group( - group_id: str, - token: str = Depends(require_user_token) -): - """ - Confirm the current stable match as the group. - - This sets group_confirmed_at timestamp on the match. - A match is fully confirmed when BOTH group and listing owner confirm. - - Confirmed matches are preserved during re-matching - only unconfirmed - matches are recalculated when new groups join or preferences change. - """ - raise HTTPException( - status_code=410, - detail="Stable match confirmations have been retired.", - ) - - -@router.delete("/{group_id}/reject-match", response_model=dict) -async def reject_match_as_group( - group_id: str, - token: str = Depends(require_user_token) -): - """ - Reject the current stable match as the group. - - This removes the match and optionally triggers re-matching - to find a new match for the group. - """ - raise HTTPException( - status_code=410, - detail="Stable match rejections have been retired.", - ) - - -@router.get("/{group_id}/compatible-users", response_model=dict) -async def get_compatible_users( - group_id: str, - token: str = Depends(require_user_token) -): - """ - Get users who are compatible with the group's hard constraints. - - Filters users based on: - - Target city match - - Budget overlap - - Move-in date compatibility (within 30 days) - - Not already a member or has pending invitation - - Returns user profiles with their preferences for invitation. - """ - supabase = get_admin_client() - - # Get current user - auth_user = resolve_auth_user(supabase, token) - - auth_user_id = auth_user.id - - # Look up the user in the users table by auth_id - user_record = supabase.table('users').select('id').eq('auth_id', auth_user_id).execute() - if not user_record.data: - raise HTTPException(status_code=404, detail="User profile not found") - - current_user_id = user_record.data[0]['id'] - - # Get the group details - group_response = supabase.table('roommate_groups')\ - .select('*')\ - .eq('id', group_id)\ - .single()\ - .execute() - - if not group_response.data: - raise HTTPException(status_code=404, detail="Group not found") - - group = group_response.data - - # Check if current user is a member of this group - member_check = supabase.table('group_members')\ - .select('user_id')\ - .eq('group_id', group_id)\ - .eq('user_id', current_user_id)\ - .eq('status', 'accepted')\ - .execute() - - if not member_check.data: - raise HTTPException(status_code=403, detail="Only group members can view compatible users") - - # Get existing members and pending invitations to exclude them - existing_members = supabase.table('group_members')\ - .select('user_id')\ - .eq('group_id', group_id)\ - .execute() - - excluded_user_ids = [m['user_id'] for m in existing_members.data] - - # Get all users with their preferences - users_response = supabase.table('users')\ - .select('id, email, full_name, profile_picture_url, company_name, school_name, verification_status, bio')\ - .execute() - - # Get all personal preferences - prefs_response = supabase.table('personal_preferences')\ - .select('*')\ - .execute() - - # Create a map of user_id -> preferences - prefs_map = {p['user_id']: p for p in prefs_response.data} - - # Filter users based on hard constraints - compatible_users = [] - - group_city = group.get('target_city', '').strip() - group_budget_min = group.get('budget_per_person_min') or 0 - group_budget_max = group.get('budget_per_person_max') or float('inf') - group_move_in = group.get('target_move_in_date') - - for user_data in users_response.data: - user_id = user_data['id'] - - # Skip excluded users (already members or self) - if user_id in excluded_user_ids: - continue - - prefs = prefs_map.get(user_id, {}) - - # Hard constraint 1: City match - user_city = (prefs.get('target_city') or '').strip() - if group_city and user_city and not cities_match(group_city, user_city): - continue - - # Hard constraint 2: Budget overlap - user_budget_min = prefs.get('budget_min') or 0 - user_budget_max = prefs.get('budget_max') or float('inf') - - # Check if ranges overlap - if user_budget_max < group_budget_min or user_budget_min > group_budget_max: - continue - - # Hard constraint 3: Move-in date compatibility (within 30 days) - if group_move_in and prefs.get('move_in_date'): - from datetime import datetime, timedelta - try: - group_date = datetime.fromisoformat(str(group_move_in).replace('Z', '+00:00')).date() if isinstance(group_move_in, str) else group_move_in - user_date = datetime.fromisoformat(str(prefs['move_in_date']).replace('Z', '+00:00')).date() if isinstance(prefs['move_in_date'], str) else prefs['move_in_date'] - - date_diff = abs((group_date - user_date).days) - if date_diff > 30: - continue - except (ValueError, TypeError): - pass # If dates can't be parsed, don't filter on this constraint - - # Calculate compatibility score (soft constraints) - compatibility_score = 100 # Start with perfect score - compatibility_reasons = [] - - # Check lifestyle preferences overlap - user_lifestyle = prefs.get('lifestyle_preferences', {}) or {} - - # Add user to compatible list - compatible_users.append({ - 'id': user_id, - 'full_name': user_data.get('full_name'), - 'profile_picture_url': user_data.get('profile_picture_url'), - 'company_name': user_data.get('company_name'), - 'school_name': user_data.get('school_name'), - 'verification_status': user_data.get('verification_status'), - 'bio': user_data.get('bio'), - 'preferences': { - 'target_city': prefs.get('target_city'), - 'budget_min': prefs.get('budget_min'), - 'budget_max': prefs.get('budget_max'), - 'move_in_date': str(prefs.get('move_in_date')) if prefs.get('move_in_date') else None, - 'lifestyle_preferences': user_lifestyle, - 'preferred_neighborhoods': prefs.get('preferred_neighborhoods', []) - }, - 'compatibility_score': compatibility_score - }) - - # Sort by compatibility score (highest first) - compatible_users.sort(key=lambda u: u['compatibility_score'], reverse=True) - - return { - "status": "success", - "group_id": group_id, - "group_constraints": { - "target_city": group.get('target_city'), - "budget_min": group.get('budget_per_person_min'), - "budget_max": group.get('budget_per_person_max'), - "move_in_date": str(group.get('target_move_in_date')) if group.get('target_move_in_date') else None - }, - "count": len(compatible_users), - "users": compatible_users - } - -# ============================================================================= - diff --git a/backend/app/routes/groups/__init__.py b/backend/app/routes/groups/__init__.py new file mode 100644 index 0000000..ae59099 --- /dev/null +++ b/backend/app/routes/groups/__init__.py @@ -0,0 +1,25 @@ +""" +Roommate Groups API — assembled from sub-routers. + +Sub-modules: + crud — list, get, create, update, delete + members — invite, join, leave, accept/reject, remove + discovery — discover, pending-requests, compatible-users + listings — matches, eligible-listings, ranked-listings, neural-ranked +""" + +from fastapi import APIRouter + +from .crud import router as crud_router +from .members import router as members_router +from .discovery import router as discovery_router +from .listings import router as listings_router + +router = APIRouter(prefix="/api/roommate-groups", tags=["Roommate Groups"]) + +router.include_router(crud_router) +router.include_router(members_router) +router.include_router(discovery_router) +router.include_router(listings_router) + +__all__ = ["router"] diff --git a/backend/app/routes/groups/_helpers.py b/backend/app/routes/groups/_helpers.py new file mode 100644 index 0000000..3402d97 --- /dev/null +++ b/backend/app/routes/groups/_helpers.py @@ -0,0 +1,270 @@ +""" +Shared helpers for the groups sub-package. + +Contains preference normalisation, payload serialisation, and common +Supabase query patterns that are used by multiple groups sub-routers. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from fastapi import HTTPException + +from app.dependencies.auth import resolve_auth_user +from app.dependencies.supabase import get_admin_client +from app.services.location_matching import filter_listings_for_location +from app.services.preferences_contract import ( + normalize_lease_type, + resolve_furnished_preference, + target_furnished_from_preference, +) +from app.services.auth_helpers import safe_float, safe_int + + +# --------------------------------------------------------------------------- +# Preference normalisation +# --------------------------------------------------------------------------- + +def normalize_group_preference_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + """Keep personal-style and group-legacy field names in sync.""" + out = dict(payload) + + if out.get("budget_min") is not None and out.get("budget_per_person_min") is None: + out["budget_per_person_min"] = out["budget_min"] + if out.get("budget_per_person_min") is not None and out.get("budget_min") is None: + out["budget_min"] = out["budget_per_person_min"] + if out.get("budget_max") is not None and out.get("budget_per_person_max") is None: + out["budget_per_person_max"] = out["budget_max"] + if out.get("budget_per_person_max") is not None and out.get("budget_max") is None: + out["budget_max"] = out["budget_per_person_max"] + + if out.get("move_in_date") is not None and out.get("target_move_in_date") is None: + out["target_move_in_date"] = out["move_in_date"] + if out.get("target_move_in_date") is not None and out.get("move_in_date") is None: + out["move_in_date"] = out["target_move_in_date"] + + if out.get("required_bedrooms") is not None and out.get("target_bedrooms") is None: + out["target_bedrooms"] = out["required_bedrooms"] + if out.get("target_bedrooms") is not None and out.get("required_bedrooms") is None: + out["required_bedrooms"] = out["target_bedrooms"] + + furnished_pref = resolve_furnished_preference( + out.get("furnished_preference"), + out.get("target_furnished"), + ) + if furnished_pref is not None: + out["furnished_preference"] = furnished_pref + out["target_furnished"] = target_furnished_from_preference(furnished_pref) + out["furnished_is_hard"] = furnished_pref == "required" + + if "target_lease_type" in out and out.get("target_lease_type") is not None: + normalized_lease_type = normalize_lease_type(out.get("target_lease_type")) + if normalized_lease_type is not None: + out["target_lease_type"] = normalized_lease_type + + if out.get("preferred_neighborhoods") is not None: + values = [ + str(v).strip() + for v in (out.get("preferred_neighborhoods") or []) + if str(v).strip() + ] + out["preferred_neighborhoods"] = list(dict.fromkeys(values)) + if out.get("lifestyle_preferences") is not None and not isinstance( + out.get("lifestyle_preferences"), dict + ): + out["lifestyle_preferences"] = {} + + return out + + +def to_json_serializable_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + """Convert Decimal/date-like values for Supabase JSON serialisation.""" + from decimal import Decimal + from datetime import date as date_type, datetime as datetime_type + + out = dict(payload) + for key, value in list(out.items()): + if isinstance(value, Decimal): + out[key] = float(value) + elif isinstance(value, date_type) and not isinstance(value, datetime_type): + out[key] = value.isoformat() + return out + + +def normalize_group_record_for_response(group: Dict[str, Any]) -> Dict[str, Any]: + """Ensure response payloads expose both personal-style and legacy group aliases.""" + if not isinstance(group, dict): + return group + return normalize_group_preference_payload(group) + + +# --------------------------------------------------------------------------- +# Aggregate preferences +# --------------------------------------------------------------------------- + +def build_group_update_from_aggregate_prefs( + aggregate_prefs: Dict[str, Any], +) -> Dict[str, Any]: + allowed_fields = [ + "target_country", "target_state_province", "target_city", + "budget_min", "budget_max", "budget_per_person_min", "budget_per_person_max", + "move_in_date", "target_move_in_date", "required_bedrooms", "target_bedrooms", + "target_bathrooms", "target_furnished", "furnished_preference", "furnished_is_hard", + "target_utilities_included", "target_deposit_amount", "gender_policy", + "target_lease_type", "target_lease_duration_months", "target_house_rules", + "preferred_neighborhoods", "lifestyle_preferences", + ] + + nullable_passthrough_fields = { + "target_furnished", "target_utilities_included", "target_deposit_amount", + "target_house_rules", "target_lease_type", "target_lease_duration_months", + } + + update_data: Dict[str, Any] = {} + for key in allowed_fields: + if key not in aggregate_prefs: + continue + if aggregate_prefs[key] is not None or key in nullable_passthrough_fields: + update_data[key] = aggregate_prefs[key] + + update_data = normalize_group_preference_payload(update_data) + return to_json_serializable_payload(update_data) + + +def aggregate_and_persist_group_preferences(group_id: str) -> Dict[str, Any]: + """Recompute aggregate preferences for a group and persist them.""" + from app.services.group_preferences_aggregator import calculate_aggregate_group_preferences + + supabase = get_admin_client() + aggregate_prefs = calculate_aggregate_group_preferences(group_id) + update_data = build_group_update_from_aggregate_prefs(aggregate_prefs) + + if not update_data: + return {"status": "skipped"} + + supabase.table("roommate_groups").update(update_data).eq("id", group_id).execute() + return { + "status": "success", + "updated_fields": list(update_data.keys()), + "aggregate_prefs": { + "budget_min": update_data.get("budget_min"), + "budget_max": update_data.get("budget_max"), + "move_in_date": update_data.get("move_in_date"), + "required_bedrooms": update_data.get("required_bedrooms"), + "target_bathrooms": update_data.get("target_bathrooms"), + "furnished_preference": update_data.get("furnished_preference"), + "gender_policy": update_data.get("gender_policy"), + }, + } + + +# --------------------------------------------------------------------------- +# Listing helpers +# --------------------------------------------------------------------------- + +def fetch_active_listings_for_group_location( + supabase: Any, + group: Dict[str, Any], +) -> List[Dict[str, Any]]: + """Load all active listings, then apply metro-aware location matching.""" + page_size = 1000 + page = 0 + listings: List[Dict[str, Any]] = [] + + while True: + batch = ( + supabase.table("listings") + .select("*") + .eq("status", "active") + .range(page * page_size, page * page_size + page_size - 1) + .execute() + .data + or [] + ) + if not batch: + break + listings.extend(batch) + if len(batch) < page_size: + break + page += 1 + + return filter_listings_for_location( + listings, + target_city=group.get("target_city"), + target_state=group.get("target_state_province"), + target_country=group.get("target_country"), + ) + + +# --------------------------------------------------------------------------- +# Legacy stable-matching stub +# --------------------------------------------------------------------------- + +async def maybe_trigger_legacy_stable_matching( + target_city: Optional[str], + reason: str, +) -> Dict[str, Any]: + """Stable matching has been retired. Preserve response shape for legacy callers.""" + return { + "status": "retired", + "city": target_city, + "message": f"Legacy stable matching has been removed (reason={reason}).", + } + + +# --------------------------------------------------------------------------- +# User-payload builder for group ranking +# --------------------------------------------------------------------------- + +def build_user_payload_from_group( + group: Dict[str, Any], + behavior: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + """Map group constraints + behavior context into recommender user payload.""" + group_size = safe_int( + group.get("target_group_size") or group.get("current_member_count") or 2, + default=2, + ) + group_size = max(1, group_size) + required_beds = safe_int(group.get("required_bedrooms"), default=0) + desired_beds = max(group_size, required_beds) if required_beds else float(group_size) + + furnished_preference = str(group.get("furnished_preference") or "").strip().lower() + wants_furnished = None + if furnished_preference in {"required", "preferred"}: + wants_furnished = 1 + elif group.get("target_furnished") is True: + wants_furnished = 1 + elif group.get("target_furnished") is False: + wants_furnished = 0 + + vector = (behavior or {}).get("vector") or {} + return { + "budget_min": safe_float(group.get("budget_per_person_min"), default=0.0) or None, + "budget_max": safe_float(group.get("budget_per_person_max"), default=0.0) or None, + "desired_beds": desired_beds, + "desired_baths": safe_float(group.get("target_bathrooms"), default=0.0) or None, + "wants_furnished": wants_furnished, + "liked_mean_price": vector.get("liked_mean_price"), + "liked_mean_beds": vector.get("liked_mean_beds"), + "liked_mean_sqfeet": vector.get("liked_mean_sqfeet"), + "behavior_sample_size": safe_int((behavior or {}).get("sample_size"), default=0), + } + + +# --------------------------------------------------------------------------- +# Auth resolve (resolve auth user → app user id) +# --------------------------------------------------------------------------- + +def resolve_auth_user_to_app_id(supabase: Any, token: str) -> str: + """Auth user → users table id, raising 404 if not found.""" + auth_user = resolve_auth_user(supabase, token) + auth_user_id = auth_user.id + + user_record = ( + supabase.table("users").select("id").eq("auth_id", auth_user_id).execute() + ) + if not user_record.data: + raise HTTPException(status_code=404, detail="User profile not found") + return user_record.data[0]["id"] diff --git a/backend/app/routes/groups/crud.py b/backend/app/routes/groups/crud.py new file mode 100644 index 0000000..297e5ef --- /dev/null +++ b/backend/app/routes/groups/crud.py @@ -0,0 +1,314 @@ +"""Group CRUD operations: list, get, create, update, delete.""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.dependencies.auth import require_user_token, resolve_auth_user +from app.dependencies.supabase import get_admin_client +from app.models import RoommateGroupCreate, RoommateGroupUpdate +from app.services.controlled_vocab import validate_city_name, validate_neighborhoods + +from ._helpers import ( + maybe_trigger_legacy_stable_matching, + normalize_group_preference_payload, + normalize_group_record_for_response, + resolve_auth_user_to_app_id, + to_json_serializable_payload, +) + +router = APIRouter() + + +@router.get("/", response_model=dict) +async def list_groups( + token: str = Depends(require_user_token), + status: Optional[str] = Query(None, description="Filter by status (active, inactive, matched)"), + city: Optional[str] = Query(None, description="Filter by target city"), + my_groups: bool = Query(False, description="Only show groups I'm a member of"), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), +): + """List roommate groups with optional filters.""" + supabase = get_admin_client() + + query = supabase.table("roommate_groups").select("*") + if status: + query = query.eq("status", status) + if city: + query = query.ilike("target_city", f"%{city}%") + query = query.order("created_at", desc=True).range(offset, offset + limit - 1) + + response = query.execute() + groups = response.data + + if my_groups and token: + auth_user = resolve_auth_user(supabase, token) + if auth_user: + auth_user_id = auth_user.id + user_record = ( + supabase.table("users") + .select("id") + .eq("auth_id", auth_user_id) + .execute() + ) + if user_record.data: + user_id = user_record.data[0]["id"] + member_response = ( + supabase.table("group_members") + .select("group_id") + .eq("user_id", user_id) + .eq("status", "accepted") + .execute() + ) + member_group_ids = [m["group_id"] for m in member_response.data] + groups = [g for g in groups if g["id"] in member_group_ids] + else: + groups = [] + + if groups: + group_ids = [g["id"] for g in groups] + members_response = ( + supabase.table("group_members") + .select("group_id") + .in_("group_id", group_ids) + .eq("status", "accepted") + .execute() + ) + member_counts: dict = {} + for member in members_response.data: + gid = member["group_id"] + member_counts[gid] = member_counts.get(gid, 0) + 1 + for group in groups: + group["current_member_count"] = member_counts.get(group["id"], 0) + normalized = normalize_group_record_for_response(group) + group.clear() + group.update(normalized) + + return {"status": "success", "count": len(groups), "data": groups} + + +@router.get("/{group_id}", response_model=dict) +async def get_group( + group_id: str, + token: str = Depends(require_user_token), + include_members: bool = Query(True, description="Include member details"), +): + """Get a single roommate group by ID.""" + supabase = get_admin_client() + + group_response = ( + supabase.table("roommate_groups") + .select("*") + .eq("id", group_id) + .single() + .execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + + group = group_response.data + + if include_members: + members_response = ( + supabase.table("group_members") + .select("*, users(id, email, full_name)") + .eq("group_id", group_id) + .execute() + ) + members = [] + for member in members_response.data: + user_data = ( + member.get("users", {}) + if isinstance(member.get("users"), dict) + else {} + ) + members.append( + { + "id": member.get("id"), + "group_id": member.get("group_id"), + "user_id": member.get("user_id"), + "is_creator": member.get("is_creator", False), + "status": member.get("status", "unknown"), + "joined_at": member.get("joined_at"), + "user_name": user_data.get("full_name"), + } + ) + group["members"] = members + group["member_count"] = len( + [m for m in members if m.get("status") == "accepted"] + ) + + return {"status": "success", "data": group} + + +@router.post("/", response_model=dict) +async def create_group( + group_data: RoommateGroupCreate, + token: str = Depends(require_user_token), +): + """Create a new roommate group and add the creator as the first member.""" + supabase = get_admin_client() + user_id = resolve_auth_user_to_app_id(supabase, token) + + group_dict = group_data.model_dump(exclude_none=True) + group_dict = normalize_group_preference_payload(group_dict) + group_dict["creator_user_id"] = user_id + + try: + group_dict["target_city"] = validate_city_name(group_dict.get("target_city", "")) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + if "preferred_neighborhoods" in group_dict: + try: + group_dict["preferred_neighborhoods"] = validate_neighborhoods( + group_dict.get("target_city", ""), + group_dict.get("preferred_neighborhoods"), + ) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + group_dict = to_json_serializable_payload(group_dict) + + group_response = supabase.table("roommate_groups").insert(group_dict).execute() + if not group_response.data: + raise HTTPException(status_code=500, detail="Failed to create group") + + created_group = normalize_group_record_for_response(group_response.data[0]) + group_id = created_group["id"] + + supabase.table("group_members").insert( + {"group_id": group_id, "user_id": user_id, "is_creator": True, "status": "accepted"} + ).execute() + + matching_result = await maybe_trigger_legacy_stable_matching( + target_city=created_group.get("target_city"), reason="group_created" + ) + + return { + "status": "success", + "message": "Group created successfully", + "data": created_group, + "matching": matching_result, + } + + +@router.put("/{group_id}", response_model=dict) +async def update_group( + group_id: str, + group_data: RoommateGroupUpdate, + token: str = Depends(require_user_token), +): + """Update a roommate group. Only the creator can update.""" + supabase = get_admin_client() + user_id = resolve_auth_user_to_app_id(supabase, token) + + member_response = ( + supabase.table("group_members") + .select("is_creator") + .eq("group_id", group_id) + .eq("user_id", user_id) + .eq("status", "accepted") + .execute() + ) + if not member_response.data: + raise HTTPException(status_code=403, detail="You are not a member of this group") + if not member_response.data[0].get("is_creator"): + raise HTTPException(status_code=403, detail="Only the group creator can edit the group") + + group_response = ( + supabase.table("roommate_groups") + .select("*") + .eq("id", group_id) + .single() + .execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + + group = normalize_group_record_for_response(group_response.data) + + update_dict = group_data.model_dump(exclude_none=True) + update_dict = normalize_group_preference_payload(update_dict) + if not update_dict: + raise HTTPException(status_code=400, detail="No data provided for update") + + if "target_city" in update_dict: + try: + update_dict["target_city"] = validate_city_name(update_dict.get("target_city", "")) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + if "preferred_neighborhoods" in update_dict: + effective_city = update_dict.get("target_city") or group.get("target_city") + try: + update_dict["preferred_neighborhoods"] = validate_neighborhoods( + effective_city or "", update_dict.get("preferred_neighborhoods") + ) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + update_dict = to_json_serializable_payload(update_dict) + + updated_response = ( + supabase.table("roommate_groups") + .update(update_dict) + .eq("id", group_id) + .execute() + ) + + preference_fields = [ + "budget_per_person_min", "budget_per_person_max", + "target_move_in_date", "target_city", + ] + matching_result = {"status": "skipped", "message": "No preference changes detected"} + if any(field in update_dict for field in preference_fields): + target_city = update_dict.get("target_city") or group.get("target_city") + matching_result = await maybe_trigger_legacy_stable_matching( + target_city=target_city, reason="group_updated" + ) + + return { + "status": "success", + "message": "Group updated successfully", + "data": ( + normalize_group_record_for_response(updated_response.data[0]) + if updated_response.data + else None + ), + "matching": matching_result, + } + + +@router.delete("/{group_id}", response_model=dict) +async def delete_group( + group_id: str, + token: str = Depends(require_user_token), +): + """Delete a roommate group. Only the creator can delete.""" + supabase = get_admin_client() + user_id = resolve_auth_user_to_app_id(supabase, token) + + group_response = ( + supabase.table("roommate_groups") + .select("*") + .eq("id", group_id) + .single() + .execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + + group = group_response.data + if group["creator_user_id"] != user_id: + raise HTTPException( + status_code=403, detail="Only the group creator can delete this group" + ) + + supabase.table("group_members").delete().eq("group_id", group_id).execute() + supabase.table("roommate_groups").delete().eq("id", group_id).execute() + + return {"status": "success", "message": "Group deleted successfully"} diff --git a/backend/app/routes/groups/discovery.py b/backend/app/routes/groups/discovery.py new file mode 100644 index 0000000..cc6709d --- /dev/null +++ b/backend/app/routes/groups/discovery.py @@ -0,0 +1,286 @@ +"""Group discovery: discover compatible groups, pending requests, compatible users.""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.dependencies.auth import require_user_token, resolve_auth_user +from app.dependencies.supabase import get_admin_client +from app.services.location_matching import cities_match + +from ._helpers import ( + normalize_group_record_for_response, + resolve_auth_user_to_app_id, +) + +router = APIRouter() + + +@router.get("/discover", response_model=dict) +async def discover_groups( + city: str = Query(..., description="Target city"), + budget_min: Optional[float] = Query(None, description="Minimum budget per person"), + budget_max: Optional[float] = Query(None, description="Maximum budget per person"), + move_in_date: Optional[str] = Query(None, description="Target move-in date (ISO format)"), + min_compatibility_score: int = Query(50, ge=0, le=100, description="Minimum compatibility score"), + limit: int = Query(20, ge=1, le=100, description="Max results"), + token: str = Depends(require_user_token), +): + """Discover compatible roommate groups based on user preferences.""" + from app.services.user_group_matching import find_compatible_groups + + supabase = get_admin_client() + auth_user = resolve_auth_user(supabase, token) + user_id = auth_user.id + + user_db_response = ( + supabase.table("users").select("*").eq("id", user_id).single().execute() + ) + if not user_db_response.data: + raise HTTPException(status_code=404, detail="User profile not found") + + prefs_response = ( + supabase.table("personal_preferences").select("*").eq("user_id", user_id).execute() + ) + if prefs_response.data: + user_prefs = prefs_response.data[0] + else: + user_prefs = { + "target_city": city, + "budget_min": budget_min, + "budget_max": budget_max, + "move_in_date": move_in_date, + } + + if budget_min is not None: + user_prefs["budget_min"] = budget_min + if budget_max is not None: + user_prefs["budget_max"] = budget_max + if move_in_date is not None: + user_prefs["move_in_date"] = move_in_date + if not user_prefs.get("target_city"): + user_prefs["target_city"] = city + + compatible_groups = await find_compatible_groups( + user_id=user_id, + user_prefs=user_prefs, + min_score=min_compatibility_score, + limit=limit, + ) + + formatted_groups = [] + for group in compatible_groups: + members_response = ( + supabase.table("group_members") + .select("*, users(id, full_name, company_name, school_name, verification_status)") + .eq("group_id", group["id"]) + .eq("status", "accepted") + .execute() + ) + members = [] + for md in members_response.data: + ud = md.get("users", {}) if isinstance(md.get("users"), dict) else {} + members.append({ + "id": ud.get("id"), + "full_name": ud.get("full_name"), + "company_name": ud.get("company_name"), + "school_name": ud.get("school_name"), + "verification_status": ud.get("verification_status"), + "is_creator": md.get("is_creator", False), + }) + + current_count = group.get("current_member_count", len(members)) + target_size = group.get("target_group_size") + open_spots = (target_size - current_count) if target_size else None + + formatted_groups.append({ + "id": group["id"], + "group_name": group["group_name"], + "description": group.get("description"), + "target_city": group["target_city"], + "budget_per_person_min": group.get("budget_per_person_min"), + "budget_per_person_max": group.get("budget_per_person_max"), + "target_move_in_date": str(group["target_move_in_date"]) if group.get("target_move_in_date") else None, + "target_group_size": target_size, + "current_member_count": current_count, + "open_spots": open_spots, + "members": members, + "compatibility": group["compatibility"], + "created_at": str(group["created_at"]) if group.get("created_at") else None, + }) + + return {"status": "success", "count": len(formatted_groups), "groups": formatted_groups} + + +@router.get("/{group_id}/pending-requests", response_model=dict) +async def get_pending_requests(group_id: str, token: str = Depends(require_user_token)): + """Get pending join requests for a group (creator only).""" + from app.services.user_group_matching import calculate_user_group_compatibility + + supabase = get_admin_client() + current_user_id = resolve_auth_user_to_app_id(supabase, token) + + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = normalize_group_record_for_response(group_response.data) + if group["creator_user_id"] != current_user_id: + raise HTTPException(status_code=403, detail="Only group creator can view pending requests") + + pending_response = ( + supabase.table("group_members") + .select("*, users(id, email, full_name, company_name, school_name, verification_status, profile_picture_url)") + .eq("group_id", group_id) + .eq("status", "pending") + .execute() + ) + + requests = [] + for md in pending_response.data: + ud = md.get("users", {}) if isinstance(md.get("users"), dict) else {} + uid = ud.get("id") + if not uid: + continue + + prefs_response = ( + supabase.table("personal_preferences").select("*").eq("user_id", uid).execute() + ) + user_prefs = prefs_response.data[0] if prefs_response.data else {} + compatibility = calculate_user_group_compatibility(ud, user_prefs, group) + + requests.append({ + "user_id": uid, + "full_name": ud.get("full_name"), + "company_name": ud.get("company_name"), + "school_name": ud.get("school_name"), + "verification_status": ud.get("verification_status"), + "profile_picture_url": ud.get("profile_picture_url"), + "requested_at": str(md.get("joined_at")) if md.get("joined_at") else None, + "user_preferences": { + "budget_min": user_prefs.get("budget_min"), + "budget_max": user_prefs.get("budget_max"), + "target_city": user_prefs.get("target_city"), + "move_in_date": str(user_prefs.get("move_in_date")) if user_prefs.get("move_in_date") else None, + "lifestyle_preferences": user_prefs.get("lifestyle_preferences", {}), + }, + "compatibility": compatibility, + }) + + requests.sort(key=lambda r: r["compatibility"]["score"], reverse=True) + return {"status": "success", "count": len(requests), "requests": requests} + + +@router.get("/{group_id}/compatible-users", response_model=dict) +async def get_compatible_users(group_id: str, token: str = Depends(require_user_token)): + """Get users who are compatible with the group's hard constraints.""" + supabase = get_admin_client() + current_user_id = resolve_auth_user_to_app_id(supabase, token) + + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data + + member_check = ( + supabase.table("group_members") + .select("user_id") + .eq("group_id", group_id) + .eq("user_id", current_user_id) + .eq("status", "accepted") + .execute() + ) + if not member_check.data: + raise HTTPException(status_code=403, detail="Only group members can view compatible users") + + existing_members = ( + supabase.table("group_members").select("user_id").eq("group_id", group_id).execute() + ) + excluded_user_ids = [m["user_id"] for m in existing_members.data] + + users_response = ( + supabase.table("users") + .select("id, email, full_name, profile_picture_url, company_name, school_name, verification_status, bio") + .execute() + ) + prefs_response = supabase.table("personal_preferences").select("*").execute() + prefs_map = {p["user_id"]: p for p in prefs_response.data} + + compatible_users = [] + group_city = group.get("target_city", "").strip() + group_budget_min = group.get("budget_per_person_min") or 0 + group_budget_max = group.get("budget_per_person_max") or float("inf") + group_move_in = group.get("target_move_in_date") + + for user_data in users_response.data: + uid = user_data["id"] + if uid in excluded_user_ids: + continue + + prefs = prefs_map.get(uid, {}) + + user_city = (prefs.get("target_city") or "").strip() + if group_city and user_city and not cities_match(group_city, user_city): + continue + + user_budget_min = prefs.get("budget_min") or 0 + user_budget_max = prefs.get("budget_max") or float("inf") + if user_budget_max < group_budget_min or user_budget_min > group_budget_max: + continue + + if group_move_in and prefs.get("move_in_date"): + from datetime import datetime + try: + group_date = ( + datetime.fromisoformat(str(group_move_in).replace("Z", "+00:00")).date() + if isinstance(group_move_in, str) else group_move_in + ) + user_date = ( + datetime.fromisoformat(str(prefs["move_in_date"]).replace("Z", "+00:00")).date() + if isinstance(prefs["move_in_date"], str) else prefs["move_in_date"] + ) + if abs((group_date - user_date).days) > 30: + continue + except (ValueError, TypeError): + pass + + user_lifestyle = prefs.get("lifestyle_preferences", {}) or {} + + compatible_users.append({ + "id": uid, + "full_name": user_data.get("full_name"), + "profile_picture_url": user_data.get("profile_picture_url"), + "company_name": user_data.get("company_name"), + "school_name": user_data.get("school_name"), + "verification_status": user_data.get("verification_status"), + "bio": user_data.get("bio"), + "preferences": { + "target_city": prefs.get("target_city"), + "budget_min": prefs.get("budget_min"), + "budget_max": prefs.get("budget_max"), + "move_in_date": str(prefs.get("move_in_date")) if prefs.get("move_in_date") else None, + "lifestyle_preferences": user_lifestyle, + "preferred_neighborhoods": prefs.get("preferred_neighborhoods", []), + }, + "compatibility_score": 100, + }) + + compatible_users.sort(key=lambda u: u["compatibility_score"], reverse=True) + + return { + "status": "success", + "group_id": group_id, + "group_constraints": { + "target_city": group.get("target_city"), + "budget_min": group.get("budget_per_person_min"), + "budget_max": group.get("budget_per_person_max"), + "move_in_date": str(group.get("target_move_in_date")) if group.get("target_move_in_date") else None, + }, + "count": len(compatible_users), + "users": compatible_users, + } diff --git a/backend/app/routes/groups/listings.py b/backend/app/routes/groups/listings.py new file mode 100644 index 0000000..209b70e --- /dev/null +++ b/backend/app/routes/groups/listings.py @@ -0,0 +1,435 @@ +"""Group listing ranking: matches, eligible-listings, ranked-listings, neural-ranked.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.config import settings +from app.dependencies.auth import get_user_token, require_user_token +from app.dependencies.supabase import get_admin_client +from app.services import ml_client +from app.services.auth_helpers import resolve_current_user_id, require_group_membership, safe_float, safe_int +from app.services.behavior_features import build_group_behavior_vector + +from ._helpers import ( + build_user_payload_from_group, + fetch_active_listings_for_group_location, + normalize_group_record_for_response, +) + +router = APIRouter() + + +def _build_legacy_rule_rankings( + group: Dict[str, Any], + eligible_listings: List[Dict[str, Any]], + limit: int, +) -> List[Dict[str, Any]]: + """Phase 1 legacy ranking: soft rule score over hard-filtered candidates.""" + from app.services.stable_matching.scoring import calculate_group_score + + group_size = safe_int(group.get("target_group_size"), default=2) + group_size = max(1, group_size) + + ranked = [] + for listing in eligible_listings: + score = float(calculate_group_score(group, listing)) + listing_copy = dict(listing) + listing_copy["match_score"] = round(score, 2) + listing_copy["match_percent"] = f"{round(score)}%" + listing_price = safe_float(listing.get("price_per_month"), default=0.0) + listing_copy["price_per_person"] = round(listing_price / group_size, 2) + ranked.append(listing_copy) + + ranked.sort( + key=lambda x: (-x.get("match_score", 0.0), x.get("price_per_month", float("inf"))) + ) + return ranked[:limit] + + +@router.get("/{group_id}/matches", response_model=dict) +async def get_group_matches( + group_id: str, + token: Optional[str] = Depends(get_user_token), +): + """Phase 3B compatibility endpoint. + + Primary: neural-ranked listings. Fallback: deterministic rule-ranked. + """ + + def to_legacy_data(ranked_listings: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for idx, listing in enumerate(ranked_listings, start=1): + listing_id = listing.get("id") or listing.get("listing_id") + normalized_listing = dict(listing) + normalized_listing["id"] = listing_id + out.append({ + "id": listing_id, + "listing_id": listing_id, + "group_rank": idx, + "listing_rank": None, + "group_score": listing.get("match_score"), + "listing_score": None, + "is_stable": False, + "algorithm_version": listing.get("algorithm_version"), + "score_breakdown": listing.get("score_breakdown"), + "explainability": listing.get("explainability"), + "listing": normalized_listing, + }) + return out + + if token: + try: + response = await get_neural_ranked_listings_for_group( + group_id=group_id, + limit=50, + shadow_compare=False, + force_enable=True, + token=token, + ) + data = to_legacy_data(response.get("ranked_listings", [])) + return { + "status": "success", + "mode": "neural_cutover", + "group_id": group_id, + "count": len(data), + "data": data, + "fallback_used": False, + } + except HTTPException as exc: + if exc.status_code not in {403, 503, 500}: + raise + + fallback = await get_ranked_listings_for_group( + group_id=group_id, limit=50, token=token + ) + data = to_legacy_data(fallback.get("ranked_listings", [])) + return { + "status": "success", + "mode": "legacy_rule_fallback", + "group_id": group_id, + "count": len(data), + "data": data, + "fallback_used": True, + } + + +@router.get("/{group_id}/eligible-listings", response_model=dict) +async def get_eligible_listings_for_group( + group_id: str, + limit: int = Query(50, ge=1, le=200, description="Max listings to return"), + token: Optional[str] = Depends(get_user_token), +): + """Get listings that match the group's HARD CONSTRAINTS only.""" + from app.services.stable_matching import build_feasible_pairs, get_feasibility_statistics + + supabase = get_admin_client() + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data + target_city = group.get("target_city") + if not target_city: + raise HTTPException(status_code=400, detail="Group must have a target city to find eligible listings") + + all_listings = fetch_active_listings_for_group_location(supabase, group) + if not all_listings: + return { + "status": "success", + "group_id": group_id, + "group_constraints": { + "target_city": target_city, + "budget_min": group.get("budget_per_person_min"), + "budget_max": group.get("budget_per_person_max"), + "target_move_in_date": str(group.get("target_move_in_date")) if group.get("target_move_in_date") else None, + }, + "count": 0, + "listings": [], + "message": f"No active listings found in {target_city}", + } + + feasible_pairs, _ = build_feasible_pairs( + groups=[group], listings=all_listings, date_delta_days=30, include_rejection_reasons=True + ) + eligible_listing_ids = {listing_id for _, listing_id in feasible_pairs} + + eligible_listings = [] + for listing in all_listings: + if listing["id"] in eligible_listing_ids: + price = float(listing.get("price_per_month", 0)) + listing["price_per_person"] = round(price / 2, 2) + eligible_listings.append(listing) + + eligible_listings.sort(key=lambda x: x.get("price_per_month", float("inf"))) + eligible_listings = eligible_listings[:limit] + + stats = get_feasibility_statistics([group], all_listings, feasible_pairs) + + return { + "status": "success", + "group_id": group_id, + "group_constraints": { + "target_city": target_city, + "budget_min": group.get("budget_per_person_min"), + "budget_max": group.get("budget_per_person_max"), + "target_move_in_date": str(group.get("target_move_in_date")) if group.get("target_move_in_date") else None, + "target_furnished": group.get("target_furnished"), + "target_utilities_included": group.get("target_utilities_included"), + }, + "stats": { + "total_listings_in_city": stats["total_listings"], + "eligible_count": stats["total_feasible_pairs"], + "rejected_count": stats["listings_with_no_options"], + }, + "count": len(eligible_listings), + "listings": eligible_listings, + } + + +@router.get("/{group_id}/ranked-listings", response_model=dict) +async def get_ranked_listings_for_group( + group_id: str, + limit: int = Query(50, ge=1, le=200, description="Max ranked listings to return"), + token: Optional[str] = Depends(get_user_token), +): + """Deterministic fallback: hard constraint filter + soft rule-based scoring.""" + from app.services.stable_matching import build_feasible_pairs, get_feasibility_statistics + from app.services.stable_matching.scoring import calculate_group_score + + supabase = get_admin_client() + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).limit(1).execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data[0] + target_city = group.get("target_city") + if not target_city: + raise HTTPException(status_code=400, detail="Group must have a target city") + + all_listings = fetch_active_listings_for_group_location(supabase, group) + if not all_listings: + return { + "status": "success", + "group_id": group_id, + "count": 0, + "ranked_listings": [], + "message": f"No active listings found in {target_city}", + } + + feasible_pairs, _ = build_feasible_pairs( + groups=[group], listings=all_listings, date_delta_days=30, include_rejection_reasons=True + ) + eligible_listing_ids = {listing_id for _, listing_id in feasible_pairs} + + group_size = max(int(group.get("target_group_size") or group.get("current_member_count") or 2), 1) + + ranked = [] + for listing in all_listings: + if listing["id"] not in eligible_listing_ids: + continue + score = float(calculate_group_score(group, listing)) + listing_copy = dict(listing) + listing_copy["match_score"] = round(score, 2) + listing_copy["match_percent"] = f"{round(score)}%" + listing_price = float(listing.get("price_per_month") or 0.0) + listing_copy["price_per_person"] = round(listing_price / group_size, 2) + ranked.append(listing_copy) + + ranked.sort(key=lambda x: (-x.get("match_score", 0.0), x.get("price_per_month", float("inf")))) + ranked = ranked[:limit] + + stats = get_feasibility_statistics([group], all_listings, feasible_pairs) + + return { + "status": "success", + "group_id": group_id, + "group_constraints": { + "target_city": target_city, + "budget_min": group.get("budget_per_person_min"), + "budget_max": group.get("budget_per_person_max"), + "target_move_in_date": str(group.get("target_move_in_date")) if group.get("target_move_in_date") else None, + "target_lease_type": group.get("target_lease_type"), + "target_lease_duration_months": group.get("target_lease_duration_months"), + "target_bathrooms": group.get("target_bathrooms"), + "target_furnished": group.get("target_furnished"), + "target_utilities_included": group.get("target_utilities_included"), + "target_deposit_amount": group.get("target_deposit_amount"), + "target_house_rules": group.get("target_house_rules"), + }, + "stats": { + "total_listings_in_city": stats["total_listings"], + "eligible_count": stats["total_feasible_pairs"], + "returned_count": len(ranked), + }, + "count": len(ranked), + "ranked_listings": ranked, + } + + +@router.get("/{group_id}/neural-ranked-listings", response_model=dict) +async def get_neural_ranked_listings_for_group( + group_id: str, + limit: int = Query(50, ge=1, le=200, description="Max ranked listings to return"), + shadow_compare: bool = Query(False), + force_enable: bool = Query(False), + token: str = Depends(require_user_token), +): + """Phase 3A Group -> Listing neural-ranked feed.""" + from app.services.stable_matching import build_feasible_pairs, get_feasibility_statistics + + ranking_enabled = settings.padly_group_neural_ranking_enabled + kill_switch = settings.padly_group_neural_kill_switch + + if kill_switch: + raise HTTPException(status_code=503, detail="Group neural ranking is temporarily disabled by kill switch.") + if not ranking_enabled and not force_enable: + raise HTTPException( + status_code=503, + detail="Group neural ranking is not enabled yet. Set PADLY_GROUP_NEURAL_RANKING_ENABLED=true or use force_enable for testing.", + ) + + supabase = get_admin_client() + current_user_id = resolve_current_user_id(token) + require_group_membership(group_id=group_id, user_id=current_user_id) + + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).limit(1).execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data[0] + + target_city = group.get("target_city") + if not target_city: + raise HTTPException(status_code=400, detail="Group must have a target city") + + all_listings = fetch_active_listings_for_group_location(supabase, group) + if not all_listings: + return { + "status": "success", + "mode": "group_neural_ranked", + "group_id": group_id, + "count": 0, + "ranked_listings": [], + "message": f"No active listings found in {target_city}", + } + + feasible_pairs, _ = build_feasible_pairs( + groups=[group], listings=all_listings, date_delta_days=30, include_rejection_reasons=True + ) + eligible_listing_ids = {listing_id for _, listing_id in feasible_pairs} + eligible_listings = [l for l in all_listings if l.get("id") in eligible_listing_ids] + feasibility_stats = get_feasibility_statistics([group], all_listings, feasible_pairs) + + if not eligible_listings: + return { + "status": "success", + "mode": "group_neural_ranked", + "group_id": group_id, + "stats": { + "total_listings_in_city": feasibility_stats["total_listings"], + "eligible_count": feasibility_stats["total_feasible_pairs"], + "returned_count": 0, + }, + "count": 0, + "ranked_listings": [], + "message": "No hard-eligible listings found for this group.", + } + + warnings: List[str] = [] + behavior = None + try: + behavior = build_group_behavior_vector(group_id=group_id, days=180, max_events_per_user=2000) + except Exception as e: + warnings.append(f"Behavior vector unavailable, using neutral behavior prior: {e}") + + user_payload = build_user_payload_from_group(group=group, behavior=behavior) + scored = await ml_client.score_listings(user_payload, eligible_listings, top_n=limit) + + invalid_ids = [str(item.get("id")) for item in scored if item.get("id") not in eligible_listing_ids] + if invalid_ids: + raise HTTPException( + status_code=500, + detail=f"Hard-filter guardrail violation. Ranked non-eligible listings: {invalid_ids[:5]}", + ) + + group_size = max(safe_int(group.get("target_group_size") or group.get("current_member_count") or 2, default=2), 1) + + ranked_listings = [] + for item in scored: + listing = dict(item) + listing["listing_id"] = str(item.get("id") or "") + listing_price = safe_float(item.get("price_per_month"), default=0.0) + listing["price_per_person"] = round(listing_price / group_size, 2) + ranked_listings.append(listing) + + shadow_comparison = None + if shadow_compare: + legacy_ranked = _build_legacy_rule_rankings(group=group, eligible_listings=eligible_listings, limit=limit) + neural_ids = [str(x.get("id")) for x in ranked_listings] + legacy_ids = [str(x.get("id")) for x in legacy_ranked] + neural_top = neural_ids[:limit] + legacy_top = legacy_ids[:limit] + overlap = len(set(neural_top).intersection(set(legacy_top))) + denom = len(set(neural_top).union(set(legacy_top))) or 1 + shadow_comparison = { + "enabled": True, + "top_k": limit, + "overlap_count": overlap, + "overlap_rate": round(overlap / max(1, min(len(neural_top), len(legacy_top))), 4), + "jaccard_top_k": round(overlap / denom, 4), + "legacy_only_top_ids": [x for x in legacy_top if x not in set(neural_top)][:10], + "neural_only_top_ids": [x for x in neural_top if x not in set(legacy_top)][:10], + } + + return { + "status": "success", + "mode": "group_neural_ranked", + "group_id": group_id, + "feature_flags": { + "group_neural_ranking_enabled": ranking_enabled, + "group_neural_kill_switch": kill_switch, + "force_enable": force_enable, + }, + "group_constraints": { + "target_city": target_city, + "budget_min": group.get("budget_per_person_min"), + "budget_max": group.get("budget_per_person_max"), + "target_move_in_date": str(group.get("target_move_in_date")) if group.get("target_move_in_date") else None, + }, + "stats": { + "total_listings_in_city": feasibility_stats["total_listings"], + "eligible_count": feasibility_stats["total_feasible_pairs"], + "returned_count": len(ranked_listings), + }, + "behavior_context": { + "sample_size": user_payload.get("behavior_sample_size"), + "has_behavior_signal": any( + user_payload.get(key) is not None + for key in ("liked_mean_price", "liked_mean_beds", "liked_mean_sqfeet") + ), + }, + "warnings": warnings, + "count": len(ranked_listings), + "ranked_listings": ranked_listings, + "shadow_comparison": shadow_comparison, + } + + +# --------------------------------------------------------------------------- +# Retired confirmation endpoints +# --------------------------------------------------------------------------- + +@router.post("/{group_id}/confirm-match", response_model=dict) +async def confirm_match_as_group(group_id: str, token: str = Depends(require_user_token)): + raise HTTPException(status_code=410, detail="Stable match confirmations have been retired.") + + +@router.delete("/{group_id}/reject-match", response_model=dict) +async def reject_match_as_group(group_id: str, token: str = Depends(require_user_token)): + raise HTTPException(status_code=410, detail="Stable match rejections have been retired.") diff --git a/backend/app/routes/groups/members.py b/backend/app/routes/groups/members.py new file mode 100644 index 0000000..795ca1a --- /dev/null +++ b/backend/app/routes/groups/members.py @@ -0,0 +1,653 @@ +"""Member management: invite, join, leave, accept/reject requests.""" + +from __future__ import annotations + +import random +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from app.dependencies.auth import require_user_token, resolve_auth_user +from app.dependencies.supabase import get_admin_client + +from ._helpers import ( + aggregate_and_persist_group_preferences, + maybe_trigger_legacy_stable_matching, + normalize_group_record_for_response, + resolve_auth_user_to_app_id, +) + +router = APIRouter() + + +class GroupMemberInvite(BaseModel): + user_email: str + message: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Pending requests (user's own) +# --------------------------------------------------------------------------- + +@router.get("/my-pending-requests", response_model=dict) +async def get_my_pending_requests(token: str = Depends(require_user_token)): + """Get all pending join requests for the current user.""" + supabase = get_admin_client() + user_id = resolve_auth_user_to_app_id(supabase, token) + + pending_response = ( + supabase.table("group_members") + .select("group_id, joined_at, roommate_groups(id, group_name, target_city, status)") + .eq("user_id", user_id) + .eq("status", "pending") + .execute() + ) + + pending_requests = [] + for membership in pending_response.data: + group_data = membership.get("roommate_groups", {}) if membership.get("roommate_groups") else {} + pending_requests.append({ + "group_id": membership["group_id"], + "group_name": group_data.get("group_name"), + "target_city": group_data.get("target_city"), + "group_status": group_data.get("status"), + "requested_at": membership.get("joined_at"), + }) + + return {"status": "success", "count": len(pending_requests), "data": pending_requests} + + +# --------------------------------------------------------------------------- +# List / get members +# --------------------------------------------------------------------------- + +@router.get("/{group_id}/members", response_model=dict) +async def get_group_members( + group_id: str, + token: str = Depends(require_user_token), + status_filter: Optional[str] = Query(None, description="Filter by member status"), +): + """Get all members of a group.""" + supabase = get_admin_client() + + group_response = ( + supabase.table("roommate_groups").select("id").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + + query = ( + supabase.table("group_members") + .select("*, users(id, email, full_name, profile_picture_url)") + .eq("group_id", group_id) + ) + if status_filter: + query = query.eq("status", status_filter) + members_response = query.execute() + + members = [] + for member in members_response.data: + user_data = member.get("users", {}) if isinstance(member.get("users"), dict) else {} + members.append({ + "id": member.get("id"), + "group_id": member.get("group_id"), + "user_id": member.get("user_id"), + "is_creator": member.get("is_creator", False), + "status": member.get("status", "unknown"), + "joined_at": member.get("joined_at"), + "user_name": user_data.get("full_name"), + "user_picture": user_data.get("profile_picture_url"), + }) + + return {"status": "success", "count": len(members), "data": members} + + +# --------------------------------------------------------------------------- +# Invite +# --------------------------------------------------------------------------- + +@router.post("/{group_id}/invite", response_model=dict) +async def invite_to_group( + group_id: str, + invite_data: GroupMemberInvite, + token: str = Depends(require_user_token), +): + """Invite a user to join the group by email. Only group members can invite.""" + supabase = get_admin_client() + inviter_id = resolve_auth_user_to_app_id(supabase, token) + + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data + + is_creator = group["creator_user_id"] == inviter_id + if not is_creator: + member_response = ( + supabase.table("group_members") + .select("*") + .eq("group_id", group_id) + .eq("user_id", inviter_id) + .eq("status", "accepted") + .execute() + ) + if not member_response.data: + raise HTTPException(status_code=403, detail="Only group members can invite others") + + invited_user_response = ( + supabase.table("users") + .select("id, email, full_name") + .eq("email", invite_data.user_email) + .execute() + ) + if not invited_user_response.data: + raise HTTPException( + status_code=404, detail=f"User with email {invite_data.user_email} not found" + ) + invited_user_id = invited_user_response.data[0]["id"] + + existing_member = ( + supabase.table("group_members") + .select("*") + .eq("group_id", group_id) + .eq("user_id", invited_user_id) + .execute() + ) + if existing_member.data: + st = existing_member.data[0]["status"] + if st == "accepted": + raise HTTPException(status_code=400, detail="User is already a member of this group") + if st == "pending": + raise HTTPException(status_code=400, detail="User already has a pending invitation") + if st == "rejected": + supabase.table("group_members").update({"status": "pending"}).eq( + "id", existing_member.data[0]["id"] + ).execute() + return { + "status": "success", + "message": "Invitation re-sent successfully", + "data": { + "group_id": group_id, + "invited_user_email": invite_data.user_email, + "status": "pending", + }, + } + + supabase.table("group_members").insert( + {"group_id": group_id, "user_id": invited_user_id, "is_creator": False, "status": "pending"} + ).execute() + + return { + "status": "success", + "message": "Invitation sent successfully", + "data": { + "group_id": group_id, + "invited_user_email": invite_data.user_email, + "invited_user_name": invited_user_response.data[0].get("full_name"), + "status": "pending", + }, + } + + +# --------------------------------------------------------------------------- +# Request-join / join / reject +# --------------------------------------------------------------------------- + +@router.post("/{group_id}/request-join", response_model=dict) +async def request_join_group(group_id: str, token: str = Depends(require_user_token)): + """Request to join a group (self-invite, creates a pending invitation).""" + supabase = get_admin_client() + user_id = resolve_auth_user_to_app_id(supabase, token) + + existing_group_membership = ( + supabase.table("group_members") + .select("group_id, roommate_groups(group_name, is_solo)") + .eq("user_id", user_id) + .eq("status", "accepted") + .execute() + ) + if existing_group_membership.data: + existing = existing_group_membership.data[0] + group_info = existing.get("roommate_groups", {}) + is_solo = group_info.get("is_solo", False) + group_name = group_info.get("group_name", "a group") + if is_solo: + raise HTTPException( + status_code=400, + detail="You are currently in a solo group. Please leave your solo group first before requesting to join another group.", + ) + raise HTTPException( + status_code=400, + detail=f"You are already a member of '{group_name}'. You can only be in one group at a time. Please leave your current group first.", + ) + + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data + + existing_member = ( + supabase.table("group_members") + .select("*") + .eq("group_id", group_id) + .eq("user_id", user_id) + .execute() + ) + if existing_member.data: + st = existing_member.data[0]["status"] + if st == "accepted": + raise HTTPException(status_code=400, detail="You are already a member of this group") + if st == "pending": + raise HTTPException(status_code=400, detail="You already have a pending request to join") + if st == "rejected": + supabase.table("group_members").update({"status": "pending"}).eq( + "id", existing_member.data[0]["id"] + ).execute() + return { + "status": "success", + "message": "Join request sent successfully", + "data": {"group_id": group_id, "group_name": group["group_name"], "status": "pending"}, + } + + current_members = ( + supabase.table("group_members") + .select("user_id") + .eq("group_id", group_id) + .eq("status", "accepted") + .execute() + ) + target_size = group.get("target_group_size") + if target_size is not None and len(current_members.data) >= target_size: + raise HTTPException(status_code=400, detail="Group is already full") + + supabase.table("group_members").insert( + {"group_id": group_id, "user_id": user_id, "is_creator": False, "status": "pending"} + ).execute() + + return { + "status": "success", + "message": "Join request sent successfully. You can accept it from the Invitations page.", + "data": {"group_id": group_id, "group_name": group["group_name"], "status": "pending"}, + } + + +@router.post("/{group_id}/join", response_model=dict) +async def join_group(group_id: str, token: str = Depends(require_user_token)): + """Accept invitation and join a group.""" + supabase = get_admin_client() + auth_user = resolve_auth_user(supabase, token) + user_id = auth_user.id + + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data + + member_response = ( + supabase.table("group_members") + .select("*") + .eq("group_id", group_id) + .eq("user_id", user_id) + .execute() + ) + if not member_response.data: + raise HTTPException(status_code=400, detail="You don't have an invitation to this group") + member = member_response.data[0] + if member["status"] == "accepted": + raise HTTPException(status_code=400, detail="You are already a member of this group") + if member["status"] == "rejected": + raise HTTPException(status_code=400, detail="You previously rejected this invitation") + + target_size = group.get("target_group_size") + if target_size is not None: + current_members = ( + supabase.table("group_members") + .select("user_id") + .eq("group_id", group_id) + .eq("status", "accepted") + .execute() + ) + if len(current_members.data) >= target_size: + raise HTTPException(status_code=400, detail="Group is already full") + + supabase.table("group_members").update({"status": "accepted"}).eq( + "group_id", group_id + ).eq("user_id", user_id).execute() + + aggregation_result: dict = {"status": "skipped"} + try: + aggregation_result = aggregate_and_persist_group_preferences(group_id) + except Exception as e: + aggregation_result = {"status": "error", "message": str(e)} + + matching_result = await maybe_trigger_legacy_stable_matching( + target_city=group.get("target_city"), reason="group_joined" + ) + + updated_group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + updated_group = updated_group_response.data if updated_group_response.data else group + + return { + "status": "success", + "message": "Successfully joined the group", + "data": { + "group_id": group_id, + "group_name": group["group_name"], + "current_member_count": updated_group.get("current_member_count", 1), + }, + "preference_aggregation": aggregation_result, + "matching": matching_result, + } + + +@router.post("/{group_id}/reject", response_model=dict) +async def reject_invitation(group_id: str, token: str = Depends(require_user_token)): + """Reject a group invitation.""" + supabase = get_admin_client() + user_id = resolve_auth_user_to_app_id(supabase, token) + + member_response = ( + supabase.table("group_members") + .select("*") + .eq("group_id", group_id) + .eq("user_id", user_id) + .eq("status", "pending") + .execute() + ) + if not member_response.data: + raise HTTPException(status_code=400, detail="No pending invitation found") + + supabase.table("group_members").update({"status": "rejected"}).eq( + "group_id", group_id + ).eq("user_id", user_id).execute() + + return {"status": "success", "message": "Invitation rejected"} + + +# --------------------------------------------------------------------------- +# Creator accept/reject join requests +# --------------------------------------------------------------------------- + +@router.post("/{group_id}/accept-request/{user_id}", response_model=dict) +async def accept_join_request( + group_id: str, user_id: str, token: str = Depends(require_user_token) +): + """Accept a user's request to join the group (creator only).""" + supabase = get_admin_client() + current_user_id = resolve_auth_user_to_app_id(supabase, token) + + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data + if group["creator_user_id"] != current_user_id: + raise HTTPException(status_code=403, detail="Only the group creator can accept join requests") + + member_response = ( + supabase.table("group_members") + .select("*") + .eq("group_id", group_id) + .eq("user_id", user_id) + .eq("status", "pending") + .execute() + ) + if not member_response.data: + raise HTTPException(status_code=400, detail="No pending join request found for this user") + + target_size = group.get("target_group_size") + if target_size is not None: + current_members = ( + supabase.table("group_members") + .select("user_id") + .eq("group_id", group_id) + .eq("status", "accepted") + .execute() + ) + if len(current_members.data) >= target_size: + raise HTTPException(status_code=400, detail="Group is already full") + + supabase.table("group_members").update({"status": "accepted"}).eq( + "group_id", group_id + ).eq("user_id", user_id).execute() + + supabase.table("group_members").delete().eq("user_id", user_id).eq( + "status", "pending" + ).neq("group_id", group_id).execute() + + user_info_response = ( + supabase.table("users").select("email, full_name").eq("id", user_id).single().execute() + ) + user_info = user_info_response.data if user_info_response.data else {} + + matching_result = await maybe_trigger_legacy_stable_matching( + target_city=group.get("target_city"), reason="join_request_accepted" + ) + + return { + "status": "success", + "message": f"Join request accepted. {user_info.get('full_name', 'User')} is now a member.", + "data": { + "group_id": group_id, + "user_id": user_id, + "user_name": user_info.get("full_name"), + "status": "accepted", + }, + "matching": matching_result, + } + + +@router.post("/{group_id}/reject-request/{user_id}", response_model=dict) +async def reject_join_request( + group_id: str, user_id: str, token: str = Depends(require_user_token) +): + """Reject a user's request to join the group (creator only).""" + supabase = get_admin_client() + current_user_id = resolve_auth_user_to_app_id(supabase, token) + + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data + if group["creator_user_id"] != current_user_id: + raise HTTPException(status_code=403, detail="Only the group creator can reject join requests") + + member_response = ( + supabase.table("group_members") + .select("*") + .eq("group_id", group_id) + .eq("user_id", user_id) + .eq("status", "pending") + .execute() + ) + if not member_response.data: + raise HTTPException(status_code=400, detail="No pending join request found for this user") + + user_info_response = ( + supabase.table("users").select("email, full_name").eq("id", user_id).single().execute() + ) + user_info = user_info_response.data if user_info_response.data else {} + + supabase.table("group_members").update({"status": "rejected"}).eq( + "group_id", group_id + ).eq("user_id", user_id).execute() + + return { + "status": "success", + "message": f"Join request from {user_info.get('full_name', 'user')} has been rejected.", + "data": { + "group_id": group_id, + "user_id": user_id, + "user_name": user_info.get("full_name"), + "status": "rejected", + }, + } + + +# --------------------------------------------------------------------------- +# Leave / Remove +# --------------------------------------------------------------------------- + +@router.delete("/{group_id}/leave", response_model=dict) +async def leave_group(group_id: str, token: str = Depends(require_user_token)): + """Leave a group. If creator leaves, ownership transfers or group is deleted.""" + supabase = get_admin_client() + user_id = resolve_auth_user_to_app_id(supabase, token) + + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data + + member_response = ( + supabase.table("group_members") + .select("*") + .eq("group_id", group_id) + .eq("user_id", user_id) + .execute() + ) + if not member_response.data: + raise HTTPException(status_code=400, detail="You are not a member of this group") + member = member_response.data[0] + + if member["is_creator"]: + other_members_response = ( + supabase.table("group_members") + .select("user_id") + .eq("group_id", group_id) + .eq("status", "accepted") + .neq("user_id", user_id) + .execute() + ) + other_members = other_members_response.data or [] + + if not other_members: + supabase.table("group_members").delete().eq("group_id", group_id).execute() + supabase.table("roommate_groups").delete().eq("id", group_id).execute() + return { + "status": "success", + "message": "Successfully left the group. The group was deleted as you were the only member.", + "data": {"group_id": group_id, "group_name": group["group_name"], "group_deleted": True}, + } + + new_creator = random.choice(other_members) + new_creator_id = new_creator["user_id"] + supabase.table("group_members").update({"is_creator": True}).eq( + "group_id", group_id + ).eq("user_id", new_creator_id).execute() + supabase.table("roommate_groups").update({"creator_user_id": new_creator_id}).eq( + "id", group_id + ).execute() + supabase.table("group_members").delete().eq("group_id", group_id).eq( + "user_id", user_id + ).execute() + + new_creator_user = ( + supabase.table("users") + .select("full_name, email") + .eq("id", new_creator_id) + .single() + .execute() + ) + new_creator_name = ( + (new_creator_user.data.get("full_name") or new_creator_user.data.get("email")) + if new_creator_user.data + else "another member" + ) + return { + "status": "success", + "message": f"Successfully left the group. Ownership transferred to {new_creator_name}.", + "data": { + "group_id": group_id, + "group_name": group["group_name"], + "ownership_transferred": True, + "new_creator_id": new_creator_id, + }, + } + + supabase.table("group_members").delete().eq("group_id", group_id).eq( + "user_id", user_id + ).execute() + + aggregation_result: dict = {"status": "skipped"} + try: + aggregation_result = aggregate_and_persist_group_preferences(group_id) + except Exception as e: + aggregation_result = {"status": "error", "message": str(e)} + + matching_result = await maybe_trigger_legacy_stable_matching( + target_city=group.get("target_city"), reason="member_left" + ) + + return { + "status": "success", + "message": "Successfully left the group", + "data": {"group_id": group_id, "group_name": group["group_name"]}, + "aggregation": aggregation_result, + "matching": matching_result, + } + + +@router.delete("/{group_id}/members/{member_user_id}", response_model=dict) +async def remove_member( + group_id: str, member_user_id: str, token: str = Depends(require_user_token) +): + """Remove a member from the group (creator only).""" + supabase = get_admin_client() + current_user_id = resolve_auth_user_to_app_id(supabase, token) + + group_response = ( + supabase.table("roommate_groups").select("*").eq("id", group_id).single().execute() + ) + if not group_response.data: + raise HTTPException(status_code=404, detail="Group not found") + group = group_response.data + if group["creator_user_id"] != current_user_id: + raise HTTPException(status_code=403, detail="Only the group creator can remove members") + + member_response = ( + supabase.table("group_members") + .select("*") + .eq("group_id", group_id) + .eq("user_id", member_user_id) + .execute() + ) + if not member_response.data: + raise HTTPException(status_code=400, detail="User is not a member of this group") + if member_response.data[0]["is_creator"]: + raise HTTPException(status_code=400, detail="Cannot remove the group creator") + + supabase.table("group_members").delete().eq("group_id", group_id).eq( + "user_id", member_user_id + ).execute() + + aggregation_result: dict = {"status": "skipped"} + try: + aggregation_result = aggregate_and_persist_group_preferences(group_id) + except Exception as e: + aggregation_result = {"status": "error", "message": str(e)} + + matching_result = await maybe_trigger_legacy_stable_matching( + target_city=group.get("target_city"), reason="member_removed" + ) + + return { + "status": "success", + "message": "Member removed successfully", + "aggregation": aggregation_result, + "matching": matching_result, + } diff --git a/backend/app/routes/interactions.py b/backend/app/routes/interactions.py deleted file mode 100644 index 1655b9b..0000000 --- a/backend/app/routes/interactions.py +++ /dev/null @@ -1,1425 +0,0 @@ -""" -Interactions routes - -Phase 1 endpoint(s) for swipe event capture from Discover. -""" - -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, Optional, Literal - -from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field - -from app.dependencies.auth import require_user_token, get_user_token, resolve_auth_user -from app.dependencies.supabase import get_admin_client -from app.services.behavior_features import ( - build_group_behavior_vector, - build_user_behavior_vector, - get_swipe_health_summary, -) -from app.services.listing_payloads import hydrate_listing_images - -router = APIRouter(prefix="/api/interactions", tags=["interactions"]) - - -class SwipeEventCreate(BaseModel): - listing_id: str - action: Literal["like", "pass", "super_like"] - group_id_at_time: Optional[str] = None - surface: str = Field(default="discover", min_length=1, max_length=100) - session_id: str = Field(..., min_length=1, max_length=128) - position_in_feed: int = Field(default=0, ge=0) - algorithm_version: str = Field(..., min_length=1, max_length=100) - model_version: Optional[str] = Field(default=None, max_length=100) - city_filter: Optional[str] = Field(default=None, max_length=100) - preference_snapshot_hash: Optional[str] = Field(default=None, max_length=128) - latency_ms: Optional[int] = Field(default=None, ge=0) - - -class RecommendationSessionCreate(BaseModel): - client_session_id: str = Field(..., min_length=1, max_length=128) - surface: Literal["matches", "discover"] = "matches" - recommendation_count_shown: int = Field(default=0, ge=0) - top_listing_ids_shown: list[str] = Field(default_factory=list) - algorithm_version: Optional[str] = Field(default=None, max_length=100) - model_version: Optional[str] = Field(default=None, max_length=100) - experiment_name: Optional[str] = Field(default=None, max_length=100) - experiment_variant: Optional[str] = Field(default=None, max_length=100) - - -class RecommendationSessionUpdate(BaseModel): - recommendation_count_shown: Optional[int] = Field(default=None, ge=0) - top_listing_ids_shown: Optional[list[str]] = None - detail_opens_count: Optional[int] = Field(default=None, ge=0) - saves_count: Optional[int] = Field(default=None, ge=0) - likes_count: Optional[int] = Field(default=None, ge=0) - surface_dwell_ms: Optional[int] = Field(default=None, ge=0) - detail_dwell_ms: Optional[int] = Field(default=None, ge=0) - prompt_presented: bool = False - prompt_dismissed: bool = False - mark_ended: bool = False - algorithm_version: Optional[str] = Field(default=None, max_length=100) - model_version: Optional[str] = Field(default=None, max_length=100) - experiment_name: Optional[str] = Field(default=None, max_length=100) - experiment_variant: Optional[str] = Field(default=None, max_length=100) - - -class RecommendationFeedbackCreate(BaseModel): - recommendation_session_id: str = Field(..., min_length=1, max_length=128) - feedback_label: Literal["not_useful", "somewhat_useful", "very_useful"] - reason_label: Optional[ - Literal["too_expensive", "wrong_location", "not_my_style", "too_few_good_options", "other"] - ] = None - - -class RecommendationEngagementEventCreate(BaseModel): - recommendation_session_id: str = Field(..., min_length=1, max_length=128) - client_event_id: str = Field(..., min_length=1, max_length=128) - surface: Literal["matches", "discover"] = "matches" - event_type: Literal["detail_open", "detail_view", "save", "unsave"] - listing_id: Optional[str] = Field(default=None, max_length=128) - position_in_feed: Optional[int] = Field(default=None, ge=0) - dwell_ms: Optional[int] = Field(default=None, ge=0) - metadata: dict = Field(default_factory=dict) - - -class InterestedListingCreate(BaseModel): - source: Optional[str] = Field(default=None, max_length=100) - - -def _resolve_current_user_id(token: str) -> str: - """ - Resolve authenticated user to internal users.id UUID. - """ - supabase = get_admin_client() - auth_user = resolve_auth_user(supabase, token) - auth_user_id = auth_user.id - - # Preferred mapping: users.auth_id == auth user id. - user_record = supabase.table("users").select("id").eq("auth_id", auth_user_id).limit(1).execute() - if user_record.data: - return user_record.data[0]["id"] - - # Fallback for legacy rows where users.id may equal auth user id. - fallback_record = supabase.table("users").select("id").eq("id", auth_user_id).limit(1).execute() - if fallback_record.data: - return fallback_record.data[0]["id"] - - raise HTTPException(status_code=404, detail="User profile not found") - - -def _now_utc() -> datetime: - return datetime.now(timezone.utc) - - -def _normalize_listing_ids(listing_ids: Optional[list[str]]) -> list[str]: - cleaned: list[str] = [] - for listing_id in listing_ids or []: - value = str(listing_id).strip() - if value: - cleaned.append(value) - return cleaned[:100] - - -def _recommendation_storage_missing(exc: Exception) -> bool: - err = str(exc).lower() - return ( - "recommendation_sessions" in err and "does not exist" in err - ) or ( - "user_recommendation_feedback" in err and "does not exist" in err - ) or ( - "recommendation_engagement_events" in err and "does not exist" in err - ) - - -def _interested_storage_missing(exc: Exception) -> bool: - err = str(exc).lower() - return "user_interested_listings" in err and "does not exist" in err - - -def _get_recommendation_session(supabase, session_id: str, user_id: str) -> dict: - session_response = ( - supabase.table("recommendation_sessions") - .select("*") - .eq("id", session_id) - .eq("actor_user_id", user_id) - .limit(1) - .execute() - ) - if not session_response.data: - raise HTTPException(status_code=404, detail="Recommendation session not found") - return session_response.data[0] - - -def _recommendation_prompt_allowed( - supabase, - user_id: str, - current_session_id: Optional[str] = None, - surface: Optional[str] = None, -) -> bool: - if surface in {"discover", "matches"}: - return True - - cooldown_since = (_now_utc() - timedelta(days=7)).isoformat() - recent = ( - supabase.table("recommendation_sessions") - .select("id, prompt_presented_at, prompt_dismissed_at, feedback_submitted_at") - .eq("actor_user_id", user_id) - .gte("started_at", cooldown_since) - .order("started_at", desc=True) - .limit(50) - .execute() - ) - - for row in recent.data or []: - if current_session_id and row.get("id") == current_session_id: - continue - if row.get("prompt_presented_at") or row.get("prompt_dismissed_at") or row.get("feedback_submitted_at"): - return False - return True - - -def _build_session_response(supabase, session_row: dict) -> dict: - session_completed = bool( - session_row.get("feedback_submitted_at") - or session_row.get("prompt_dismissed_at") - or session_row.get("prompt_presented_at") - ) - return { - "status": "success", - "data": session_row, - "prompt_allowed": (not session_completed) - and _recommendation_prompt_allowed( - supabase, - user_id=session_row["actor_user_id"], - current_session_id=session_row["id"], - surface=session_row.get("surface"), - ), - } - - -def _update_session_aggregate_max(update_data: dict, field: str, current_value: Optional[int], next_value: int) -> None: - update_data[field] = max(int(current_value or 0), int(next_value)) - - -def _summarize_recommendation_events(events: list[dict], session_row: dict) -> dict: - summary = { - "detail_open_events": 0, - "save_events": 0, - "unsave_events": 0, - "detail_view_events": 0, - "detail_view_dwell_ms": 0, - "avg_detail_view_dwell_ms": 0, - "surface_dwell_ms": int(session_row.get("surface_dwell_ms") or 0), - "detail_dwell_ms": int(session_row.get("detail_dwell_ms") or 0), - "detail_open_rate": 0.0, - "save_rate": 0.0, - "detail_opens_by_position": {}, - "saves_by_position": {}, - } - - detail_view_count = 0 - recommendation_count = max(int(session_row.get("recommendation_count_shown") or 0), 1) - - for event in events: - event_type = event.get("event_type") - position = event.get("position_in_feed") - position_key = str(position) if position is not None else None - - if event_type == "detail_open": - summary["detail_open_events"] += 1 - if position_key is not None: - summary["detail_opens_by_position"][position_key] = ( - summary["detail_opens_by_position"].get(position_key, 0) + 1 - ) - elif event_type == "save": - summary["save_events"] += 1 - if position_key is not None: - summary["saves_by_position"][position_key] = ( - summary["saves_by_position"].get(position_key, 0) + 1 - ) - elif event_type == "unsave": - summary["unsave_events"] += 1 - elif event_type == "detail_view": - detail_view_count += 1 - summary["detail_view_events"] += 1 - summary["detail_view_dwell_ms"] += int(event.get("dwell_ms") or 0) - - if detail_view_count > 0: - summary["avg_detail_view_dwell_ms"] = round(summary["detail_view_dwell_ms"] / detail_view_count, 2) - - summary["detail_open_rate"] = round(summary["detail_open_events"] / recommendation_count, 4) - summary["save_rate"] = round(summary["save_events"] / recommendation_count, 4) - return summary - - -def _require_group_membership(group_id: str, user_id: str) -> None: - """ - Require that user is an accepted member of the target group. - """ - supabase = get_admin_client() - membership = ( - supabase.table("group_members") - .select("group_id") - .eq("group_id", group_id) - .eq("user_id", user_id) - .eq("status", "accepted") - .limit(1) - .execute() - ) - if not membership.data: - raise HTTPException(status_code=403, detail="You are not a member of this group") - - -@router.post("/swipes") -async def create_swipe_event( - payload: SwipeEventCreate, - token: str = Depends(require_user_token), -): - """ - Persist a single swipe event from Discover. - - This endpoint is idempotent per: - actor_user_id + listing_id + session_id + position_in_feed - """ - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - - try: - existing = ( - supabase.table("swipe_interactions") - .select("event_id") - .eq("actor_user_id", user_id) - .eq("listing_id", payload.listing_id) - .eq("session_id", payload.session_id) - .eq("position_in_feed", payload.position_in_feed) - .limit(1) - .execute() - ) - if existing.data: - return { - "status": "success", - "duplicate_ignored": True, - "event_id": existing.data[0]["event_id"], - } - - insert_data = { - "actor_type": "user", - "actor_user_id": user_id, - "group_id_at_time": payload.group_id_at_time, - "listing_id": payload.listing_id, - "action": payload.action, - "surface": payload.surface, - "session_id": payload.session_id, - "position_in_feed": payload.position_in_feed, - "algorithm_version": payload.algorithm_version, - "model_version": payload.model_version, - "city_filter": payload.city_filter, - "preference_snapshot_hash": payload.preference_snapshot_hash, - "latency_ms": payload.latency_ms, - } - - created = supabase.table("swipe_interactions").insert(insert_data).execute() - if not created.data: - raise HTTPException(status_code=500, detail="Swipe event was not persisted") - - return { - "status": "success", - "duplicate_ignored": False, - "event_id": created.data[0]["event_id"], - } - except HTTPException: - raise - except Exception as e: - err = str(e).lower() - if "swipe_interactions" in err and "does not exist" in err: - raise HTTPException( - status_code=503, - detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.", - ) - if "duplicate key value violates unique constraint" in err: - # Edge race between duplicate check and insert. - existing = ( - supabase.table("swipe_interactions") - .select("event_id") - .eq("actor_user_id", user_id) - .eq("listing_id", payload.listing_id) - .eq("session_id", payload.session_id) - .eq("position_in_feed", payload.position_in_feed) - .limit(1) - .execute() - ) - return { - "status": "success", - "duplicate_ignored": True, - "event_id": existing.data[0]["event_id"] if existing.data else None, - } - raise HTTPException(status_code=500, detail=f"Failed to store swipe event: {e}") - - -@router.post("/recommendation-sessions") -async def create_recommendation_session( - payload: RecommendationSessionCreate, - token: str = Depends(require_user_token), -): - """ - Create or refresh a recommendation session for the current user. - - Sessions are idempotent per actor_user_id + client_session_id so refetches on the - same page visit update the same row instead of creating duplicates. - """ - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - now_iso = _now_utc().isoformat() - top_listing_ids = _normalize_listing_ids(payload.top_listing_ids_shown) - - try: - existing = ( - supabase.table("recommendation_sessions") - .select("*") - .eq("actor_user_id", user_id) - .eq("client_session_id", payload.client_session_id) - .limit(1) - .execute() - ) - - if existing.data: - session = existing.data[0] - update_data = { - "updated_at": now_iso, - "surface": payload.surface, - "recommendation_count_shown": payload.recommendation_count_shown, - "top_listing_ids_shown": top_listing_ids, - "algorithm_version": payload.algorithm_version, - "model_version": payload.model_version, - "experiment_name": payload.experiment_name, - "experiment_variant": payload.experiment_variant, - } - updated = ( - supabase.table("recommendation_sessions") - .update(update_data) - .eq("id", session["id"]) - .execute() - ) - session = updated.data[0] if updated.data else {**session, **update_data} - return _build_session_response(supabase, session) - - created = ( - supabase.table("recommendation_sessions") - .insert( - { - "actor_user_id": user_id, - "client_session_id": payload.client_session_id, - "surface": payload.surface, - "started_at": now_iso, - "recommendation_count_shown": payload.recommendation_count_shown, - "top_listing_ids_shown": top_listing_ids, - "algorithm_version": payload.algorithm_version, - "model_version": payload.model_version, - "experiment_name": payload.experiment_name, - "experiment_variant": payload.experiment_variant, - "updated_at": now_iso, - } - ) - .execute() - ) - if not created.data: - raise HTTPException(status_code=500, detail="Recommendation session was not persisted") - return _build_session_response(supabase, created.data[0]) - except HTTPException: - raise - except Exception as e: - if _recommendation_storage_missing(e): - raise HTTPException( - status_code=503, - detail="Recommendation feedback storage not configured. Run migration 20260406010000_recommendation_feedback_phase2.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to create recommendation session: {e}") - - -@router.patch("/recommendation-sessions/{session_id}") -async def update_recommendation_session( - session_id: str, - payload: RecommendationSessionUpdate, - token: str = Depends(require_user_token), -): - """ - Update counters and prompt lifecycle fields for an existing recommendation session. - """ - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - now_iso = _now_utc().isoformat() - - try: - session = _get_recommendation_session(supabase, session_id=session_id, user_id=user_id) - update_data = {"updated_at": now_iso} - - if payload.recommendation_count_shown is not None: - update_data["recommendation_count_shown"] = payload.recommendation_count_shown - if payload.top_listing_ids_shown is not None: - update_data["top_listing_ids_shown"] = _normalize_listing_ids(payload.top_listing_ids_shown) - if payload.detail_opens_count is not None: - update_data["detail_opens_count"] = max( - int(session.get("detail_opens_count") or 0), - payload.detail_opens_count, - ) - if payload.saves_count is not None: - update_data["saves_count"] = max( - int(session.get("saves_count") or 0), - payload.saves_count, - ) - if payload.likes_count is not None: - update_data["likes_count"] = max( - int(session.get("likes_count") or 0), - payload.likes_count, - ) - if payload.surface_dwell_ms is not None: - _update_session_aggregate_max( - update_data, - "surface_dwell_ms", - session.get("surface_dwell_ms"), - payload.surface_dwell_ms, - ) - if payload.detail_dwell_ms is not None: - _update_session_aggregate_max( - update_data, - "detail_dwell_ms", - session.get("detail_dwell_ms"), - payload.detail_dwell_ms, - ) - if payload.prompt_presented and not session.get("prompt_presented_at"): - update_data["prompt_presented_at"] = now_iso - if payload.prompt_dismissed and not session.get("prompt_dismissed_at"): - update_data["prompt_dismissed_at"] = now_iso - if payload.mark_ended: - update_data["ended_at"] = now_iso - if payload.algorithm_version is not None: - update_data["algorithm_version"] = payload.algorithm_version - if payload.model_version is not None: - update_data["model_version"] = payload.model_version - if payload.experiment_name is not None: - update_data["experiment_name"] = payload.experiment_name - if payload.experiment_variant is not None: - update_data["experiment_variant"] = payload.experiment_variant - - updated = ( - supabase.table("recommendation_sessions") - .update(update_data) - .eq("id", session_id) - .eq("actor_user_id", user_id) - .execute() - ) - if not updated.data: - raise HTTPException(status_code=500, detail="Recommendation session update failed") - return {"status": "success", "data": updated.data[0]} - except HTTPException: - raise - except Exception as e: - if _recommendation_storage_missing(e): - raise HTTPException( - status_code=503, - detail="Recommendation feedback storage not configured. Run migration 20260406010000_recommendation_feedback_phase2.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to update recommendation session: {e}") - - -@router.post("/recommendation-events") -async def create_recommendation_engagement_event( - payload: RecommendationEngagementEventCreate, - token: str = Depends(require_user_token), -): - """ - Persist a passive engagement event tied to a recommendation session. - """ - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - now_iso = _now_utc().isoformat() - - try: - session = _get_recommendation_session( - supabase, - session_id=payload.recommendation_session_id, - user_id=user_id, - ) - - existing = ( - supabase.table("recommendation_engagement_events") - .select("event_id") - .eq("actor_user_id", user_id) - .eq("client_event_id", payload.client_event_id) - .limit(1) - .execute() - ) - if existing.data: - return { - "status": "success", - "duplicate_ignored": True, - "event_id": existing.data[0]["event_id"], - } - - created = ( - supabase.table("recommendation_engagement_events") - .insert( - { - "actor_user_id": user_id, - "recommendation_session_id": session["id"], - "client_event_id": payload.client_event_id, - "surface": payload.surface, - "event_type": payload.event_type, - "listing_id": payload.listing_id, - "position_in_feed": payload.position_in_feed, - "dwell_ms": payload.dwell_ms, - "metadata": payload.metadata or {}, - "created_at": now_iso, - } - ) - .execute() - ) - if not created.data: - raise HTTPException(status_code=500, detail="Recommendation engagement event was not persisted") - - session_update: dict = {"updated_at": now_iso} - if payload.event_type == "detail_open": - session_update["detail_opens_count"] = int(session.get("detail_opens_count") or 0) + 1 - elif payload.event_type == "save": - session_update["saves_count"] = int(session.get("saves_count") or 0) + 1 - elif payload.event_type == "detail_view" and payload.dwell_ms is not None: - session_update["detail_dwell_ms"] = int(session.get("detail_dwell_ms") or 0) + int(payload.dwell_ms) - - if len(session_update) > 1: - supabase.table("recommendation_sessions").update(session_update).eq( - "id", session["id"] - ).eq("actor_user_id", user_id).execute() - - return { - "status": "success", - "duplicate_ignored": False, - "event_id": created.data[0]["event_id"], - } - except HTTPException: - raise - except Exception as e: - if _recommendation_storage_missing(e): - raise HTTPException( - status_code=503, - detail="Recommendation passive metrics storage not configured. Run migration 20260406020000_recommendation_passive_metrics_phase3.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to store recommendation engagement event: {e}") - - -@router.get("/recommendation-sessions/{session_id}/passive-summary") -async def get_recommendation_passive_summary( - session_id: str, - token: str = Depends(require_user_token), -): - """ - Return passive engagement metrics for one recommendation session. - """ - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - - try: - session = _get_recommendation_session(supabase, session_id=session_id, user_id=user_id) - events_response = ( - supabase.table("recommendation_engagement_events") - .select("event_type, position_in_feed, dwell_ms, listing_id, created_at") - .eq("recommendation_session_id", session["id"]) - .eq("actor_user_id", user_id) - .order("created_at", desc=False) - .execute() - ) - events = events_response.data or [] - summary = _summarize_recommendation_events(events, session) - return { - "status": "success", - "data": { - "session_id": session["id"], - "surface": session.get("surface"), - "recommendation_count_shown": int(session.get("recommendation_count_shown") or 0), - "detail_opens_count": int(session.get("detail_opens_count") or 0), - "saves_count": int(session.get("saves_count") or 0), - "likes_count": int(session.get("likes_count") or 0), - **summary, - }, - } - except HTTPException: - raise - except Exception as e: - if _recommendation_storage_missing(e): - raise HTTPException( - status_code=503, - detail="Recommendation passive metrics storage not configured. Run migration 20260406020000_recommendation_passive_metrics_phase3.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to fetch recommendation passive summary: {e}") - - -@router.post("/recommendation-feedback") -async def create_recommendation_feedback( - payload: RecommendationFeedbackCreate, - token: str = Depends(require_user_token), -): - """ - Persist a single session-level usefulness response for the current user. - """ - if payload.reason_label and payload.feedback_label != "not_useful": - raise HTTPException(status_code=422, detail="A negative reason can only be attached to 'not_useful' feedback") - - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - now_iso = _now_utc().isoformat() - - try: - session = _get_recommendation_session( - supabase, - session_id=payload.recommendation_session_id, - user_id=user_id, - ) - swipe_count = None - if session.get("client_session_id"): - try: - swipe_count = ( - supabase.table("swipe_interactions") - .select("event_id", count="exact") - .eq("actor_user_id", user_id) - .eq("session_id", session["client_session_id"]) - .execute() - .count - ) - except Exception: - swipe_count = None - existing = ( - supabase.table("user_recommendation_feedback") - .select("*") - .eq("recommendation_session_id", session["id"]) - .limit(1) - .execute() - ) - if existing.data: - return {"status": "success", "duplicate_ignored": True, "data": existing.data[0]} - - created = ( - supabase.table("user_recommendation_feedback") - .insert( - { - "actor_user_id": user_id, - "recommendation_session_id": session["id"], - "surface": session["surface"], - "feedback_label": payload.feedback_label, - "reason_label": payload.reason_label, - "submitted_at": now_iso, - "algorithm_version": session.get("algorithm_version"), - "model_version": session.get("model_version"), - "experiment_name": session.get("experiment_name"), - "experiment_variant": session.get("experiment_variant"), - "swipes_in_session": int(swipe_count) if swipe_count is not None else None, - "likes_in_session": int(session.get("likes_count") or 0), - "saves_in_session": int(session.get("saves_count") or 0), - "detail_opens_in_session": int(session.get("detail_opens_count") or 0), - "recommendation_count_shown": int(session.get("recommendation_count_shown") or 0), - "top_listing_ids_shown": session.get("top_listing_ids_shown") or [], - } - ) - .execute() - ) - if not created.data: - raise HTTPException(status_code=500, detail="Recommendation feedback was not persisted") - - supabase.table("recommendation_sessions").update( - { - "feedback_submitted_at": now_iso, - "ended_at": session.get("ended_at") or now_iso, - "updated_at": now_iso, - } - ).eq("id", session["id"]).eq("actor_user_id", user_id).execute() - - return {"status": "success", "duplicate_ignored": False, "data": created.data[0]} - except HTTPException: - raise - except Exception as e: - if _recommendation_storage_missing(e): - raise HTTPException( - status_code=503, - detail="Recommendation feedback storage not configured. Run migration 20260406010000_recommendation_feedback_phase2.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to store recommendation feedback: {e}") - - -@router.get("/swipes/me") -async def get_my_swipe_events( - limit: int = Query(50, ge=1, le=500), - token: str = Depends(require_user_token), -): - """ - Return recent swipe events for the current user (debug/inspection endpoint). - """ - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - - try: - response = ( - supabase.table("swipe_interactions") - .select("*") - .eq("actor_user_id", user_id) - .order("created_at", desc=True) - .limit(limit) - .execute() - ) - events = response.data or [] - return {"status": "success", "count": len(events), "data": events} - except Exception as e: - err = str(e).lower() - if "swipe_interactions" in err and "does not exist" in err: - raise HTTPException( - status_code=503, - detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to fetch swipe events: {e}") - - -@router.get("/behavior/me") -async def get_my_behavior_vector( - days: int = Query(180, ge=7, le=365), - max_events: int = Query(2000, ge=100, le=10000), - token: str = Depends(require_user_token), -): - """ - Return Phase 2A behavior vector for the authenticated user. - """ - user_id = _resolve_current_user_id(token) - try: - vector = build_user_behavior_vector(user_id=user_id, days=days, max_events=max_events) - return {"status": "success", "data": vector} - except Exception as e: - err = str(e).lower() - if "swipe_interactions" in err and "does not exist" in err: - raise HTTPException( - status_code=503, - detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to build user behavior vector: {e}") - - -@router.get("/behavior/groups/{group_id}") -async def get_group_behavior( - group_id: str, - days: int = Query(180, ge=7, le=365), - max_events_per_user: int = Query(2000, ge=100, le=10000), - token: str = Depends(require_user_token), -): - """ - Return Phase 2A behavior vector for a group. - Access is limited to accepted group members. - """ - user_id = _resolve_current_user_id(token) - _require_group_membership(group_id=group_id, user_id=user_id) - try: - vector = build_group_behavior_vector( - group_id=group_id, - days=days, - max_events_per_user=max_events_per_user, - ) - return {"status": "success", "data": vector} - except Exception as e: - err = str(e).lower() - if "swipe_interactions" in err and "does not exist" in err: - raise HTTPException( - status_code=503, - detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to build group behavior vector: {e}") - - -@router.get("/swipes/groups/{group_id}/liked") -async def get_group_liked_listings( - group_id: str, - limit: int = Query(100, ge=1, le=500), - action: Optional[str] = Query(None, description="Filter by action: 'like', 'group_save', or omit for both"), - token: str = Depends(require_user_token), -): - """ - Return listings interacted with by any accepted member of the group. - Pass ?action=group_save to see only group-saved listings. - Pass ?action=like to see only individually liked listings. - Omit action to see both. - Each item includes listing data plus a list of member names who saved/liked it. - """ - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - _require_group_membership(group_id=group_id, user_id=user_id) - - try: - # Get all accepted member user IDs - members_resp = ( - supabase.table("group_members") - .select("user_id") - .eq("group_id", group_id) - .eq("status", "accepted") - .execute() - ) - members = members_resp.data or [] - if not members: - return {"status": "success", "data": []} - - member_ids = [m["user_id"] for m in members] - - # Fetch user details separately - users_resp = ( - supabase.table("users") - .select("id, full_name, email") - .in_("id", member_ids) - .execute() - ) - member_map = { - u["id"]: u.get("full_name") or u.get("email", "Member") - for u in (users_resp.data or []) - } - - # Determine which actions to fetch based on the optional filter param. - if action == "group_save": - actions_to_fetch = ["group_save"] - elif action == "like": - actions_to_fetch = ["like"] - else: - actions_to_fetch = ["like", "group_save"] - - swipes_resp = ( - supabase.table("swipe_interactions") - .select("listing_id, actor_user_id, action, group_id_at_time") - .in_("actor_user_id", member_ids) - .in_("action", actions_to_fetch) - .order("created_at", desc=True) - .limit(limit) - .execute() - ) - raw_swipes = swipes_resp.data or [] - # group_save rows must belong to this specific group; likes are group-agnostic. - swipes = [] - for s in raw_swipes: - if s.get("action") == "like": - swipes.append(s) - elif s.get("action") == "group_save" and s.get("group_id_at_time") == group_id: - swipes.append(s) - if not swipes: - return {"status": "success", "data": []} - - # Build mapping: listing_id -> list of member names who liked it - liked_by: dict = {} - for s in swipes: - lid = s["listing_id"] - name = member_map.get(s["actor_user_id"], "Member") - liked_by.setdefault(lid, []) - if name not in liked_by[lid]: - liked_by[lid].append(name) - - unique_listing_ids = list(liked_by.keys()) - - # Fetch listing details - listings_resp = ( - supabase.table("listings") - .select("*") - .in_("id", unique_listing_ids) - .execute() - ) - listings = listings_resp.data or [] - listing_map = {str(l["id"]): l for l in listings} - - result = [] - for lid in unique_listing_ids: - listing = listing_map.get(str(lid)) - if listing: - result.append({**listing, "liked_by": liked_by[lid]}) - - return {"status": "success", "data": result} - - except Exception as e: - err = str(e).lower() - if "swipe_interactions" in err and "does not exist" in err: - raise HTTPException( - status_code=503, - detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to fetch group liked listings: {e}") - - -@router.post("/swipes/groups/{group_id}/save/{listing_id}") -async def save_listing_to_group( - group_id: str, - listing_id: str, - token: str = Depends(require_user_token), -): - """Save a listing to the group (star/bookmark action from Discover).""" - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - _require_group_membership(group_id=group_id, user_id=user_id) - - try: - # Delete any existing group_save for this user+listing+group, then insert fresh - supabase.table("swipe_interactions").delete().eq( - "actor_user_id", user_id - ).eq("listing_id", listing_id).eq("action", "group_save").eq( - "group_id_at_time", group_id - ).execute() - - supabase.table("swipe_interactions").insert({ - "actor_user_id": user_id, - "listing_id": listing_id, - "action": "group_save", - "group_id_at_time": group_id, - "surface": "matches", - "session_id": f"group-save-{user_id}-{group_id}-{listing_id}", - "algorithm_version": "group-save-v1", - "position_in_feed": 0, - }).execute() - return {"status": "success"} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to save listing: {e}") - - -@router.delete("/swipes/groups/{group_id}/save/{listing_id}") -async def unsave_listing_from_group( - group_id: str, - listing_id: str, - token: str = Depends(require_user_token), -): - """Remove a group-saved listing.""" - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - _require_group_membership(group_id=group_id, user_id=user_id) - - try: - supabase.table("swipe_interactions").delete().eq( - "actor_user_id", user_id - ).eq("listing_id", listing_id).eq("action", "group_save").execute() - return {"status": "success"} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to unsave listing: {e}") - - -@router.get("/swipes/groups/{group_id}/saved") -async def get_my_saved_listings_for_group( - group_id: str, - token: str = Depends(require_user_token), -): - """Return listing IDs the current user has starred for this group.""" - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - _require_group_membership(group_id=group_id, user_id=user_id) - - try: - resp = ( - supabase.table("swipe_interactions") - .select("listing_id") - .eq("actor_user_id", user_id) - .eq("group_id_at_time", group_id) - .eq("action", "group_save") - .execute() - ) - ids = [r["listing_id"] for r in (resp.data or [])] - return {"status": "success", "saved_listing_ids": ids} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to fetch saved listings: {e}") - - -@router.get("/interested-listings") -async def get_my_interested_listings(token: str = Depends(require_user_token)): - """Return the current user's interested listings with listing payloads.""" - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - - try: - rows_resp = ( - supabase.table("user_interested_listings") - .select("listing_id, source, created_at") - .eq("actor_user_id", user_id) - .order("created_at", desc=True) - .execute() - ) - rows = rows_resp.data or [] - if not rows: - return {"status": "success", "data": []} - - listing_ids = [] - seen = set() - for row in rows: - listing_id = str(row.get("listing_id") or "").strip() - if listing_id and listing_id not in seen: - seen.add(listing_id) - listing_ids.append(listing_id) - - listings_resp = ( - supabase.table("listings") - .select("*,listing_photos(photo_url,sort_order)") - .in_("id", listing_ids) - .execute() - ) - listing_map = { - str(item["id"]): hydrate_listing_images(item) - for item in (listings_resp.data or []) - } - - data = [] - for row in rows: - listing_id = str(row.get("listing_id") or "") - listing = listing_map.get(listing_id) - if listing: - data.append( - { - "interested_at": row.get("created_at"), - "interest_source": row.get("source"), - **listing, - } - ) - - return {"status": "success", "data": data} - except Exception as e: - if _interested_storage_missing(e): - raise HTTPException( - status_code=503, - detail="Interested listing storage not configured. Run migration 20260406030000_user_interested_listings.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to fetch interested listings: {e}") - - -@router.get("/interested-listings/ids") -async def get_my_interested_listing_ids(token: str = Depends(require_user_token)): - """Return listing IDs the current user marked as interested.""" - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - - try: - rows_resp = ( - supabase.table("user_interested_listings") - .select("listing_id") - .eq("actor_user_id", user_id) - .execute() - ) - ids = [row["listing_id"] for row in (rows_resp.data or []) if row.get("listing_id")] - return {"status": "success", "interested_listing_ids": ids} - except Exception as e: - if _interested_storage_missing(e): - raise HTTPException( - status_code=503, - detail="Interested listing storage not configured. Run migration 20260406030000_user_interested_listings.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to fetch interested listing ids: {e}") - - -@router.post("/interested-listings/{listing_id}") -async def mark_listing_interested( - listing_id: str, - payload: InterestedListingCreate, - token: str = Depends(require_user_token), -): - """Mark a listing as personally interesting for the current user.""" - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - - try: - existing_listing = ( - supabase.table("listings") - .select("id") - .eq("id", listing_id) - .limit(1) - .execute() - ) - if not existing_listing.data: - raise HTTPException(status_code=404, detail="Listing not found") - - existing_interested = ( - supabase.table("user_interested_listings") - .select("id") - .eq("actor_user_id", user_id) - .eq("listing_id", listing_id) - .limit(1) - .execute() - ) - if existing_interested.data: - return {"status": "success", "listing_id": listing_id, "already_interested": True} - - supabase.table("user_interested_listings").insert( - { - "actor_user_id": user_id, - "listing_id": listing_id, - "source": payload.source, - } - ).execute() - - return {"status": "success", "listing_id": listing_id, "already_interested": False} - except HTTPException: - raise - except Exception as e: - if _interested_storage_missing(e): - raise HTTPException( - status_code=503, - detail="Interested listing storage not configured. Run migration 20260406030000_user_interested_listings.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to mark listing interested: {e}") - - -@router.delete("/interested-listings/{listing_id}") -async def unmark_listing_interested( - listing_id: str, - token: str = Depends(require_user_token), -): - """Remove a listing from the current user's interested list.""" - supabase = get_admin_client() - user_id = _resolve_current_user_id(token) - - try: - supabase.table("user_interested_listings").delete().eq( - "actor_user_id", user_id - ).eq("listing_id", listing_id).execute() - return {"status": "success", "listing_id": listing_id} - except Exception as e: - if _interested_storage_missing(e): - raise HTTPException( - status_code=503, - detail="Interested listing storage not configured. Run migration 20260406030000_user_interested_listings.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to unmark listing interested: {e}") - - -# --------------------------------------------------------------------------- -# Data logging endpoints (analytics / AI training) -# All four are best-effort: failures are returned as 500 but callers should -# fire-and-forget these. They write to new tables and never touch existing ones. -# --------------------------------------------------------------------------- - -class SwipeContextEventCreate(BaseModel): - listing_id: str - action: Literal["like", "pass", "super_like", "group_save"] - session_id: str = Field(..., min_length=1, max_length=128) - active_filters_snapshot: Optional[Dict[str, Any]] = None - device_context: Optional[Dict[str, Any]] = None - - -class ListingViewEventCreate(BaseModel): - listing_id: str - surface: Literal["discover_card", "listing_detail", "matches_card"] - session_id: str = Field(..., min_length=1, max_length=128) - view_duration_ms: Optional[int] = Field(default=None, ge=0) - expanded: bool = False - photos_viewed_count: int = Field(default=0, ge=0) - - -class PageViewEventCreate(BaseModel): - page: Literal[ - "discover", "matches", "listing_detail", "preferences", - "account", "groups", "roommates", "onboarding" - ] - session_id: str = Field(..., min_length=1, max_length=128) - duration_ms: Optional[int] = Field(default=None, ge=0) - referrer_page: Optional[str] = Field(default=None, max_length=100) - - -class SearchQueryEventCreate(BaseModel): - session_id: str = Field(..., min_length=1, max_length=128) - filter_snapshot: Dict[str, Any] - results_returned: int = Field(default=0, ge=0) - offset: int = Field(default=0, ge=0) - - -@router.post("/swipe-context") -async def create_swipe_context_event( - payload: SwipeContextEventCreate, - token: Optional[str] = Depends(get_user_token), -): - """ - Persist filter + device context alongside a swipe. - - Companion to POST /swipes — writes to swipe_context_events without - touching the existing swipe_interactions table. Join the two tables on - (actor_user_id, listing_id, session_id) to get the full swipe picture. - Uses optional auth — silently no-ops when there is no valid session. - """ - if not token: - return {"status": "skipped"} - supabase = get_admin_client() - try: - user_id = _resolve_current_user_id(token) - except HTTPException: - return {"status": "skipped"} - - try: - insert_data = { - "actor_user_id": user_id, - "listing_id": payload.listing_id, - "session_id": payload.session_id, - "action": payload.action, - "active_filters_snapshot": payload.active_filters_snapshot, - "device_context": payload.device_context, - } - created = supabase.table("swipe_context_events").insert(insert_data).execute() - if not created.data: - raise HTTPException(status_code=500, detail="Swipe context event was not persisted") - return {"status": "success", "event_id": created.data[0]["event_id"]} - except HTTPException: - raise - except Exception as e: - err = str(e).lower() - if "swipe_context_events" in err and "does not exist" in err: - raise HTTPException( - status_code=503, - detail="swipe_context_events table not found. Run migration 012.", - ) - raise HTTPException(status_code=500, detail=f"Failed to store swipe context: {e}") - - -@router.post("/listing-views") -async def create_listing_view_event( - payload: ListingViewEventCreate, - token: Optional[str] = Depends(get_user_token), -): - """ - Persist a listing view duration event. - - Fire on card mount (discover stack, matches grid) and on listing detail - page unmount. Best-effort — callers should not block on this response. - Uses optional auth — silently no-ops when there is no valid session. - """ - if not token: - return {"status": "skipped"} - supabase = get_admin_client() - try: - user_id = _resolve_current_user_id(token) - except HTTPException: - return {"status": "skipped"} - - try: - insert_data = { - "user_id": user_id, - "listing_id": payload.listing_id, - "surface": payload.surface, - "session_id": payload.session_id, - "view_duration_ms": payload.view_duration_ms, - "expanded": payload.expanded, - "photos_viewed_count": payload.photos_viewed_count, - } - created = supabase.table("listing_view_events").insert(insert_data).execute() - if not created.data: - raise HTTPException(status_code=500, detail="Listing view event was not persisted") - return {"status": "success", "event_id": created.data[0]["event_id"]} - except HTTPException: - raise - except Exception as e: - err = str(e).lower() - if "listing_view_events" in err and "does not exist" in err: - raise HTTPException( - status_code=503, - detail="listing_view_events table not found. Run migration 014.", - ) - raise HTTPException(status_code=500, detail=f"Failed to store listing view event: {e}") - - -@router.post("/page-views") -async def create_page_view_event( - payload: PageViewEventCreate, - token: Optional[str] = Depends(get_user_token), -): - """ - Persist a page view event for funnel analytics. - - Fire on page component mount; send duration_ms on unmount via keepalive fetch. - Uses optional auth — silently no-ops when the user has no valid session so - that analytics never block or error during the pre-setup / sign-in flow. - """ - if not token: - return {"status": "skipped"} - supabase = get_admin_client() - try: - user_id = _resolve_current_user_id(token) - except HTTPException: - return {"status": "skipped"} - - try: - insert_data = { - "user_id": user_id, - "page": payload.page, - "session_id": payload.session_id, - "duration_ms": payload.duration_ms, - "referrer_page": payload.referrer_page, - } - created = supabase.table("page_view_events").insert(insert_data).execute() - if not created.data: - raise HTTPException(status_code=500, detail="Page view event was not persisted") - return {"status": "success", "event_id": created.data[0]["event_id"]} - except HTTPException: - raise - except Exception as e: - err = str(e).lower() - if "page_view_events" in err and "does not exist" in err: - raise HTTPException( - status_code=503, - detail="page_view_events table not found. Run migration 016.", - ) - raise HTTPException(status_code=500, detail=f"Failed to store page view event: {e}") - - -@router.post("/search-queries") -async def create_search_query_event( - payload: SearchQueryEventCreate, - token: Optional[str] = Depends(get_user_token), -): - """ - Persist a search/filter event for demand intelligence. - - Fire each time the discover feed calls /api/recommendations, capturing - the full filter state and how many results were returned. - Uses optional auth — silently no-ops when the user has no valid session so - that analytics never block or error during the pre-setup / sign-in flow. - """ - if not token: - return {"status": "skipped"} - supabase = get_admin_client() - try: - user_id = _resolve_current_user_id(token) - except HTTPException: - return {"status": "skipped"} - - try: - insert_data = { - "user_id": user_id, - "session_id": payload.session_id, - "filter_snapshot": payload.filter_snapshot, - "results_returned": payload.results_returned, - "offset": payload.offset, - } - created = supabase.table("search_query_events").insert(insert_data).execute() - if not created.data: - raise HTTPException(status_code=500, detail="Search query event was not persisted") - return {"status": "success", "event_id": created.data[0]["event_id"]} - except HTTPException: - raise - except Exception as e: - err = str(e).lower() - if "search_query_events" in err and "does not exist" in err: - raise HTTPException( - status_code=503, - detail="search_query_events table not found. Run migration 018.", - ) - raise HTTPException(status_code=500, detail=f"Failed to store search query event: {e}") - - -@router.get("/behavior/health") -async def get_behavior_health( - days: int = Query(7, ge=1, le=90), - max_events: int = Query(10000, ge=500, le=100000), - token: str = Depends(require_user_token), -): - """ - Return event quality and freshness summary for swipe interactions. - """ - _ = _resolve_current_user_id(token) - try: - summary = get_swipe_health_summary(days=days, max_events=max_events) - return {"status": "success", "data": summary} - except Exception as e: - err = str(e).lower() - if "swipe_interactions" in err and "does not exist" in err: - raise HTTPException( - status_code=503, - detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.", - ) - raise HTTPException(status_code=500, detail=f"Failed to build behavior health summary: {e}") diff --git a/backend/app/routes/interactions/__init__.py b/backend/app/routes/interactions/__init__.py new file mode 100644 index 0000000..0ca1a0a --- /dev/null +++ b/backend/app/routes/interactions/__init__.py @@ -0,0 +1,28 @@ +""" +Interactions API — assembled from sub-routers. + +Sub-modules: + swipes — swipe event capture and history + recommendations — sessions, feedback, passive engagement + group_saves — group save/unsave, group-liked listings + interested — user interested-listings + analytics — behavior vectors, data logging (views, searches) +""" + +from fastapi import APIRouter + +from .swipes import router as swipes_router +from .recommendations import router as recommendations_router +from .group_saves import router as group_saves_router +from .interested import router as interested_router +from .analytics import router as analytics_router + +router = APIRouter(prefix="/api/interactions", tags=["interactions"]) + +router.include_router(swipes_router) +router.include_router(recommendations_router) +router.include_router(group_saves_router) +router.include_router(interested_router) +router.include_router(analytics_router) + +__all__ = ["router"] diff --git a/backend/app/routes/interactions/_helpers.py b/backend/app/routes/interactions/_helpers.py new file mode 100644 index 0000000..85f6112 --- /dev/null +++ b/backend/app/routes/interactions/_helpers.py @@ -0,0 +1,156 @@ +"""Shared helpers for the interactions sub-package.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Optional + + +def now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def normalize_listing_ids(listing_ids: Optional[list[str]]) -> list[str]: + cleaned: list[str] = [] + for listing_id in listing_ids or []: + value = str(listing_id).strip() + if value: + cleaned.append(value) + return cleaned[:100] + + +def recommendation_storage_missing(exc: Exception) -> bool: + err = str(exc).lower() + return ( + "recommendation_sessions" in err and "does not exist" in err + ) or ( + "user_recommendation_feedback" in err and "does not exist" in err + ) or ( + "recommendation_engagement_events" in err and "does not exist" in err + ) + + +def interested_storage_missing(exc: Exception) -> bool: + err = str(exc).lower() + return "user_interested_listings" in err and "does not exist" in err + + +def get_recommendation_session(supabase, session_id: str, user_id: str) -> dict: + session_response = ( + supabase.table("recommendation_sessions") + .select("*") + .eq("id", session_id) + .eq("actor_user_id", user_id) + .limit(1) + .execute() + ) + if not session_response.data: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Recommendation session not found") + return session_response.data[0] + + +def recommendation_prompt_allowed( + supabase, + user_id: str, + current_session_id: Optional[str] = None, + surface: Optional[str] = None, +) -> bool: + if surface in {"discover", "matches"}: + return True + + cooldown_since = (now_utc() - timedelta(days=7)).isoformat() + recent = ( + supabase.table("recommendation_sessions") + .select("id, prompt_presented_at, prompt_dismissed_at, feedback_submitted_at") + .eq("actor_user_id", user_id) + .gte("started_at", cooldown_since) + .order("started_at", desc=True) + .limit(50) + .execute() + ) + + for row in recent.data or []: + if current_session_id and row.get("id") == current_session_id: + continue + if row.get("prompt_presented_at") or row.get("prompt_dismissed_at") or row.get("feedback_submitted_at"): + return False + return True + + +def build_session_response(supabase, session_row: dict) -> dict: + session_completed = bool( + session_row.get("feedback_submitted_at") + or session_row.get("prompt_dismissed_at") + or session_row.get("prompt_presented_at") + ) + return { + "status": "success", + "data": session_row, + "prompt_allowed": (not session_completed) + and recommendation_prompt_allowed( + supabase, + user_id=session_row["actor_user_id"], + current_session_id=session_row["id"], + surface=session_row.get("surface"), + ), + } + + +def update_session_aggregate_max( + update_data: dict, field: str, current_value: Optional[int], next_value: int +) -> None: + update_data[field] = max(int(current_value or 0), int(next_value)) + + +def summarize_recommendation_events(events: list[dict], session_row: dict) -> dict: + summary = { + "detail_open_events": 0, + "save_events": 0, + "unsave_events": 0, + "detail_view_events": 0, + "detail_view_dwell_ms": 0, + "avg_detail_view_dwell_ms": 0, + "surface_dwell_ms": int(session_row.get("surface_dwell_ms") or 0), + "detail_dwell_ms": int(session_row.get("detail_dwell_ms") or 0), + "detail_open_rate": 0.0, + "save_rate": 0.0, + "detail_opens_by_position": {}, + "saves_by_position": {}, + } + + detail_view_count = 0 + recommendation_count = max(int(session_row.get("recommendation_count_shown") or 0), 1) + + for event in events: + event_type = event.get("event_type") + position = event.get("position_in_feed") + position_key = str(position) if position is not None else None + + if event_type == "detail_open": + summary["detail_open_events"] += 1 + if position_key is not None: + summary["detail_opens_by_position"][position_key] = ( + summary["detail_opens_by_position"].get(position_key, 0) + 1 + ) + elif event_type == "save": + summary["save_events"] += 1 + if position_key is not None: + summary["saves_by_position"][position_key] = ( + summary["saves_by_position"].get(position_key, 0) + 1 + ) + elif event_type == "unsave": + summary["unsave_events"] += 1 + elif event_type == "detail_view": + detail_view_count += 1 + summary["detail_view_events"] += 1 + summary["detail_view_dwell_ms"] += int(event.get("dwell_ms") or 0) + + if detail_view_count > 0: + summary["avg_detail_view_dwell_ms"] = round( + summary["detail_view_dwell_ms"] / detail_view_count, 2 + ) + + summary["detail_open_rate"] = round(summary["detail_open_events"] / recommendation_count, 4) + summary["save_rate"] = round(summary["save_events"] / recommendation_count, 4) + return summary diff --git a/backend/app/routes/interactions/analytics.py b/backend/app/routes/interactions/analytics.py new file mode 100644 index 0000000..160b8c8 --- /dev/null +++ b/backend/app/routes/interactions/analytics.py @@ -0,0 +1,263 @@ +"""Analytics / data-logging endpoints (swipe context, listing views, page views, search queries). + +All four are best-effort: failures return 500 but callers should fire-and-forget. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Literal + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from app.dependencies.auth import get_user_token, require_user_token +from app.dependencies.supabase import get_admin_client +from app.services.auth_helpers import resolve_current_user_id +from app.services.behavior_features import ( + build_group_behavior_vector, + build_user_behavior_vector, + get_swipe_health_summary, +) +from app.services.auth_helpers import require_group_membership + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +class SwipeContextEventCreate(BaseModel): + listing_id: str + action: Literal["like", "pass", "super_like", "group_save"] + session_id: str = Field(..., min_length=1, max_length=128) + active_filters_snapshot: Optional[Dict[str, Any]] = None + device_context: Optional[Dict[str, Any]] = None + + +class ListingViewEventCreate(BaseModel): + listing_id: str + surface: Literal["discover_card", "listing_detail", "matches_card"] + session_id: str = Field(..., min_length=1, max_length=128) + view_duration_ms: Optional[int] = Field(default=None, ge=0) + expanded: bool = False + photos_viewed_count: int = Field(default=0, ge=0) + + +class PageViewEventCreate(BaseModel): + page: Literal[ + "discover", "matches", "listing_detail", "preferences", + "account", "groups", "roommates", "onboarding", + ] + session_id: str = Field(..., min_length=1, max_length=128) + duration_ms: Optional[int] = Field(default=None, ge=0) + referrer_page: Optional[str] = Field(default=None, max_length=100) + + +class SearchQueryEventCreate(BaseModel): + session_id: str = Field(..., min_length=1, max_length=128) + filter_snapshot: Dict[str, Any] + results_returned: int = Field(default=0, ge=0) + offset: int = Field(default=0, ge=0) + + +# --------------------------------------------------------------------------- +# Behavior vectors +# --------------------------------------------------------------------------- + +@router.get("/behavior/me") +async def get_my_behavior_vector( + days: int = Query(180, ge=7, le=365), + max_events: int = Query(2000, ge=100, le=10000), + token: str = Depends(require_user_token), +): + """Return Phase 2A behavior vector for the authenticated user.""" + user_id = resolve_current_user_id(token) + try: + vector = build_user_behavior_vector(user_id=user_id, days=days, max_events=max_events) + return {"status": "success", "data": vector} + except Exception as e: + err = str(e).lower() + if "swipe_interactions" in err and "does not exist" in err: + raise HTTPException(status_code=503, detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.") + raise HTTPException(status_code=500, detail=f"Failed to build user behavior vector: {e}") + + +@router.get("/behavior/groups/{group_id}") +async def get_group_behavior( + group_id: str, + days: int = Query(180, ge=7, le=365), + max_events_per_user: int = Query(2000, ge=100, le=10000), + token: str = Depends(require_user_token), +): + """Return Phase 2A behavior vector for a group (members only).""" + user_id = resolve_current_user_id(token) + require_group_membership(group_id=group_id, user_id=user_id) + try: + vector = build_group_behavior_vector(group_id=group_id, days=days, max_events_per_user=max_events_per_user) + return {"status": "success", "data": vector} + except Exception as e: + err = str(e).lower() + if "swipe_interactions" in err and "does not exist" in err: + raise HTTPException(status_code=503, detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.") + raise HTTPException(status_code=500, detail=f"Failed to build group behavior vector: {e}") + + +@router.get("/behavior/health") +async def get_behavior_health( + days: int = Query(7, ge=1, le=90), + max_events: int = Query(10000, ge=500, le=100000), + token: str = Depends(require_user_token), +): + """Return event quality and freshness summary for swipe interactions.""" + _ = resolve_current_user_id(token) + try: + summary = get_swipe_health_summary(days=days, max_events=max_events) + return {"status": "success", "data": summary} + except Exception as e: + err = str(e).lower() + if "swipe_interactions" in err and "does not exist" in err: + raise HTTPException(status_code=503, detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.") + raise HTTPException(status_code=500, detail=f"Failed to build behavior health summary: {e}") + + +# --------------------------------------------------------------------------- +# Data-logging endpoints +# --------------------------------------------------------------------------- + +def _try_resolve_user(token: Optional[str]): + """Best-effort user resolution; returns None when no valid session.""" + if not token: + return None + try: + return resolve_current_user_id(token) + except Exception: + return None + + +@router.post("/swipe-context") +async def create_swipe_context_event( + payload: SwipeContextEventCreate, + token: Optional[str] = Depends(get_user_token), +): + """Persist filter + device context alongside a swipe (optional auth).""" + user_id = _try_resolve_user(token) + if user_id is None: + return {"status": "skipped"} + + supabase = get_admin_client() + try: + insert_data = { + "actor_user_id": user_id, + "listing_id": payload.listing_id, + "session_id": payload.session_id, + "action": payload.action, + "active_filters_snapshot": payload.active_filters_snapshot, + "device_context": payload.device_context, + } + created = supabase.table("swipe_context_events").insert(insert_data).execute() + if not created.data: + raise HTTPException(status_code=500, detail="Swipe context event was not persisted") + return {"status": "success", "event_id": created.data[0]["event_id"]} + except HTTPException: + raise + except Exception as e: + err = str(e).lower() + if "swipe_context_events" in err and "does not exist" in err: + raise HTTPException(status_code=503, detail="swipe_context_events table not found. Run migration 012.") + raise HTTPException(status_code=500, detail=f"Failed to store swipe context: {e}") + + +@router.post("/listing-views") +async def create_listing_view_event( + payload: ListingViewEventCreate, + token: Optional[str] = Depends(get_user_token), +): + """Persist a listing view duration event (optional auth).""" + user_id = _try_resolve_user(token) + if user_id is None: + return {"status": "skipped"} + + supabase = get_admin_client() + try: + created = supabase.table("listing_view_events").insert({ + "user_id": user_id, + "listing_id": payload.listing_id, + "surface": payload.surface, + "session_id": payload.session_id, + "view_duration_ms": payload.view_duration_ms, + "expanded": payload.expanded, + "photos_viewed_count": payload.photos_viewed_count, + }).execute() + if not created.data: + raise HTTPException(status_code=500, detail="Listing view event was not persisted") + return {"status": "success", "event_id": created.data[0]["event_id"]} + except HTTPException: + raise + except Exception as e: + err = str(e).lower() + if "listing_view_events" in err and "does not exist" in err: + raise HTTPException(status_code=503, detail="listing_view_events table not found. Run migration 014.") + raise HTTPException(status_code=500, detail=f"Failed to store listing view event: {e}") + + +@router.post("/page-views") +async def create_page_view_event( + payload: PageViewEventCreate, + token: Optional[str] = Depends(get_user_token), +): + """Persist a page view event for funnel analytics (optional auth).""" + user_id = _try_resolve_user(token) + if user_id is None: + return {"status": "skipped"} + + supabase = get_admin_client() + try: + created = supabase.table("page_view_events").insert({ + "user_id": user_id, + "page": payload.page, + "session_id": payload.session_id, + "duration_ms": payload.duration_ms, + "referrer_page": payload.referrer_page, + }).execute() + if not created.data: + raise HTTPException(status_code=500, detail="Page view event was not persisted") + return {"status": "success", "event_id": created.data[0]["event_id"]} + except HTTPException: + raise + except Exception as e: + err = str(e).lower() + if "page_view_events" in err and "does not exist" in err: + raise HTTPException(status_code=503, detail="page_view_events table not found. Run migration 016.") + raise HTTPException(status_code=500, detail=f"Failed to store page view event: {e}") + + +@router.post("/search-queries") +async def create_search_query_event( + payload: SearchQueryEventCreate, + token: Optional[str] = Depends(get_user_token), +): + """Persist a search/filter event for demand intelligence (optional auth).""" + user_id = _try_resolve_user(token) + if user_id is None: + return {"status": "skipped"} + + supabase = get_admin_client() + try: + created = supabase.table("search_query_events").insert({ + "user_id": user_id, + "session_id": payload.session_id, + "filter_snapshot": payload.filter_snapshot, + "results_returned": payload.results_returned, + "offset": payload.offset, + }).execute() + if not created.data: + raise HTTPException(status_code=500, detail="Search query event was not persisted") + return {"status": "success", "event_id": created.data[0]["event_id"]} + except HTTPException: + raise + except Exception as e: + err = str(e).lower() + if "search_query_events" in err and "does not exist" in err: + raise HTTPException(status_code=503, detail="search_query_events table not found. Run migration 018.") + raise HTTPException(status_code=500, detail=f"Failed to store search query event: {e}") diff --git a/backend/app/routes/interactions/group_saves.py b/backend/app/routes/interactions/group_saves.py new file mode 100644 index 0000000..99ff53b --- /dev/null +++ b/backend/app/routes/interactions/group_saves.py @@ -0,0 +1,177 @@ +"""Group save/unsave operations and group-liked-listings retrieval.""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.dependencies.auth import require_user_token +from app.dependencies.supabase import get_admin_client +from app.services.auth_helpers import resolve_current_user_id, require_group_membership + +router = APIRouter() + + +@router.get("/swipes/groups/{group_id}/liked") +async def get_group_liked_listings( + group_id: str, + limit: int = Query(100, ge=1, le=500), + action: Optional[str] = Query(None, description="Filter by action: 'like', 'group_save', or omit for both"), + token: str = Depends(require_user_token), +): + """Return listings interacted with by any accepted member of the group.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + require_group_membership(group_id=group_id, user_id=user_id) + + try: + members_resp = ( + supabase.table("group_members") + .select("user_id") + .eq("group_id", group_id) + .eq("status", "accepted") + .execute() + ) + members = members_resp.data or [] + if not members: + return {"status": "success", "data": []} + + member_ids = [m["user_id"] for m in members] + + users_resp = ( + supabase.table("users") + .select("id, full_name, email") + .in_("id", member_ids) + .execute() + ) + member_map = { + u["id"]: u.get("full_name") or u.get("email", "Member") + for u in (users_resp.data or []) + } + + if action == "group_save": + actions_to_fetch = ["group_save"] + elif action == "like": + actions_to_fetch = ["like"] + else: + actions_to_fetch = ["like", "group_save"] + + swipes_resp = ( + supabase.table("swipe_interactions") + .select("listing_id, actor_user_id, action, group_id_at_time") + .in_("actor_user_id", member_ids) + .in_("action", actions_to_fetch) + .order("created_at", desc=True) + .limit(limit) + .execute() + ) + raw_swipes = swipes_resp.data or [] + swipes = [] + for s in raw_swipes: + if s.get("action") == "like": + swipes.append(s) + elif s.get("action") == "group_save" and s.get("group_id_at_time") == group_id: + swipes.append(s) + if not swipes: + return {"status": "success", "data": []} + + liked_by: dict = {} + for s in swipes: + lid = s["listing_id"] + name = member_map.get(s["actor_user_id"], "Member") + liked_by.setdefault(lid, []) + if name not in liked_by[lid]: + liked_by[lid].append(name) + + unique_listing_ids = list(liked_by.keys()) + listings_resp = ( + supabase.table("listings").select("*").in_("id", unique_listing_ids).execute() + ) + listing_map = {str(l["id"]): l for l in (listings_resp.data or [])} + + result = [] + for lid in unique_listing_ids: + listing = listing_map.get(str(lid)) + if listing: + result.append({**listing, "liked_by": liked_by[lid]}) + + return {"status": "success", "data": result} + except Exception as e: + err = str(e).lower() + if "swipe_interactions" in err and "does not exist" in err: + raise HTTPException(status_code=503, detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.") + raise HTTPException(status_code=500, detail=f"Failed to fetch group liked listings: {e}") + + +@router.post("/swipes/groups/{group_id}/save/{listing_id}") +async def save_listing_to_group( + group_id: str, listing_id: str, token: str = Depends(require_user_token) +): + """Save a listing to the group.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + require_group_membership(group_id=group_id, user_id=user_id) + + try: + supabase.table("swipe_interactions").delete().eq( + "actor_user_id", user_id + ).eq("listing_id", listing_id).eq("action", "group_save").eq( + "group_id_at_time", group_id + ).execute() + + supabase.table("swipe_interactions").insert({ + "actor_user_id": user_id, + "listing_id": listing_id, + "action": "group_save", + "group_id_at_time": group_id, + "surface": "matches", + "session_id": f"group-save-{user_id}-{group_id}-{listing_id}", + "algorithm_version": "group-save-v1", + "position_in_feed": 0, + }).execute() + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save listing: {e}") + + +@router.delete("/swipes/groups/{group_id}/save/{listing_id}") +async def unsave_listing_from_group( + group_id: str, listing_id: str, token: str = Depends(require_user_token) +): + """Remove a group-saved listing.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + require_group_membership(group_id=group_id, user_id=user_id) + + try: + supabase.table("swipe_interactions").delete().eq( + "actor_user_id", user_id + ).eq("listing_id", listing_id).eq("action", "group_save").execute() + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to unsave listing: {e}") + + +@router.get("/swipes/groups/{group_id}/saved") +async def get_my_saved_listings_for_group( + group_id: str, token: str = Depends(require_user_token) +): + """Return listing IDs the current user has starred for this group.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + require_group_membership(group_id=group_id, user_id=user_id) + + try: + resp = ( + supabase.table("swipe_interactions") + .select("listing_id") + .eq("actor_user_id", user_id) + .eq("group_id_at_time", group_id) + .eq("action", "group_save") + .execute() + ) + ids = [r["listing_id"] for r in (resp.data or [])] + return {"status": "success", "saved_listing_ids": ids} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch saved listings: {e}") diff --git a/backend/app/routes/interactions/interested.py b/backend/app/routes/interactions/interested.py new file mode 100644 index 0000000..d814ffe --- /dev/null +++ b/backend/app/routes/interactions/interested.py @@ -0,0 +1,151 @@ +"""User interested-listings: mark, unmark, list.""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from app.dependencies.auth import require_user_token +from app.dependencies.supabase import get_admin_client +from app.services.auth_helpers import resolve_current_user_id +from app.services.listing_payloads import hydrate_listing_images + +from ._helpers import interested_storage_missing + +router = APIRouter() + + +class InterestedListingCreate(BaseModel): + source: Optional[str] = Field(default=None, max_length=100) + + +@router.get("/interested-listings") +async def get_my_interested_listings(token: str = Depends(require_user_token)): + """Return the current user's interested listings with listing payloads.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + + try: + rows_resp = ( + supabase.table("user_interested_listings") + .select("listing_id, source, created_at") + .eq("actor_user_id", user_id) + .order("created_at", desc=True) + .execute() + ) + rows = rows_resp.data or [] + if not rows: + return {"status": "success", "data": []} + + listing_ids: list[str] = [] + seen: set[str] = set() + for row in rows: + listing_id = str(row.get("listing_id") or "").strip() + if listing_id and listing_id not in seen: + seen.add(listing_id) + listing_ids.append(listing_id) + + listings_resp = ( + supabase.table("listings") + .select("*,listing_photos(photo_url,sort_order)") + .in_("id", listing_ids) + .execute() + ) + listing_map = { + str(item["id"]): hydrate_listing_images(item) + for item in (listings_resp.data or []) + } + + data = [] + for row in rows: + listing_id = str(row.get("listing_id") or "") + listing = listing_map.get(listing_id) + if listing: + data.append({"interested_at": row.get("created_at"), "interest_source": row.get("source"), **listing}) + + return {"status": "success", "data": data} + except Exception as e: + if interested_storage_missing(e): + raise HTTPException(status_code=503, detail="Interested listing storage not configured. Run migration 20260406030000_user_interested_listings.sql.") + raise HTTPException(status_code=500, detail=f"Failed to fetch interested listings: {e}") + + +@router.get("/interested-listings/ids") +async def get_my_interested_listing_ids(token: str = Depends(require_user_token)): + """Return listing IDs the current user marked as interested.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + + try: + rows_resp = ( + supabase.table("user_interested_listings") + .select("listing_id") + .eq("actor_user_id", user_id) + .execute() + ) + ids = [row["listing_id"] for row in (rows_resp.data or []) if row.get("listing_id")] + return {"status": "success", "interested_listing_ids": ids} + except Exception as e: + if interested_storage_missing(e): + raise HTTPException(status_code=503, detail="Interested listing storage not configured. Run migration 20260406030000_user_interested_listings.sql.") + raise HTTPException(status_code=500, detail=f"Failed to fetch interested listing ids: {e}") + + +@router.post("/interested-listings/{listing_id}") +async def mark_listing_interested( + listing_id: str, + payload: InterestedListingCreate, + token: str = Depends(require_user_token), +): + """Mark a listing as personally interesting for the current user.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + + try: + existing_listing = ( + supabase.table("listings").select("id").eq("id", listing_id).limit(1).execute() + ) + if not existing_listing.data: + raise HTTPException(status_code=404, detail="Listing not found") + + existing_interested = ( + supabase.table("user_interested_listings") + .select("id") + .eq("actor_user_id", user_id) + .eq("listing_id", listing_id) + .limit(1) + .execute() + ) + if existing_interested.data: + return {"status": "success", "listing_id": listing_id, "already_interested": True} + + supabase.table("user_interested_listings").insert({ + "actor_user_id": user_id, "listing_id": listing_id, "source": payload.source + }).execute() + + return {"status": "success", "listing_id": listing_id, "already_interested": False} + except HTTPException: + raise + except Exception as e: + if interested_storage_missing(e): + raise HTTPException(status_code=503, detail="Interested listing storage not configured. Run migration 20260406030000_user_interested_listings.sql.") + raise HTTPException(status_code=500, detail=f"Failed to mark listing interested: {e}") + + +@router.delete("/interested-listings/{listing_id}") +async def unmark_listing_interested(listing_id: str, token: str = Depends(require_user_token)): + """Remove a listing from the current user's interested list.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + + try: + supabase.table("user_interested_listings").delete().eq( + "actor_user_id", user_id + ).eq("listing_id", listing_id).execute() + return {"status": "success", "listing_id": listing_id} + except Exception as e: + if interested_storage_missing(e): + raise HTTPException(status_code=503, detail="Interested listing storage not configured. Run migration 20260406030000_user_interested_listings.sql.") + raise HTTPException(status_code=500, detail=f"Failed to unmark listing interested: {e}") diff --git a/backend/app/routes/interactions/recommendations.py b/backend/app/routes/interactions/recommendations.py new file mode 100644 index 0000000..f99d915 --- /dev/null +++ b/backend/app/routes/interactions/recommendations.py @@ -0,0 +1,402 @@ +"""Recommendation sessions, feedback, and passive engagement events.""" + +from __future__ import annotations + +from typing import Optional, Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from app.dependencies.auth import require_user_token +from app.dependencies.supabase import get_admin_client +from app.services.auth_helpers import resolve_current_user_id + +from ._helpers import ( + build_session_response, + get_recommendation_session, + normalize_listing_ids, + now_utc, + recommendation_storage_missing, + summarize_recommendation_events, + update_session_aggregate_max, +) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +class RecommendationSessionCreate(BaseModel): + client_session_id: str = Field(..., min_length=1, max_length=128) + surface: Literal["matches", "discover"] = "matches" + recommendation_count_shown: int = Field(default=0, ge=0) + top_listing_ids_shown: list[str] = Field(default_factory=list) + algorithm_version: Optional[str] = Field(default=None, max_length=100) + model_version: Optional[str] = Field(default=None, max_length=100) + experiment_name: Optional[str] = Field(default=None, max_length=100) + experiment_variant: Optional[str] = Field(default=None, max_length=100) + + +class RecommendationSessionUpdate(BaseModel): + recommendation_count_shown: Optional[int] = Field(default=None, ge=0) + top_listing_ids_shown: Optional[list[str]] = None + detail_opens_count: Optional[int] = Field(default=None, ge=0) + saves_count: Optional[int] = Field(default=None, ge=0) + likes_count: Optional[int] = Field(default=None, ge=0) + surface_dwell_ms: Optional[int] = Field(default=None, ge=0) + detail_dwell_ms: Optional[int] = Field(default=None, ge=0) + prompt_presented: bool = False + prompt_dismissed: bool = False + mark_ended: bool = False + algorithm_version: Optional[str] = Field(default=None, max_length=100) + model_version: Optional[str] = Field(default=None, max_length=100) + experiment_name: Optional[str] = Field(default=None, max_length=100) + experiment_variant: Optional[str] = Field(default=None, max_length=100) + + +class RecommendationFeedbackCreate(BaseModel): + recommendation_session_id: str = Field(..., min_length=1, max_length=128) + feedback_label: Literal["not_useful", "somewhat_useful", "very_useful"] + reason_label: Optional[ + Literal["too_expensive", "wrong_location", "not_my_style", "too_few_good_options", "other"] + ] = None + + +class RecommendationEngagementEventCreate(BaseModel): + recommendation_session_id: str = Field(..., min_length=1, max_length=128) + client_event_id: str = Field(..., min_length=1, max_length=128) + surface: Literal["matches", "discover"] = "matches" + event_type: Literal["detail_open", "detail_view", "save", "unsave"] + listing_id: Optional[str] = Field(default=None, max_length=128) + position_in_feed: Optional[int] = Field(default=None, ge=0) + dwell_ms: Optional[int] = Field(default=None, ge=0) + metadata: dict = Field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Sessions +# --------------------------------------------------------------------------- + +@router.post("/recommendation-sessions") +async def create_recommendation_session( + payload: RecommendationSessionCreate, + token: str = Depends(require_user_token), +): + """Create or refresh a recommendation session (idempotent per client_session_id).""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + now_iso = now_utc().isoformat() + top_listing_ids = normalize_listing_ids(payload.top_listing_ids_shown) + + try: + existing = ( + supabase.table("recommendation_sessions") + .select("*") + .eq("actor_user_id", user_id) + .eq("client_session_id", payload.client_session_id) + .limit(1) + .execute() + ) + + if existing.data: + session = existing.data[0] + update_data = { + "updated_at": now_iso, + "surface": payload.surface, + "recommendation_count_shown": payload.recommendation_count_shown, + "top_listing_ids_shown": top_listing_ids, + "algorithm_version": payload.algorithm_version, + "model_version": payload.model_version, + "experiment_name": payload.experiment_name, + "experiment_variant": payload.experiment_variant, + } + updated = ( + supabase.table("recommendation_sessions") + .update(update_data) + .eq("id", session["id"]) + .execute() + ) + session = updated.data[0] if updated.data else {**session, **update_data} + return build_session_response(supabase, session) + + created = ( + supabase.table("recommendation_sessions") + .insert({ + "actor_user_id": user_id, + "client_session_id": payload.client_session_id, + "surface": payload.surface, + "started_at": now_iso, + "recommendation_count_shown": payload.recommendation_count_shown, + "top_listing_ids_shown": top_listing_ids, + "algorithm_version": payload.algorithm_version, + "model_version": payload.model_version, + "experiment_name": payload.experiment_name, + "experiment_variant": payload.experiment_variant, + "updated_at": now_iso, + }) + .execute() + ) + if not created.data: + raise HTTPException(status_code=500, detail="Recommendation session was not persisted") + return build_session_response(supabase, created.data[0]) + except HTTPException: + raise + except Exception as e: + if recommendation_storage_missing(e): + raise HTTPException(status_code=503, detail="Recommendation feedback storage not configured. Run migration 20260406010000_recommendation_feedback_phase2.sql.") + raise HTTPException(status_code=500, detail=f"Failed to create recommendation session: {e}") + + +@router.patch("/recommendation-sessions/{session_id}") +async def update_recommendation_session( + session_id: str, + payload: RecommendationSessionUpdate, + token: str = Depends(require_user_token), +): + """Update counters and prompt lifecycle fields for a recommendation session.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + now_iso = now_utc().isoformat() + + try: + session = get_recommendation_session(supabase, session_id=session_id, user_id=user_id) + update_data: dict = {"updated_at": now_iso} + + if payload.recommendation_count_shown is not None: + update_data["recommendation_count_shown"] = payload.recommendation_count_shown + if payload.top_listing_ids_shown is not None: + update_data["top_listing_ids_shown"] = normalize_listing_ids(payload.top_listing_ids_shown) + if payload.detail_opens_count is not None: + update_data["detail_opens_count"] = max(int(session.get("detail_opens_count") or 0), payload.detail_opens_count) + if payload.saves_count is not None: + update_data["saves_count"] = max(int(session.get("saves_count") or 0), payload.saves_count) + if payload.likes_count is not None: + update_data["likes_count"] = max(int(session.get("likes_count") or 0), payload.likes_count) + if payload.surface_dwell_ms is not None: + update_session_aggregate_max(update_data, "surface_dwell_ms", session.get("surface_dwell_ms"), payload.surface_dwell_ms) + if payload.detail_dwell_ms is not None: + update_session_aggregate_max(update_data, "detail_dwell_ms", session.get("detail_dwell_ms"), payload.detail_dwell_ms) + if payload.prompt_presented and not session.get("prompt_presented_at"): + update_data["prompt_presented_at"] = now_iso + if payload.prompt_dismissed and not session.get("prompt_dismissed_at"): + update_data["prompt_dismissed_at"] = now_iso + if payload.mark_ended: + update_data["ended_at"] = now_iso + if payload.algorithm_version is not None: + update_data["algorithm_version"] = payload.algorithm_version + if payload.model_version is not None: + update_data["model_version"] = payload.model_version + if payload.experiment_name is not None: + update_data["experiment_name"] = payload.experiment_name + if payload.experiment_variant is not None: + update_data["experiment_variant"] = payload.experiment_variant + + updated = ( + supabase.table("recommendation_sessions") + .update(update_data) + .eq("id", session_id) + .eq("actor_user_id", user_id) + .execute() + ) + if not updated.data: + raise HTTPException(status_code=500, detail="Recommendation session update failed") + return {"status": "success", "data": updated.data[0]} + except HTTPException: + raise + except Exception as e: + if recommendation_storage_missing(e): + raise HTTPException(status_code=503, detail="Recommendation feedback storage not configured. Run migration 20260406010000_recommendation_feedback_phase2.sql.") + raise HTTPException(status_code=500, detail=f"Failed to update recommendation session: {e}") + + +# --------------------------------------------------------------------------- +# Engagement events +# --------------------------------------------------------------------------- + +@router.post("/recommendation-events") +async def create_recommendation_engagement_event( + payload: RecommendationEngagementEventCreate, + token: str = Depends(require_user_token), +): + """Persist a passive engagement event tied to a recommendation session.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + now_iso = now_utc().isoformat() + + try: + session = get_recommendation_session(supabase, session_id=payload.recommendation_session_id, user_id=user_id) + + existing = ( + supabase.table("recommendation_engagement_events") + .select("event_id") + .eq("actor_user_id", user_id) + .eq("client_event_id", payload.client_event_id) + .limit(1) + .execute() + ) + if existing.data: + return {"status": "success", "duplicate_ignored": True, "event_id": existing.data[0]["event_id"]} + + created = ( + supabase.table("recommendation_engagement_events") + .insert({ + "actor_user_id": user_id, + "recommendation_session_id": session["id"], + "client_event_id": payload.client_event_id, + "surface": payload.surface, + "event_type": payload.event_type, + "listing_id": payload.listing_id, + "position_in_feed": payload.position_in_feed, + "dwell_ms": payload.dwell_ms, + "metadata": payload.metadata or {}, + "created_at": now_iso, + }) + .execute() + ) + if not created.data: + raise HTTPException(status_code=500, detail="Recommendation engagement event was not persisted") + + session_update: dict = {"updated_at": now_iso} + if payload.event_type == "detail_open": + session_update["detail_opens_count"] = int(session.get("detail_opens_count") or 0) + 1 + elif payload.event_type == "save": + session_update["saves_count"] = int(session.get("saves_count") or 0) + 1 + elif payload.event_type == "detail_view" and payload.dwell_ms is not None: + session_update["detail_dwell_ms"] = int(session.get("detail_dwell_ms") or 0) + int(payload.dwell_ms) + + if len(session_update) > 1: + supabase.table("recommendation_sessions").update(session_update).eq("id", session["id"]).eq("actor_user_id", user_id).execute() + + return {"status": "success", "duplicate_ignored": False, "event_id": created.data[0]["event_id"]} + except HTTPException: + raise + except Exception as e: + if recommendation_storage_missing(e): + raise HTTPException(status_code=503, detail="Recommendation passive metrics storage not configured. Run migration 20260406020000_recommendation_passive_metrics_phase3.sql.") + raise HTTPException(status_code=500, detail=f"Failed to store recommendation engagement event: {e}") + + +@router.get("/recommendation-sessions/{session_id}/passive-summary") +async def get_recommendation_passive_summary( + session_id: str, + token: str = Depends(require_user_token), +): + """Return passive engagement metrics for one recommendation session.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + + try: + session = get_recommendation_session(supabase, session_id=session_id, user_id=user_id) + events_response = ( + supabase.table("recommendation_engagement_events") + .select("event_type, position_in_feed, dwell_ms, listing_id, created_at") + .eq("recommendation_session_id", session["id"]) + .eq("actor_user_id", user_id) + .order("created_at", desc=False) + .execute() + ) + events = events_response.data or [] + summary = summarize_recommendation_events(events, session) + return { + "status": "success", + "data": { + "session_id": session["id"], + "surface": session.get("surface"), + "recommendation_count_shown": int(session.get("recommendation_count_shown") or 0), + "detail_opens_count": int(session.get("detail_opens_count") or 0), + "saves_count": int(session.get("saves_count") or 0), + "likes_count": int(session.get("likes_count") or 0), + **summary, + }, + } + except HTTPException: + raise + except Exception as e: + if recommendation_storage_missing(e): + raise HTTPException(status_code=503, detail="Recommendation passive metrics storage not configured. Run migration 20260406020000_recommendation_passive_metrics_phase3.sql.") + raise HTTPException(status_code=500, detail=f"Failed to fetch recommendation passive summary: {e}") + + +# --------------------------------------------------------------------------- +# Feedback +# --------------------------------------------------------------------------- + +@router.post("/recommendation-feedback") +async def create_recommendation_feedback( + payload: RecommendationFeedbackCreate, + token: str = Depends(require_user_token), +): + """Persist a single session-level usefulness response.""" + if payload.reason_label and payload.feedback_label != "not_useful": + raise HTTPException(status_code=422, detail="A negative reason can only be attached to 'not_useful' feedback") + + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + now_iso = now_utc().isoformat() + + try: + session = get_recommendation_session(supabase, session_id=payload.recommendation_session_id, user_id=user_id) + swipe_count = None + if session.get("client_session_id"): + try: + swipe_count = ( + supabase.table("swipe_interactions") + .select("event_id", count="exact") + .eq("actor_user_id", user_id) + .eq("session_id", session["client_session_id"]) + .execute() + .count + ) + except Exception: + swipe_count = None + + existing = ( + supabase.table("user_recommendation_feedback") + .select("*") + .eq("recommendation_session_id", session["id"]) + .limit(1) + .execute() + ) + if existing.data: + return {"status": "success", "duplicate_ignored": True, "data": existing.data[0]} + + created = ( + supabase.table("user_recommendation_feedback") + .insert({ + "actor_user_id": user_id, + "recommendation_session_id": session["id"], + "surface": session["surface"], + "feedback_label": payload.feedback_label, + "reason_label": payload.reason_label, + "submitted_at": now_iso, + "algorithm_version": session.get("algorithm_version"), + "model_version": session.get("model_version"), + "experiment_name": session.get("experiment_name"), + "experiment_variant": session.get("experiment_variant"), + "swipes_in_session": int(swipe_count) if swipe_count is not None else None, + "likes_in_session": int(session.get("likes_count") or 0), + "saves_in_session": int(session.get("saves_count") or 0), + "detail_opens_in_session": int(session.get("detail_opens_count") or 0), + "recommendation_count_shown": int(session.get("recommendation_count_shown") or 0), + "top_listing_ids_shown": session.get("top_listing_ids_shown") or [], + }) + .execute() + ) + if not created.data: + raise HTTPException(status_code=500, detail="Recommendation feedback was not persisted") + + supabase.table("recommendation_sessions").update({ + "feedback_submitted_at": now_iso, + "ended_at": session.get("ended_at") or now_iso, + "updated_at": now_iso, + }).eq("id", session["id"]).eq("actor_user_id", user_id).execute() + + return {"status": "success", "duplicate_ignored": False, "data": created.data[0]} + except HTTPException: + raise + except Exception as e: + if recommendation_storage_missing(e): + raise HTTPException(status_code=503, detail="Recommendation feedback storage not configured. Run migration 20260406010000_recommendation_feedback_phase2.sql.") + raise HTTPException(status_code=500, detail=f"Failed to store recommendation feedback: {e}") diff --git a/backend/app/routes/interactions/swipes.py b/backend/app/routes/interactions/swipes.py new file mode 100644 index 0000000..436f4ba --- /dev/null +++ b/backend/app/routes/interactions/swipes.py @@ -0,0 +1,123 @@ +"""Swipe event capture and user swipe history.""" + +from __future__ import annotations + +from typing import Optional, Literal + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from app.dependencies.auth import require_user_token +from app.dependencies.supabase import get_admin_client +from app.services.auth_helpers import resolve_current_user_id + +router = APIRouter() + + +class SwipeEventCreate(BaseModel): + listing_id: str + action: Literal["like", "pass", "super_like"] + group_id_at_time: Optional[str] = None + surface: str = Field(default="discover", min_length=1, max_length=100) + session_id: str = Field(..., min_length=1, max_length=128) + position_in_feed: int = Field(default=0, ge=0) + algorithm_version: str = Field(..., min_length=1, max_length=100) + model_version: Optional[str] = Field(default=None, max_length=100) + city_filter: Optional[str] = Field(default=None, max_length=100) + preference_snapshot_hash: Optional[str] = Field(default=None, max_length=128) + latency_ms: Optional[int] = Field(default=None, ge=0) + + +@router.post("/swipes") +async def create_swipe_event( + payload: SwipeEventCreate, + token: str = Depends(require_user_token), +): + """Persist a single swipe event from Discover (idempotent).""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + + try: + existing = ( + supabase.table("swipe_interactions") + .select("event_id") + .eq("actor_user_id", user_id) + .eq("listing_id", payload.listing_id) + .eq("session_id", payload.session_id) + .eq("position_in_feed", payload.position_in_feed) + .limit(1) + .execute() + ) + if existing.data: + return {"status": "success", "duplicate_ignored": True, "event_id": existing.data[0]["event_id"]} + + insert_data = { + "actor_type": "user", + "actor_user_id": user_id, + "group_id_at_time": payload.group_id_at_time, + "listing_id": payload.listing_id, + "action": payload.action, + "surface": payload.surface, + "session_id": payload.session_id, + "position_in_feed": payload.position_in_feed, + "algorithm_version": payload.algorithm_version, + "model_version": payload.model_version, + "city_filter": payload.city_filter, + "preference_snapshot_hash": payload.preference_snapshot_hash, + "latency_ms": payload.latency_ms, + } + created = supabase.table("swipe_interactions").insert(insert_data).execute() + if not created.data: + raise HTTPException(status_code=500, detail="Swipe event was not persisted") + + return {"status": "success", "duplicate_ignored": False, "event_id": created.data[0]["event_id"]} + except HTTPException: + raise + except Exception as e: + err = str(e).lower() + if "swipe_interactions" in err and "does not exist" in err: + raise HTTPException(status_code=503, detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.") + if "duplicate key value violates unique constraint" in err: + existing = ( + supabase.table("swipe_interactions") + .select("event_id") + .eq("actor_user_id", user_id) + .eq("listing_id", payload.listing_id) + .eq("session_id", payload.session_id) + .eq("position_in_feed", payload.position_in_feed) + .limit(1) + .execute() + ) + return { + "status": "success", + "duplicate_ignored": True, + "event_id": existing.data[0]["event_id"] if existing.data else None, + } + raise HTTPException(status_code=500, detail=f"Failed to store swipe event: {e}") + + +@router.get("/swipes/me") +async def get_my_swipe_events( + limit: int = Query(50, ge=1, le=500), + token: str = Depends(require_user_token), +): + """Return recent swipe events for the current user.""" + supabase = get_admin_client() + user_id = resolve_current_user_id(token) + + try: + response = ( + supabase.table("swipe_interactions") + .select("*") + .eq("actor_user_id", user_id) + .order("created_at", desc=True) + .limit(limit) + .execute() + ) + events = response.data or [] + return {"status": "success", "count": len(events), "data": events} + except Exception as e: + err = str(e).lower() + if "swipe_interactions" in err and "does not exist" in err: + raise HTTPException(status_code=503, detail="Swipe storage not configured. Run migration 004_swipe_interactions.sql.") + raise HTTPException(status_code=500, detail=f"Failed to fetch swipe events: {e}") diff --git a/backend/app/routes/recommendations.py b/backend/app/routes/recommendations.py index a7ea67c..ebea152 100644 --- a/backend/app/routes/recommendations.py +++ b/backend/app/routes/recommendations.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from typing import Any, Dict, List, Optional -from app.ai.recommender import score_listings +from app.services import ml_client from app.services.listing_payloads import hydrate_listing_image_collection from app.services.location_matching import filter_listings_for_location from app.dependencies.auth import require_user_token @@ -199,7 +199,7 @@ async def get_recommendations( ) try: - scored = score_listings(user, listings, top_n=len(listings)) + scored = await ml_client.score_listings(user, listings, top_n=len(listings)) except Exception as e: print(f"[recommendations] scoring error: {e}") raise HTTPException(status_code=500, detail="Recommendation scoring failed. Please try again.") diff --git a/backend/app/routes/stable_matching.py b/backend/app/routes/stable_matching.py index a1799d7..368d753 100644 --- a/backend/app/routes/stable_matching.py +++ b/backend/app/routes/stable_matching.py @@ -20,8 +20,8 @@ from datetime import datetime from pydantic import BaseModel, Field import logging -import os +from app.config import settings from app.dependencies.supabase import get_admin_client from app.models import ListingResponse, RoommateGroupResponse from app.services.location_matching import cities_match, filter_listings_for_location @@ -94,16 +94,8 @@ def _fetch_all_active_groups(supabase) -> List[Dict]: return groups -def _env_bool(name: str, default: bool = False) -> bool: - value = os.getenv(name) - if value is None: - return default - return str(value).strip().lower() in {"1", "true", "yes", "on"} - - def _stable_writes_enabled() -> bool: - # Phase 3B default: stable matching is read-only legacy. - return _env_bool("PADLY_STABLE_GROUP_LISTING_WRITES_ENABLED", default=False) + return settings.padly_stable_group_listing_writes_enabled def _require_stable_writes_enabled() -> None: diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 0d0fb32..ba39cc8 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -2,7 +2,9 @@ Services package Business logic and external service clients -Import concrete modules directly, e.g. `from app.services.supabase_client import SupabaseHTTPClient`. +Supabase access is consolidated through the facade in +``app.services.supabase_client.SupabaseHTTPClient`` (async CRUD) or +``app.dependencies.supabase.get_admin_client`` (sync supabase-py ``Client``). """ __all__: list[str] = [] diff --git a/backend/app/services/auth_helpers.py b/backend/app/services/auth_helpers.py new file mode 100644 index 0000000..2656904 --- /dev/null +++ b/backend/app/services/auth_helpers.py @@ -0,0 +1,84 @@ +""" +Shared authentication / authorization helpers used across multiple route modules. + +Centralises the ``auth-user → users.id`` resolution and group-membership +guard that were previously duplicated in ``groups.py`` and ``interactions.py``. +""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import HTTPException + +from app.dependencies.auth import resolve_auth_user +from app.dependencies.supabase import get_admin_client + + +def resolve_current_user_id(token: str) -> str: + """Map an authenticated JWT to the internal ``users.id`` UUID. + + Tries ``users.auth_id`` first, then falls back to ``users.id`` for + legacy rows where the two happen to match. + """ + supabase = get_admin_client() + auth_user = resolve_auth_user(supabase, token) + auth_user_id = auth_user.id + + user_record = ( + supabase.table("users") + .select("id") + .eq("auth_id", auth_user_id) + .limit(1) + .execute() + ) + if user_record.data: + return user_record.data[0]["id"] + + fallback_record = ( + supabase.table("users") + .select("id") + .eq("id", auth_user_id) + .limit(1) + .execute() + ) + if fallback_record.data: + return fallback_record.data[0]["id"] + + raise HTTPException(status_code=404, detail="User profile not found") + + +def require_group_membership(group_id: str, user_id: str) -> None: + """Raise 403 unless *user_id* is an accepted member of *group_id*.""" + supabase = get_admin_client() + membership = ( + supabase.table("group_members") + .select("group_id") + .eq("group_id", group_id) + .eq("user_id", user_id) + .eq("status", "accepted") + .limit(1) + .execute() + ) + if not membership.data: + raise HTTPException( + status_code=403, detail="You are not a member of this group" + ) + + +def safe_float(value: Any, default: float = 0.0) -> float: + try: + if value is None: + return default + return float(value) + except (TypeError, ValueError): + return default + + +def safe_int(value: Any, default: int = 0) -> int: + try: + if value is None: + return default + return int(float(value)) + except (TypeError, ValueError): + return default diff --git a/backend/app/services/ml_client.py b/backend/app/services/ml_client.py new file mode 100644 index 0000000..77d017b --- /dev/null +++ b/backend/app/services/ml_client.py @@ -0,0 +1,218 @@ +""" +ML scoring / embedding client. + +The API server uses this module instead of importing ``app.ai.recommender`` +directly. Behaviour depends on ``settings.ml_service_url``: + +* **HTTP mode** (``ML_SERVICE_URL`` is set) — forwards requests to the + dedicated ML inference service. TensorFlow is never loaded in the API + process. +* **In-process fallback** (no URL) — lazily imports ``app.ai.recommender`` + so the existing single-process workflow keeps working during local + development. + +All public functions are ``async`` so callers use a single interface +regardless of the backing implementation. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import numpy as np + +from app.config import settings + +_http_client = None + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _ml_service_configured() -> bool: + return bool(getattr(settings, "ml_service_url", "")) + + +def _get_http_client(): + global _http_client + if _http_client is None: + import httpx + + _http_client = httpx.AsyncClient( + base_url=settings.ml_service_url, + timeout=60.0, + ) + return _http_client + + +def _fetch_listings_for_embedding(listing_ids: List[str]) -> Dict[str, Dict[str, Any]]: + """Load listing rows with fields needed by item-tower encoding.""" + if not listing_ids: + return {} + from app.dependencies.supabase import get_admin_client + + supabase = get_admin_client() + out: Dict[str, Dict[str, Any]] = {} + chunk_size = 200 + for i in range(0, len(listing_ids), chunk_size): + chunk = listing_ids[i : i + chunk_size] + response = ( + supabase.table("listings") + .select( + "id, price_per_month, number_of_bedrooms, number_of_bathrooms, area_sqft, " + "furnished, utilities_included, amenities, latitude, longitude, property_type" + ) + .in_("id", chunk) + .execute() + ) + for row in response.data or []: + rid = row.get("id") + if rid is not None: + out[str(rid)] = row + return out + + +def _l2_normalize(v: np.ndarray) -> np.ndarray: + x = np.asarray(v, dtype=np.float32).reshape(-1) + n = float(np.linalg.norm(x)) + if n <= 1e-12: + return x + return (x / n).astype(np.float32) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +async def score_listings( + user: Dict[str, Any], + listings: List[Dict[str, Any]], + top_n: int = 50, +) -> List[Dict[str, Any]]: + """Score and rank listings for a user. + + Returns listing dicts augmented with ``match_score``, ``rule_score``, + ``behavior_score``, ``ml_score``, etc. + """ + if _ml_service_configured(): + try: + client = _get_http_client() + resp = await client.post( + "/score-listings", + json={"user": user, "listings": listings, "top_n": top_n}, + ) + resp.raise_for_status() + return resp.json()["scored"] + except Exception as e: + print(f"[ml_client] HTTP scoring failed, falling back to in-process: {e}") + + from app.ai.recommender import score_listings as _score + + return _score(user, listings, top_n) + + +async def item_tower_latent_batch( + listings: List[Dict[str, Any]], +) -> Optional[np.ndarray]: + """Batch item-tower forward pass. Returns (N, embed_dim) or None.""" + if _ml_service_configured(): + try: + client = _get_http_client() + resp = await client.post( + "/embed/item-tower-batch", + json={"listings": listings}, + ) + resp.raise_for_status() + data = resp.json() + if data["embeddings"] is None: + return None + return np.array(data["embeddings"], dtype=np.float32) + except Exception as e: + print(f"[ml_client] HTTP embedding failed, falling back to in-process: {e}") + + from app.ai.recommender import item_tower_latent_batch as _batch + + return _batch(listings) + + +async def embedding_inference_available() -> bool: + """True when the ML model and tower sub-models are usable.""" + if _ml_service_configured(): + try: + client = _get_http_client() + resp = await client.get("/status") + resp.raise_for_status() + return resp.json().get("embedding_available", False) + except Exception: + return False + + from app.ai.recommender import embedding_inference_available as _avail + + return _avail() + + +async def mean_taste_item_embedding( + user_id: str, + *, + k: int = 50, + days: int = 180, + max_events: int = 2000, +) -> Optional[np.ndarray]: + """Mean L2-normalised item-tower embedding over a user's recent likes. + + Data fetching happens on the API side; only the neural forward pass + is delegated to the ML service (or the in-process fallback). + """ + if not await embedding_inference_available(): + return None + + from app.services.behavior_features import POSITIVE_ACTIONS, _fetch_user_swipes + + events = _fetch_user_swipes(user_id=user_id, days=days, max_events=max_events) + positive = [ + e + for e in events + if e.get("action") in POSITIVE_ACTIONS and e.get("listing_id") + ] + seen: set[str] = set() + listing_ids: List[str] = [] + for e in positive: + lid = str(e.get("listing_id")) + if lid in seen: + continue + seen.add(lid) + listing_ids.append(lid) + if len(listing_ids) >= max(1, k): + break + + if not listing_ids: + return None + + rows = _fetch_listings_for_embedding(listing_ids) + ordered = [rows[lid] for lid in listing_ids if lid in rows] + if not ordered: + return None + + embs = await item_tower_latent_batch(ordered) + if embs is None or embs.size == 0: + return None + + return _l2_normalize(np.mean(embs, axis=0)) + + +def taste_similarity_from_mean_embeddings( + embedding_a: Optional[np.ndarray], + embedding_b: Optional[np.ndarray], +) -> Optional[float]: + """Cosine similarity mapped from [-1, 1] to [0, 1]. None if either is missing.""" + if embedding_a is None or embedding_b is None: + return None + a = np.asarray(embedding_a, dtype=np.float64).reshape(-1) + b = np.asarray(embedding_b, dtype=np.float64).reshape(-1) + na = float(np.linalg.norm(a)) + nb = float(np.linalg.norm(b)) + if na <= 1e-12 or nb <= 1e-12: + return 0.5 + cos = float(np.dot(a, b) / (na * nb)) + return max(0.0, min(1.0, (cos + 1.0) / 2.0)) diff --git a/backend/app/services/roommate_suggestions.py b/backend/app/services/roommate_suggestions.py index f3e24be..be278d3 100644 --- a/backend/app/services/roommate_suggestions.py +++ b/backend/app/services/roommate_suggestions.py @@ -599,12 +599,11 @@ async def get_roommate_suggestions( out_limit = max(1, min(limit, MAX_RESULT_LIMIT)) like_k = max(1, min(int(embedding_like_cap), MAX_EMBEDDING_LIKE_CAP)) - _rec: Any = None + from app.services import ml_client as _mlc + seeker_taste_vec: Optional[Any] = None if blend_embedding and mode_norm == SUGGESTION_MODE_ML: - from app.ai import recommender as _rec - - seeker_taste_vec = _rec.mean_taste_item_embedding( + seeker_taste_vec = await _mlc.mean_taste_item_embedding( seeker_id, k=like_k, days=180, max_events=2000 ) @@ -741,9 +740,9 @@ async def get_roommate_suggestions( ) ls = lifestyle_similarity_user_user(seeker_prefs, row) emb_sim: Optional[float] = None - if blend_embedding and seeker_taste_vec is not None and _rec is not None: - cand_taste = _rec.mean_taste_item_embedding(cid, k=like_k, days=180, max_events=2000) - emb_sim = _rec.taste_similarity_from_mean_embeddings(seeker_taste_vec, cand_taste) + if blend_embedding and seeker_taste_vec is not None: + cand_taste = await _mlc.mean_taste_item_embedding(cid, k=like_k, days=180, max_events=2000) + emb_sim = _mlc.taste_similarity_from_mean_embeddings(seeker_taste_vec, cand_taste) ls_eff = blend_lifestyle_with_embedding(ls, emb_sim) if blend_embedding else ls if beh.get("cold_cold"): diff --git a/backend/app/services/supabase_client.py b/backend/app/services/supabase_client.py index 995dc34..c177dd8 100644 --- a/backend/app/services/supabase_client.py +++ b/backend/app/services/supabase_client.py @@ -1,276 +1,208 @@ """ -Supabase HTTP Client -Direct HTTP calls to Supabase PostgREST API using httpx +Supabase client facade. + +Provides an async CRUD helper (``SupabaseHTTPClient``) that delegates to the +single supabase-py ``Client`` rather than hand-rolling raw httpx requests. +All existing call-sites keep their ``await client.select(…)`` interface +unchanged. """ -import httpx -from typing import Optional, Dict, Any, List +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional + from fastapi import HTTPException -from app.db import SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _apply_filters(query: Any, filters: Dict[str, str]) -> Any: + """Translate PostgREST query-param style filters to supabase-py calls.""" + for column, expr in filters.items(): + if column == "or": + value = expr.strip() + if value.startswith("(") and value.endswith(")"): + value = value[1:-1] + query = query.or_(value) + else: + op, _, value = expr.partition(".") + query = query.filter(column, op, value) + return query + + +# --------------------------------------------------------------------------- +# Public facade +# --------------------------------------------------------------------------- + class SupabaseHTTPClient: + """Thin async facade over the supabase-py ``Client``. + + Construction mirrors the original interface: + + * ``SupabaseHTTPClient(is_admin=True)`` — service-role (bypasses RLS) + * ``SupabaseHTTPClient(token=jwt)`` — user-scoped (respects RLS) """ - HTTP client for Supabase PostgREST API. - - Uses httpx for async HTTP requests directly to PostgREST endpoints. - Supports both user-scoped (with JWT) and admin (service role) operations. - """ - - def __init__(self, token: Optional[str] = None, is_admin: bool = False): - """ - Initialize Supabase HTTP client. - - Args: - token: Optional user JWT token for RLS enforcement - is_admin: If True, use service role key (bypasses RLS) - """ - self.base_url = f"{SUPABASE_URL}/rest/v1" - - # Determine which API key to use - api_key = SUPABASE_SERVICE_KEY if is_admin else SUPABASE_ANON_KEY - - # Set headers - self.headers = { - "apikey": api_key, - "Content-Type": "application/json", - "Prefer": "return=representation" # Return created/updated records - } - - # Set Authorization header + + def __init__( + self, + token: Optional[str] = None, + is_admin: bool = False, + ) -> None: + from app.dependencies.supabase import get_admin_client, get_user_client + if is_admin: - # Admin uses service role key for auth - self.headers["Authorization"] = f"Bearer {SUPABASE_SERVICE_KEY}" - elif token: - # User routes use provided JWT - self.headers["Authorization"] = f"Bearer {token}" + self._client = get_admin_client() else: - # Anonymous uses anon key - self.headers["Authorization"] = f"Bearer {SUPABASE_ANON_KEY}" - + self._client = get_user_client(token) + + # -- SELECT -------------------------------------------------------- + async def select( - self, - table: str, + self, + table: str, columns: str = "*", filters: Optional[Dict[str, Any]] = None, order: Optional[str] = None, limit: Optional[int] = None, - offset: Optional[int] = None + offset: Optional[int] = None, ) -> List[Dict[str, Any]]: - """ - SELECT query on a table. - - Args: - table: Table name - columns: Columns to select (default: "*") - filters: Filter conditions (e.g., {"id": "eq.123", "status": "eq.active"}) - order: Order by (e.g., "created_at.desc") - limit: Limit results - offset: Offset results - - Returns: - List of records - """ - url = f"{self.base_url}/{table}" - - params = {"select": columns} - - if filters: - params.update(filters) - - if order: - params["order"] = order - - if limit: - params["limit"] = limit - - if offset: - params["offset"] = offset - + def _run() -> List[Dict[str, Any]]: + q = self._client.table(table).select(columns) + if filters: + q = _apply_filters(q, filters) + if order: + col, _, direction = order.partition(".") + q = q.order(col, desc=(direction == "desc")) + if limit is not None: + q = q.limit(limit) + if offset is not None: + q = q.offset(offset) + return q.execute().data or [] + try: - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=self.headers, params=params) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - raise HTTPException( - status_code=e.response.status_code, - detail=f"Supabase error: {e.response.text}" - ) + return await asyncio.to_thread(_run) + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=f"Request failed: {str(e)}") - + raise HTTPException( + status_code=500, detail=f"Supabase query failed: {e}" + ) from e + async def select_one( self, table: str, id_value: str, id_column: str = "id", - columns: str = "*" + columns: str = "*", ) -> Optional[Dict[str, Any]]: - """ - SELECT a single record by ID. - - Args: - table: Table name - id_value: ID value - id_column: ID column name (default: "id") - columns: Columns to select - - Returns: - Single record or None - """ - filters = {id_column: f"eq.{id_value}"} - results = await self.select(table, columns=columns, filters=filters, limit=1) + results = await self.select( + table, + columns=columns, + filters={id_column: f"eq.{id_value}"}, + limit=1, + ) return results[0] if results else None - + + # -- INSERT -------------------------------------------------------- + async def insert( - self, - table: str, - data: Dict[str, Any] + self, table: str, data: Dict[str, Any] ) -> Dict[str, Any]: - """ - INSERT a record. - - Args: - table: Table name - data: Record data - - Returns: - Created record - """ - url = f"{self.base_url}/{table}" - + def _run() -> Dict[str, Any]: + result = self._client.table(table).insert(data).execute() + rows = result.data or [] + return rows[0] if rows else {} + try: - async with httpx.AsyncClient() as client: - response = await client.post(url, headers=self.headers, json=data) - response.raise_for_status() - result = response.json() - return result[0] if isinstance(result, list) and result else result - except httpx.HTTPStatusError as e: - raise HTTPException( - status_code=e.response.status_code, - detail=f"Supabase error: {e.response.text}" - ) + return await asyncio.to_thread(_run) + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=f"Insert failed: {str(e)}") - + raise HTTPException( + status_code=500, detail=f"Insert failed: {e}" + ) from e + + # -- UPDATE -------------------------------------------------------- + async def update( self, table: str, id_value: str, data: Dict[str, Any], - id_column: str = "id" + id_column: str = "id", ) -> Dict[str, Any]: - """ - UPDATE a record by ID. - - Args: - table: Table name - id_value: ID value - data: Updated data - id_column: ID column name - - Returns: - Updated record - """ - url = f"{self.base_url}/{table}" - params = {id_column: f"eq.{id_value}"} - + def _run() -> Dict[str, Any]: + result = ( + self._client.table(table) + .update(data) + .eq(id_column, id_value) + .execute() + ) + rows = result.data or [] + return rows[0] if rows else {} + try: - async with httpx.AsyncClient() as client: - response = await client.patch(url, headers=self.headers, params=params, json=data) - response.raise_for_status() - result = response.json() - if isinstance(result, list) and not result: - raise HTTPException( - status_code=404, - detail=f"No matching record found in {table} to update" - ) - return result[0] if isinstance(result, list) else result + return await asyncio.to_thread(_run) except HTTPException: raise - except httpx.HTTPStatusError as e: - raise HTTPException( - status_code=e.response.status_code, - detail=f"Supabase error: {e.response.text}" - ) except Exception as e: - raise HTTPException(status_code=500, detail=f"Update failed: {str(e)}") - + raise HTTPException( + status_code=500, detail=f"Update failed: {e}" + ) from e + + # -- DELETE -------------------------------------------------------- + async def delete( self, table: str, id_value: str, - id_column: str = "id" + id_column: str = "id", ) -> bool: - """ - DELETE a record by ID. - - Args: - table: Table name - id_value: ID value - id_column: ID column name - - Returns: - True if successful - """ - url = f"{self.base_url}/{table}" - params = {id_column: f"eq.{id_value}"} - + def _run() -> bool: + self._client.table(table).delete().eq(id_column, id_value).execute() + return True + try: - async with httpx.AsyncClient() as client: - response = await client.delete(url, headers=self.headers, params=params) - response.raise_for_status() - result = response.json() - if isinstance(result, list) and not result: - raise HTTPException( - status_code=404, - detail=f"No matching record found in {table} to delete" - ) - return True + return await asyncio.to_thread(_run) except HTTPException: raise - except httpx.HTTPStatusError as e: - raise HTTPException( - status_code=e.response.status_code, - detail=f"Supabase error: {e.response.text}" - ) except Exception as e: - raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}") - + raise HTTPException( + status_code=500, detail=f"Delete failed: {e}" + ) from e + + # -- COUNT --------------------------------------------------------- + async def count( self, table: str, - filters: Optional[Dict[str, Any]] = None + filters: Optional[Dict[str, Any]] = None, ) -> int: - """ - COUNT records in a table. - - Args: - table: Table name - filters: Optional filter conditions - - Returns: - Count of records - """ - url = f"{self.base_url}/{table}" - - # Add Prefer header for count - headers = {**self.headers, "Prefer": "count=exact"} - params = {"select": "id"} # Minimal select for count - - if filters: - params.update(filters) - + def _run() -> int: + q = self._client.table(table).select("*", count="exact").limit(0) + if filters: + q = _apply_filters(q, filters) + return q.execute().count or 0 + try: - async with httpx.AsyncClient() as client: - response = await client.head(url, headers=headers, params=params) - response.raise_for_status() - - # Get count from Content-Range header - content_range = response.headers.get("content-range", "") - if "/" in content_range: - return int(content_range.split("/")[1]) - return 0 + return await asyncio.to_thread(_run) + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=f"Count failed: {str(e)}") + raise HTTPException( + status_code=500, detail=f"Count failed: {e}" + ) from e + + +# --------------------------------------------------------------------------- +# Convenience alias expected by group_rematching_service +# --------------------------------------------------------------------------- + +def get_supabase_admin_client(): + """Return the shared admin supabase-py ``Client``.""" + from app.dependencies.supabase import get_admin_client + + return get_admin_client() diff --git a/backend/ml_service/Dockerfile b/backend/ml_service/Dockerfile new file mode 100644 index 0000000..b1e50df --- /dev/null +++ b/backend/ml_service/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.10-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY ml_service/requirements.txt ml_service/requirements.txt +RUN pip install --no-cache-dir -r ml_service/requirements.txt + +# The ML service imports from the app package (shared code in same monorepo), +# so we copy both app/ and ml_service/ into the image. +COPY app/ app/ +COPY ml_service/ ml_service/ + +EXPOSE 8001 + +CMD ["uvicorn", "ml_service.main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/backend/ml_service/__init__.py b/backend/ml_service/__init__.py new file mode 100644 index 0000000..832a034 --- /dev/null +++ b/backend/ml_service/__init__.py @@ -0,0 +1 @@ +"""ML inference service — runs TensorFlow model separately from the API.""" diff --git a/backend/ml_service/main.py b/backend/ml_service/main.py new file mode 100644 index 0000000..948417a --- /dev/null +++ b/backend/ml_service/main.py @@ -0,0 +1,95 @@ +""" +Padly ML Inference Service + +Standalone FastAPI app that loads the Two-Tower model and exposes +scoring / embedding endpoints over HTTP. The main API server calls +these endpoints via ``app.services.ml_client`` so that TensorFlow +never needs to be installed in the API image. + +Endpoints +--------- +POST /score-listings — rank listings for a user (hard-filter + blend) +POST /embed/item-tower-batch — batch item-tower forward pass +GET /status — model / tower availability +GET /health — liveness probe +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from fastapi import FastAPI +from pydantic import BaseModel, Field + +app = FastAPI( + title="Padly ML Service", + version="1.0.0", + description="Two-Tower inference service for Padly recommendations", +) + + +# --------------------------------------------------------------------------- +# Request / response schemas +# --------------------------------------------------------------------------- + +class ScoreListingsRequest(BaseModel): + user: Dict[str, Any] + listings: List[Dict[str, Any]] + top_n: int = Field(default=50, ge=1) + + +class ScoreListingsResponse(BaseModel): + scored: List[Dict[str, Any]] + + +class ItemTowerBatchRequest(BaseModel): + listings: List[Dict[str, Any]] + + +class ItemTowerBatchResponse(BaseModel): + embeddings: Optional[List[List[float]]] = None + + +class StatusResponse(BaseModel): + model_loaded: bool + embedding_available: bool + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@app.post("/score-listings", response_model=ScoreListingsResponse) +def score_listings_endpoint(req: ScoreListingsRequest): + from app.ai.recommender import score_listings + + scored = score_listings(req.user, req.listings, top_n=req.top_n) + return ScoreListingsResponse(scored=scored) + + +@app.post("/embed/item-tower-batch", response_model=ItemTowerBatchResponse) +def item_tower_batch_endpoint(req: ItemTowerBatchRequest): + from app.ai.recommender import item_tower_latent_batch + + if not req.listings: + return ItemTowerBatchResponse(embeddings=None) + + result = item_tower_latent_batch(req.listings) + if result is None: + return ItemTowerBatchResponse(embeddings=None) + + return ItemTowerBatchResponse(embeddings=result.tolist()) + + +@app.get("/status", response_model=StatusResponse) +def status_endpoint(): + from app.ai.recommender import embedding_inference_available, _model, _meta + + model_loaded = _model is not None and _meta is not None + emb_avail = embedding_inference_available() + return StatusResponse(model_loaded=model_loaded, embedding_available=emb_avail) + + +@app.get("/health") +def health_endpoint(): + return {"status": "healthy", "service": "Padly ML Service"} diff --git a/backend/ml_service/requirements.txt b/backend/ml_service/requirements.txt new file mode 100644 index 0000000..e4c2b89 --- /dev/null +++ b/backend/ml_service/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.118.0 +uvicorn==0.37.0 +pydantic==2.12.3 +numpy>=1.26.0 +tensorflow==2.20.0 +geonamescache==3.0.0 +python-dotenv==1.0.0 +pydantic-settings>=2.7.0 +supabase==2.21.1 +supabase-auth==2.22.0 +postgrest==2.22.0 +realtime==2.22.0 +storage3==2.22.0 +supabase-functions==2.22.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 3866cd9..21d5646 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,9 +6,12 @@ postgrest==2.22.0 realtime==2.22.0 storage3==2.22.0 supabase-functions==2.22.0 -python-dotenv==1.0.0 pydantic[email]==2.12.3 +pydantic-settings>=2.7.0 +python-dotenv==1.0.0 email-validator==2.2.0 httpx==0.27.2 tensorflow==2.20.0 geonamescache==3.0.0 +pytest>=8.0.0 +pytest-asyncio>=0.24.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..fe1f624 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,57 @@ +""" +Shared pytest fixtures for Padly backend tests. + +Environment bootstrap +--------------------- +``os.environ.setdefault`` calls below guarantee that ``app.config.Settings`` +can be constructed even when no ``.env`` file exists (e.g. in CI). The +values are the standard Supabase *local-dev* JWTs — they embed the correct +``role`` claims but carry no real secrets. +""" + +from __future__ import annotations + +import os + +# Must run BEFORE any app import triggers Settings construction. +os.environ.setdefault("SUPABASE_URL", "http://localhost:54321") +os.environ.setdefault( + "SUPABASE_ANON_KEY", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9." + "CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", +) +os.environ.setdefault( + "SUPABASE_SERVICE_KEY", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0." + "EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IQ", +) +os.environ.setdefault("ADMIN_SECRET", "test-admin-secret-for-ci") + +from unittest.mock import MagicMock # noqa: E402 + +import pytest # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + + +@pytest.fixture() +def client() -> TestClient: + """Vanilla FastAPI ``TestClient`` — no dependency overrides.""" + from app.main import app + + return TestClient(app, raise_server_exceptions=False) + + +@pytest.fixture() +def mock_supabase_admin(monkeypatch) -> MagicMock: + """Replace the admin ``Client`` singleton so no network I/O occurs. + + Patches every import-site that captured a reference to + ``supabase_admin`` at module level. + """ + mock = MagicMock() + monkeypatch.setattr("app.db.supabase_admin", mock) + monkeypatch.setattr("app.db.supabase", mock) + monkeypatch.setattr("app.dependencies.supabase.supabase_admin", mock) + return mock diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..9e1d8bb --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,345 @@ +"""FastAPI TestClient tests for critical routes and auth guards.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException + + +# --------------------------------------------------------------------------- +# Root / health — basic smoke tests that the app boots +# --------------------------------------------------------------------------- + +class TestRootEndpoints: + def test_root(self, client): + r = client.get("/") + assert r.status_code == 200 + body = r.json() + assert body["version"] == "1.0.0" + assert "health" in body + + def test_health(self, client): + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "healthy" + + +# --------------------------------------------------------------------------- +# Admin secret guard +# --------------------------------------------------------------------------- + +class TestAdminGuard: + def test_missing_secret_header(self, client): + r = client.get("/api/admin/users") + assert r.status_code in (401, 503) + + def test_wrong_secret(self, client): + r = client.get( + "/api/admin/users", + headers={"X-Admin-Secret": "definitely-wrong"}, + ) + assert r.status_code == 401 + + def test_valid_secret_passes_guard(self, client): + from app.config import settings + + if not settings.admin_secret: + pytest.skip("ADMIN_SECRET not configured") + + r = client.get( + "/api/admin/users", + headers={"X-Admin-Secret": settings.admin_secret}, + ) + # Guard passed → route runs (may 500 without live Supabase) + assert r.status_code not in (401, 503) + + +# --------------------------------------------------------------------------- +# Token extraction dependencies (unit tests) +# --------------------------------------------------------------------------- + +class TestTokenDependencies: + @pytest.mark.asyncio + async def test_get_user_token_extracts_bearer(self): + from app.dependencies.auth import get_user_token + + assert await get_user_token("Bearer my-jwt") == "my-jwt" + + @pytest.mark.asyncio + async def test_get_user_token_none_without_header(self): + from app.dependencies.auth import get_user_token + + assert await get_user_token(None) is None + + @pytest.mark.asyncio + async def test_get_user_token_rejects_non_bearer(self): + from app.dependencies.auth import get_user_token + + with pytest.raises(HTTPException) as exc: + await get_user_token("Basic abc123") + assert exc.value.status_code == 401 + + @pytest.mark.asyncio + async def test_get_user_token_rejects_empty_bearer(self): + from app.dependencies.auth import get_user_token + + with pytest.raises(HTTPException) as exc: + await get_user_token("Bearer ") + assert exc.value.status_code == 401 + + @pytest.mark.asyncio + async def test_require_user_token_raises_without_token(self): + from app.dependencies.auth import require_user_token + + with pytest.raises(HTTPException) as exc: + await require_user_token(None) + assert exc.value.status_code == 401 + + +# --------------------------------------------------------------------------- +# Request-body validation (no Supabase needed) +# --------------------------------------------------------------------------- + +class TestRequestValidation: + def test_signup_invalid_email(self, client): + r = client.post( + "/api/auth/signup", + json={"email": "not-an-email", "password": "s3cr3t", "full_name": "X"}, + ) + assert r.status_code == 422 + + def test_signup_missing_fields(self, client): + r = client.post("/api/auth/signup", json={"email": "a@b.com"}) + assert r.status_code == 422 + + def test_signin_missing_fields(self, client): + r = client.post("/api/auth/signin", json={}) + assert r.status_code == 422 + + def test_refresh_missing_token(self, client): + r = client.post("/api/auth/refresh", json={}) + assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# Protected route access (no mocked Supabase — just verifies the 401 guard) +# --------------------------------------------------------------------------- + +class TestProtectedRoutes: + def test_me_requires_token(self, client): + r = client.get("/api/auth/me") + assert r.status_code == 401 + + def test_users_list_requires_token(self, client): + r = client.get("/api/users") + assert r.status_code == 401 + + def test_signout_requires_token(self, client): + r = client.post("/api/auth/signout") + assert r.status_code == 401 + + +# --------------------------------------------------------------------------- +# Settings sanity +# --------------------------------------------------------------------------- + +class TestSettings: + def test_settings_loaded(self): + from app.config import settings + + assert settings.supabase_url + assert settings.supabase_anon_key + assert settings.supabase_service_key + + def test_is_dev_is_bool(self): + from app.config import settings + + assert isinstance(settings.is_dev, bool) + + def test_bool_flags_are_bools(self): + from app.config import settings + + assert isinstance(settings.padly_group_neural_ranking_enabled, bool) + assert isinstance(settings.padly_group_neural_kill_switch, bool) + assert isinstance(settings.padly_stable_group_listing_writes_enabled, bool) + + +# --------------------------------------------------------------------------- +# Supabase facade (SupabaseHTTPClient) unit tests +# --------------------------------------------------------------------------- + +class TestSupabaseFacade: + """Verify the rewritten facade delegates to supabase-py correctly.""" + + def _make_facade(self, mock_client): + """Build a facade whose internal client is *mock_client*.""" + with patch( + "app.dependencies.supabase.get_admin_client", + return_value=mock_client, + ): + from app.services.supabase_client import SupabaseHTTPClient + + return SupabaseHTTPClient(is_admin=True) + + @pytest.mark.asyncio + async def test_select_basic(self): + mock_client = MagicMock() + result = MagicMock(data=[{"id": "1"}]) + mock_client.table.return_value.select.return_value.execute.return_value = ( + result + ) + + facade = self._make_facade(mock_client) + rows = await facade.select("users") + + assert rows == [{"id": "1"}] + mock_client.table.assert_called_with("users") + + @pytest.mark.asyncio + async def test_select_with_filter(self): + mock_client = MagicMock() + result = MagicMock(data=[{"id": "2"}]) + mock_client.table.return_value.select.return_value.filter.return_value.execute.return_value = ( + result + ) + + facade = self._make_facade(mock_client) + rows = await facade.select("users", filters={"status": "eq.active"}) + + assert rows == [{"id": "2"}] + mock_client.table.return_value.select.return_value.filter.assert_called_with( + "status", "eq", "active" + ) + + @pytest.mark.asyncio + async def test_select_with_or_filter(self): + mock_client = MagicMock() + result = MagicMock(data=[{"id": "3"}]) + mock_client.table.return_value.select.return_value.or_.return_value.execute.return_value = ( + result + ) + + facade = self._make_facade(mock_client) + rows = await facade.select( + "users", + filters={"or": "(name.ilike.*test*,email.ilike.*test*)"}, + ) + + assert rows == [{"id": "3"}] + mock_client.table.return_value.select.return_value.or_.assert_called_with( + "name.ilike.*test*,email.ilike.*test*" + ) + + @pytest.mark.asyncio + async def test_select_empty(self): + mock_client = MagicMock() + result = MagicMock(data=None) + mock_client.table.return_value.select.return_value.execute.return_value = ( + result + ) + + facade = self._make_facade(mock_client) + rows = await facade.select("users") + assert rows == [] + + @pytest.mark.asyncio + async def test_select_one_found(self): + mock_client = MagicMock() + result = MagicMock(data=[{"id": "10", "name": "Alice"}]) + mock_client.table.return_value.select.return_value.filter.return_value.limit.return_value.execute.return_value = ( + result + ) + + facade = self._make_facade(mock_client) + row = await facade.select_one("users", "10") + assert row == {"id": "10", "name": "Alice"} + + @pytest.mark.asyncio + async def test_select_one_not_found(self): + mock_client = MagicMock() + result = MagicMock(data=[]) + mock_client.table.return_value.select.return_value.filter.return_value.limit.return_value.execute.return_value = ( + result + ) + + facade = self._make_facade(mock_client) + row = await facade.select_one("users", "999") + assert row is None + + @pytest.mark.asyncio + async def test_insert(self): + mock_client = MagicMock() + result = MagicMock(data=[{"id": "new", "name": "Bob"}]) + mock_client.table.return_value.insert.return_value.execute.return_value = ( + result + ) + + facade = self._make_facade(mock_client) + row = await facade.insert("users", {"name": "Bob"}) + + assert row == {"id": "new", "name": "Bob"} + mock_client.table.return_value.insert.assert_called_with({"name": "Bob"}) + + @pytest.mark.asyncio + async def test_update(self): + mock_client = MagicMock() + result = MagicMock(data=[{"id": "1", "name": "Updated"}]) + mock_client.table.return_value.update.return_value.eq.return_value.execute.return_value = ( + result + ) + + facade = self._make_facade(mock_client) + row = await facade.update("users", "1", {"name": "Updated"}) + + assert row == {"id": "1", "name": "Updated"} + + @pytest.mark.asyncio + async def test_delete(self): + mock_client = MagicMock() + mock_client.table.return_value.delete.return_value.eq.return_value.execute.return_value = ( + MagicMock() + ) + + facade = self._make_facade(mock_client) + ok = await facade.delete("users", "1") + assert ok is True + + @pytest.mark.asyncio + async def test_count(self): + mock_client = MagicMock() + result = MagicMock(count=42) + mock_client.table.return_value.select.return_value.limit.return_value.execute.return_value = ( + result + ) + + facade = self._make_facade(mock_client) + n = await facade.count("users") + assert n == 42 + + @pytest.mark.asyncio + async def test_select_propagates_error(self): + mock_client = MagicMock() + mock_client.table.return_value.select.side_effect = RuntimeError("boom") + + facade = self._make_facade(mock_client) + + with pytest.raises(HTTPException) as exc: + await facade.select("users") + assert exc.value.status_code == 500 + + +# --------------------------------------------------------------------------- +# get_supabase_admin_client alias +# --------------------------------------------------------------------------- + +class TestAdminClientAlias: + def test_alias_returns_client(self): + mock = MagicMock() + with patch( + "app.dependencies.supabase.get_admin_client", return_value=mock + ): + from app.services.supabase_client import get_supabase_admin_client + + assert get_supabase_admin_client() is mock diff --git a/frontend/lib/authService.js b/frontend/lib/authService.js index 0c573d5..4197bcd 100644 --- a/frontend/lib/authService.js +++ b/frontend/lib/authService.js @@ -1,6 +1,7 @@ // Updated auth service to communicate with FastAPI backend import { createAppError, + isNetworkError, normalizeAuthErrorMessage, parseApiErrorResponse, } from './errorHandling'; @@ -48,7 +49,7 @@ export class AuthService { } catch (error) { if (error?.name === 'AppError') throw error; throw createAppError(normalizeAuthErrorMessage(error, { flow: 'signup' }), { - isNetworkError: true, + isNetworkError: isNetworkError(error), }); } } @@ -79,7 +80,7 @@ export class AuthService { } catch (error) { if (error?.name === 'AppError') throw error; throw createAppError(normalizeAuthErrorMessage(error, { flow: 'signin' }), { - isNetworkError: true, + isNetworkError: isNetworkError(error), }); } } diff --git a/run-dev.sh b/run-dev.sh index 5821101..aba0e51 100755 --- a/run-dev.sh +++ b/run-dev.sh @@ -77,6 +77,10 @@ BACKEND_PID=$! # Give backend a moment to start sleep 2 +# Next.js client code reads NEXT_PUBLIC_*; backend .env uses SUPABASE_* — bridge for local dev +export NEXT_PUBLIC_SUPABASE_URL="${NEXT_PUBLIC_SUPABASE_URL:-$SUPABASE_URL}" +export NEXT_PUBLIC_SUPABASE_ANON_KEY="${NEXT_PUBLIC_SUPABASE_ANON_KEY:-$SUPABASE_ANON_KEY}" + # Start frontend in background echo -e "${BLUE}[FRONTEND]${NC} Starting Next.js dev server on http://localhost:3000..." cd "$PROJECT_ROOT/frontend"