Skip to content
Open
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
56 changes: 46 additions & 10 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,61 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Any, Dict, List, Optional
from app.services.cache import get_revenue_summary
from app.services.reservations import (
MixedCurrencyError,
PropertyNotFoundError,
RevenueUnavailableError,
list_properties,
)
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()

def _require_tenant(current_user) -> str:
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="User is not assigned to a tenant")
return tenant_id


@router.get("/dashboard/properties")
async def get_dashboard_properties(
current_user: dict = Depends(get_current_user)
) -> List[Dict[str, str]]:
tenant_id = _require_tenant(current_user)
try:
return await list_properties(tenant_id)
except RevenueUnavailableError:
raise HTTPException(status_code=503, detail="Property list is temporarily unavailable")


@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
year: Optional[int] = Query(None, ge=2000, le=2100),
month: Optional[int] = Query(None, ge=1, le=12),
current_user: dict = Depends(get_current_user)
) -> Dict[str, Any]:

tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant"

revenue_data = await get_revenue_summary(property_id, tenant_id)

total_revenue_float = float(revenue_data['total'])


tenant_id = _require_tenant(current_user)

if (year is None) != (month is None):
raise HTTPException(status_code=422, detail="year and month must be provided together")

try:
revenue_data = await get_revenue_summary(property_id, tenant_id, year, month)
except PropertyNotFoundError:
# Same response whether the property doesn't exist or belongs to another tenant
raise HTTPException(status_code=404, detail="Property not found")
except MixedCurrencyError:
raise HTTPException(status_code=409, detail="Reservations use more than one currency")
except RevenueUnavailableError:
raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable")

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
# Decimal string rounded to cents, e.g. "2250.00" (never a float)
"total_revenue": revenue_data['total'],
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
38 changes: 20 additions & 18 deletions backend/app/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,13 @@ async def authenticate_request(
# First try to decode as a custom JWT token.
try:
payload = jwt.decode(
token,
settings.secret_key,
token,
settings.secret_key,
algorithms=["HS256"],
audience="authenticated" # Accept tokens with aud: "authenticated"
)
logger.info(f"AUTH: Successfully decoded custom JWT token for {payload.get('email')}")

# Create a mock user object from JWT payload
class MockUser:
def __init__(self, payload):
Expand All @@ -123,14 +123,14 @@ def __init__(self, payload):
self.app_metadata = payload.get('app_metadata', {})
self.user_metadata = payload.get('user_metadata', {})
self.raw_app_metadata = payload.get('app_metadata', {})

user = MockUser(payload)

except JWTError:
# If custom JWT fails, try Supabase auth
response = supabase.auth.get_user(token)
user = response.user

except Exception as e:
# Malformed or invalid token (e.g., wrong number of segments)
logger.warning(f"AUTH: Token verification failed (hash={token_hash}): {e.__class__.__name__}")
Expand Down Expand Up @@ -252,16 +252,18 @@ def __init__(self, payload):
logger.info(f"==================== TENANT ID EXTRACTION ====================")
logger.info(f"User: {user.email} (ID: {user.id})")

# Use TenantResolver for comprehensive tenant resolution
tenant_id = await TenantResolver.resolve_tenant_id(token=token, user_id=user.id, user_email=user.email)

# If we found a tenant_id and it's not in the user's metadata, update it for next time
# Prefer the tenant from the verified token's app_metadata (set at login);
# fall back to the resolver's email mapping.
current_tenant_in_metadata = None
if hasattr(user, "raw_app_metadata") and user.raw_app_metadata:
current_tenant_in_metadata = user.raw_app_metadata.get("tenant_id")
elif hasattr(user, "app_metadata") and user.app_metadata:
current_tenant_in_metadata = user.app_metadata.get("tenant_id")

tenant_id = current_tenant_in_metadata or await TenantResolver.resolve_tenant_id(
token=token, user_id=user.id, user_email=user.email
)

if tenant_id and current_tenant_in_metadata != tenant_id:
logger.info(f"Updating user metadata with tenant_id for future requests...")
asyncio.create_task(TenantResolver.update_user_tenant_metadata(user.id, tenant_id))
Expand Down Expand Up @@ -358,24 +360,24 @@ def permission_checker(user: AuthenticatedUser = Depends(authenticate_request)):

def require_any_permission(*permissions):
"""Dependency to require any of the specified permissions (OR logic)

Args:
*permissions: List of tuples (section, action) representing permissions

Example:
require_any_permission(
("guest_portal", "read"),
("lockbox", "create"),
("lockbox", "create"),
("internal_keys", "create")
)
"""
def permission_checker(user: AuthenticatedUser = Depends(authenticate_request)):
# Check if user has any of the required permissions
has_any_permission = any(
has_permission(user, section, action)
has_permission(user, section, action)
for section, action in permissions
)

if not has_any_permission:
permission_strings = [f"{section}.{action}" for section, action in permissions]
logger.warning(
Expand All @@ -386,14 +388,14 @@ def permission_checker(user: AuthenticatedUser = Depends(authenticate_request)):
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions: requires any of {permission_strings}",
)

# Log which permission was matched for debugging
matched_permissions = [
f"{section}.{action}" for section, action in permissions
if has_permission(user, section, action)
]
logger.info(f"User {user.email} granted access via permissions: {matched_permissions}")

return user

return permission_checker
Expand Down Expand Up @@ -505,7 +507,7 @@ async def verify_token_ws(token: str) -> Optional[AuthenticatedUser]:
else:
# Keep user's explicitly assigned cities even if no properties exist
logger.debug(f"WS_AUTH: Admin user, keeping assigned cities (no properties found): {user_cities}")

logger.info(f"WS_AUTH: Final user cities after processing: {user_cities}")

# Use the comprehensive tenant resolver (same as regular auth)
Expand Down
31 changes: 17 additions & 14 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import asyncio
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
from ..config import settings

Expand All @@ -10,43 +9,47 @@ class DatabasePool:
def __init__(self):
self.engine = None
self.session_factory = None

async def initialize(self):
"""Initialize database connection pool"""
try:
# Create async engine with connection pooling
database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}"

