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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ jobs:
GHL_API_KEY: test-ghl-key-for-ci-only
GHL_LOCATION_ID: test-location-id-for-ci-only
run: |
pytest -m integration -v --tb=short --timeout=300 --timeout-method=thread
pytest -m "integration and not performance" -v --tb=short --timeout=300 --timeout-method=thread
security-scan:
name: Security Scanning
runs-on: ubuntu-latest
Expand Down
22 changes: 22 additions & 0 deletions ghl_real_estate_ai/api/enterprise/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ async def create_enterprise_tenant(self, tenant_data: Dict[str, Any]) -> Dict[st
],
}

except EnterpriseAuthError:
raise
except Exception as e:
logger.error(f"Failed to create enterprise tenant: {e}")
raise EnterpriseAuthError(
Expand All @@ -244,6 +246,8 @@ async def get_tenant_by_ontario_mills(self, ontario_mills: str) -> Optional[Dict
tenant_config = await self.cache_service.get(f"enterprise_tenant:{tenant_id}")
return tenant_config

except EnterpriseAuthError:
raise
except Exception as e:
logger.error(f"Error retrieving tenant for ontario_mills {ontario_mills}: {e}")
return None
Expand Down Expand Up @@ -280,6 +284,8 @@ async def update_tenant_configuration(self, tenant_id: str, updates: Dict[str, A
logger.info(f"Tenant {tenant_id} configuration updated")
return tenant_config

except EnterpriseAuthError:
raise
except Exception as e:
logger.error(f"Failed to update tenant {tenant_id}: {e}")
raise EnterpriseAuthError(
Expand Down Expand Up @@ -344,6 +350,8 @@ async def initiate_sso_login(self, ontario_mills: str, redirect_uri: str) -> Dic
"provider": sso_provider,
}

except EnterpriseAuthError:
raise
except Exception as e:
logger.error(f"Failed to initiate SSO login for ontario_mills {ontario_mills}: {e}")
raise EnterpriseAuthError(f"SSO initiation failed: {str(e)}", error_code="SSO_INITIATION_FAILED")
Expand Down Expand Up @@ -410,6 +418,8 @@ async def handle_sso_callback(self, code: str, state: str) -> Dict[str, Any]:
"tenant_id": tenant_id,
}

except EnterpriseAuthError:
raise
except Exception as e:
logger.error(f"SSO callback handling failed: {e}")
raise EnterpriseAuthError(f"SSO authentication failed: {str(e)}", error_code="SSO_AUTHENTICATION_FAILED")
Expand Down Expand Up @@ -453,9 +463,15 @@ async def validate_enterprise_token(self, token: str) -> Dict[str, Any]:
"permissions": user_session.get("permissions", []),
}

except jwt.ExpiredSignatureError:
# jwt.decode() verifies exp itself and raises this before the manual
# check above is reached, so map it explicitly to the granular code.
raise EnterpriseAuthError("Token expired", error_code="TOKEN_EXPIRED")
except jwt.InvalidTokenError as e:
logger.error(f"Invalid JWT token: {e}")
raise EnterpriseAuthError("Invalid token", error_code="INVALID_TOKEN")
except EnterpriseAuthError:
raise
except Exception as e:
logger.error(f"Token validation failed: {e}")
raise EnterpriseAuthError(f"Token validation failed: {str(e)}", error_code="TOKEN_VALIDATION_FAILED")
Expand Down Expand Up @@ -524,6 +540,8 @@ async def provision_enterprise_user(

return enterprise_user

except EnterpriseAuthError:
raise
except Exception as e:
logger.error(f"Failed to provision user {user_email} for tenant {tenant_id}: {e}")
raise EnterpriseAuthError(
Expand Down Expand Up @@ -570,6 +588,8 @@ async def update_user_roles(self, tenant_id: str, user_email: str, new_roles: Li

return user_data

except EnterpriseAuthError:
raise
except Exception as e:
logger.error(f"Failed to update user roles for {user_email}: {e}")
raise EnterpriseAuthError(
Expand Down Expand Up @@ -960,6 +980,8 @@ async def refresh_enterprise_token(self, refresh_token: str) -> Dict[str, Any]:
"expires_in": self.enterprise_token_expiry,
}

except EnterpriseAuthError:
raise
except Exception as e:
logger.error(f"Token refresh failed: {e}")
raise EnterpriseAuthError(f"Token refresh failed: {str(e)}", error_code="TOKEN_REFRESH_FAILED")
Expand Down
36 changes: 22 additions & 14 deletions ghl_real_estate_ai/api/middleware/domain_resolver_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ def __init__(
super().__init__(app)
self.db_pool = db_pool
self.cache = cache_service
self.default_agency_id = default_agency_id or settings.get("DEFAULT_AGENCY_ID")
self.default_agency_id = default_agency_id or getattr(settings, "DEFAULT_AGENCY_ID", None)

# Configuration
self.primary_domain = settings.get("PRIMARY_DOMAIN", "app.enterprisehub.com")
self.enable_subdomain_routing = settings.get("ENABLE_SUBDOMAIN_ROUTING", True)
self.force_https = settings.get("FORCE_HTTPS", True)
self.primary_domain = getattr(settings, "PRIMARY_DOMAIN", "app.enterprisehub.com")
self.enable_subdomain_routing = getattr(settings, "ENABLE_SUBDOMAIN_ROUTING", True)
self.force_https = getattr(settings, "FORCE_HTTPS", True)

# Performance settings
self.cache_ttl = 3600 # 1 hour cache for domain resolution
Expand Down Expand Up @@ -86,7 +86,7 @@ async def dispatch(self, request: Request, call_next: Callable[[Request], Awaita
request.state.tenant = tenant_context

# Add custom headers for debugging
if settings.get("DEBUG", False):
if getattr(settings, "DEBUG", False):
request.state.debug_info = {
"agency_id": tenant_context.agency_id,
"client_id": tenant_context.client_id,
Expand Down Expand Up @@ -242,15 +242,23 @@ async def _resolve_from_database(self, domain_name: str) -> TenantContext:
async def _resolve_subdomain_routing(self, domain_name: str) -> TenantContext:
"""Resolve subdomain-based routing (e.g., agency.app.com or client.agency.app.com)."""

parts = domain_name.split(".")
if len(parts) < 3: # Minimum: subdomain.app.com
# Count subdomain labels relative to the configured primary domain
# (e.g. with primary_domain "app.enterprisehub.com":
# agency.app.enterprisehub.com -> ["agency"] agency level
# client.agency.app.enterprisehub.com -> ["client", "agency"] client level)
suffix = f".{self.primary_domain}"
if not domain_name.endswith(suffix):
return await self._get_default_context()

subdomain_labels = domain_name[: -len(suffix)].split(".")
if not subdomain_labels or subdomain_labels == [""]:
return await self._get_default_context()

try:
async with self.db_pool.acquire() as conn:
if len(parts) == 3:
# Format: agency.app.com
agency_slug = parts[0]
if len(subdomain_labels) == 1:
# Format: agency.<primary_domain>
agency_slug = subdomain_labels[0]

agency_info = await conn.fetchrow(
"""
Expand All @@ -268,10 +276,10 @@ async def _resolve_subdomain_routing(self, domain_name: str) -> TenantContext:
context.primary_domain = False
return context

elif len(parts) == 4:
# Format: client.agency.app.com
client_slug = parts[0]
agency_slug = parts[1]
elif len(subdomain_labels) == 2:
# Format: client.agency.<primary_domain>
client_slug = subdomain_labels[0]
agency_slug = subdomain_labels[1]

client_info = await conn.fetchrow(
"""
Expand Down
12 changes: 12 additions & 0 deletions ghl_real_estate_ai/api/middleware/global_exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,76 +123,88 @@ def _load_error_patterns(self) -> Dict[str, Dict[str, Any]]:
return {
# Business Logic Errors
"commission_validation": {
"type": "commission_validation",
"status_code": 400,
"message": "Commission rate validation failed",
"guidance": "Commission must be between 5% and 8% for Alex's listings",
"retryable": False,
},
"property_qualification": {
"type": "property_qualification",
"status_code": 400,
"message": "Property does not meet Alex's criteria",
"guidance": "Properties must be residential, $100K-$2M range, in supported markets",
"retryable": False,
},
"lead_scoring_error": {
"type": "lead_scoring_error",
"status_code": 400,
"message": "Lead scoring validation failed",
"guidance": "Check lead data completeness and contact information",
"retryable": True,
},
# External Service Errors
"ghl_api_error": {
"type": "ghl_api_error",
"status_code": 502,
"message": "GoHighLevel service temporarily unavailable",
"guidance": "CRM operations will retry automatically",
"retryable": True,
},
"claude_api_error": {
"type": "claude_api_error",
"status_code": 502,
"message": "AI assistant service temporarily unavailable",
"guidance": "AI features will be restored shortly",
"retryable": True,
},
"retell_api_error": {
"type": "retell_api_error",
"status_code": 502,
"message": "Voice calling service unavailable",
"guidance": "Voice features temporarily disabled",
"retryable": True,
},
# Database and Performance
"database_timeout": {
"type": "database_timeout",
"status_code": 503,
"message": "Database operation timed out",
"guidance": "Please try your request again",
"retryable": True,
},
"cache_miss": {
"type": "cache_miss",
"status_code": 202,
"message": "Data is being processed",
"guidance": "Results will be available shortly",
"retryable": True,
},
# Authentication and Authorization
"auth_token_expired": {
"type": "auth_token_expired",
"status_code": 401,
"message": "Authentication token has expired",
"guidance": "Please sign in again to continue",
"retryable": False,
},
"insufficient_permissions": {
"type": "insufficient_permissions",
"status_code": 403,
"message": "Insufficient permissions for this action",
"guidance": "Contact your administrator for access",
"retryable": False,
},
# WebSocket Specific
"websocket_connection_failed": {
"type": "websocket_connection_failed",
"status_code": 503,
"message": "Real-time connection failed",
"guidance": "Refreshing page may restore real-time features",
"retryable": True,
},
"websocket_message_invalid": {
"type": "websocket_message_invalid",
"status_code": 400,
"message": "Invalid real-time message format",
"guidance": "Check message structure and try again",
Expand Down
10 changes: 10 additions & 0 deletions ghl_real_estate_ai/api/routes/attribution_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ async def get_source_performance(
logger.info(f"Retrieved performance data for {len(response_data)} sources")
return response_data

except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting source performance: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to retrieve performance data")
Expand Down Expand Up @@ -281,6 +283,8 @@ async def generate_attribution_report(
logger.info(f"Generated attribution report with {len(source_performances)} sources")
return response

except HTTPException:
raise
except Exception as e:
logger.error(f"Error generating attribution report: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to generate report")
Expand Down Expand Up @@ -423,6 +427,8 @@ async def get_optimization_recommendations(lead_source_tracker: LeadSourceTracke
logger.info(f"Generated {len(recommendations.get('recommendations', []))} optimization recommendations")
return recommendations

except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting recommendations: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to generate recommendations")
Expand Down Expand Up @@ -460,6 +466,8 @@ async def get_available_sources():
],
}

except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting sources: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to get sources")
Expand Down Expand Up @@ -567,6 +575,8 @@ async def export_performance_csv(
"content_type": "text/csv",
}

except HTTPException:
raise
except Exception as e:
logger.error(f"Error exporting CSV: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to export CSV")
Expand Down
15 changes: 10 additions & 5 deletions ghl_real_estate_ai/api/routes/market_intelligence.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@

from ghl_real_estate_ai.ghl_utils.logger import get_logger
from ghl_real_estate_ai.services.property_alerts import AlertCriteria, get_property_alert_system
from ghl_real_estate_ai.services.coastal_metro_ai_assistant import CoastalMetroConversationContext
from ghl_real_estate_ai.services.coastal_metro_ai_assistant import (
CoastalMetroConversationContext,
get_coastal_metro_ai_assistant,
)
from ghl_real_estate_ai.services.coastal_metro_market_service import (
PropertyType,
get_coastal_metro_market_service,
Expand Down Expand Up @@ -141,6 +144,8 @@ async def get_market_metrics(
last_updated=datetime.now(),
)

except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting market metrics: {e}")
raise HTTPException(500, f"Failed to retrieve market metrics: {str(e)}")
Expand Down Expand Up @@ -173,7 +178,7 @@ async def get_neighborhood_list():
{
"median_price": analysis.median_price,
"school_rating": analysis.school_rating,
"tech_worker_appeal": analysis.tech_worker_appeal,
"tech_worker_appeal": analysis.logistics_healthcare_appeal,
"market_condition": analysis.market_condition.value,
}
)
Expand Down Expand Up @@ -205,7 +210,7 @@ async def get_neighborhood_analysis(neighborhood_name: str):
"lifestyle_score": {
"walkability": analysis.walkability_score,
"school_rating": analysis.school_rating,
"tech_appeal": analysis.tech_worker_appeal,
"tech_appeal": analysis.logistics_healthcare_appeal,
},
}

Expand Down Expand Up @@ -547,7 +552,7 @@ async def get_market_trends(
neighborhood_analysis = await market_service.get_neighborhood_analysis(neighborhood)
if neighborhood_analysis:
trends["neighborhood_insights"] = {
"tech_worker_appeal": neighborhood_analysis.tech_worker_appeal,
"tech_worker_appeal": neighborhood_analysis.logistics_healthcare_appeal,
"school_rating": neighborhood_analysis.school_rating,
"walkability": neighborhood_analysis.walkability_score,
}
Expand Down Expand Up @@ -652,7 +657,7 @@ async def get_ai_conversation_response(
conversation_stage=lead_context.get("conversation_stage", "discovery"),
)

response = await ai_assistant.generate_coastal_metro_response(query, context, conversation_history)
response = await ai_assistant.generate_market_response(context, query, conversation_history)

return {"query": query, "ai_response": response, "generated_at": datetime.now().isoformat()}

Expand Down
Loading