# Create async engine with connection pooling (async engines use
# AsyncAdaptedQueuePool by default; QueuePool is sync-only).
# Normalize any Postgres scheme (postgres://, postgresql://,
# postgresql+psycopg2://) to the asyncpg driver.
database_url = make_url(settings.database_url).set(drivername="postgresql+asyncpg")

self.engine = create_async_engine(
database_url,
poolclass=QueuePool,
pool_size=20, # Number of connections to maintain
max_overflow=30, # Additional connections when needed
pool_timeout=5, # Fail fast instead of queueing forever
connect_args={"timeout": 5}, # asyncpg connect timeout (seconds)
pool_pre_ping=True, # Validate connections
pool_recycle=3600, # Recycle connections every hour
echo=False # Set to True for SQL debugging
)

self.session_factory = async_sessionmaker(
bind=self.engine,
class_=AsyncSession,
expire_on_commit=False
)

logger.info("✅ Database connection pool initialized")

except Exception as e:
logger.error(f"❌ Database pool initialization failed: {e}")
self.engine = None
self.session_factory = None

async def close(self):
"""Close database connections"""
if self.engine:
await self.engine.dispose()
async def get_session(self) -> AsyncSession:
"""Get database session from pool"""

def get_session(self) -> AsyncSession:
"""Get database session from pool (use as `async with db_pool.get_session()`)"""
if not self.session_factory:
raise Exception("Database pool not initialized")
return self.session_factory()
Expand Down
17 changes: 9 additions & 8 deletions backend/app/core/tenant_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,16 @@ def resolve_tenant_from_user(user_data: dict) -> Optional[str]:
return None

@staticmethod
async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> str:
async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> Optional[str]:
"""
Resolve tenant ID for a user.

Args:
user_id: User ID
user_email: User email

Returns:
Tenant ID
Tenant ID, or None when the user cannot be mapped to a tenant
"""
# Fallback mapping by known user email.
if user_email == "sunset@propertyflow.com":
Expand All @@ -87,15 +87,16 @@ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str]
return "tenant-b"
if user_email == "candidate@propertyflow.com":
return "tenant-a"

# Default fallback
return "tenant-a"

# Unknown users must not be silently attached to another client's data
logger.warning(f"No tenant mapping for user {user_email}")
return None

@staticmethod
async def update_user_tenant_metadata(user_id: str, tenant_id: str) -> None:
"""
Update user metadata with tenant_id.

Args:
user_id: User ID
tenant_id: Tenant ID
Expand Down
10 changes: 10 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ async def lifespan(app: FastAPI):
logger.error(f"❌ Supabase connection pool initialization failed: {e}")
# Continue startup - fallback to direct connections

# Initialize the SQLAlchemy pool used by revenue reporting (once per process)
from .core.database_pool import db_pool

await db_pool.initialize()

# Initialize Redis connection with timeout
try:
await redis_client.initialize()
Expand Down Expand Up @@ -127,6 +132,11 @@ async def lifespan(app: FastAPI):
await async_processor.shutdown()
logger.info("Async processor shutdown completed")

try:
await db_pool.close()
except Exception as e:
logger.warning(f"⚠️ Error closing database pool: {e}")

# Close connection pool
try:
from .core.supabase_connection_pool import supabase_pool
Expand Down
23 changes: 14 additions & 9 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
import json
import redis.asyncio as redis
from typing import Dict, Any
from typing import Any, Dict, Optional
import os

# Initialize Redis client (typically configured centrally).
redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0"))

async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any]:
async def get_revenue_summary(
property_id: str, tenant_id: str, year: Optional[int] = None, month: Optional[int] = None
) -> Dict[str, Any]:
"""
Fetches revenue summary, utilizing caching to improve performance.
"""
cache_key = f"revenue:{property_id}"

# Property IDs are only unique per tenant (prop-001 exists for several
# clients), so the tenant must be part of the key, and so must the period.
period = f"{year:04d}-{month:02d}" if year and month else "all"
cache_key = f"revenue:{tenant_id}:{property_id}:{period}"

# Try to get from cache
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)

# Revenue calculation is delegated to the reservation service.
from app.services.reservations import calculate_total_revenue

# Calculate revenue
result = await calculate_total_revenue(property_id, tenant_id)
result = await calculate_total_revenue(property_id, tenant_id, year, month)

# Cache the result for 5 minutes
await redis_client.setex(cache_key, 300, json.dumps(result))

return result
Loading