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
37 changes: 37 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.pyo
*.pyd
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg-info/
.coverage
htmlcov/

# Node / frontend
node_modules/
frontend/dist/
dist/
build/
*.tsbuildinfo
vite.config.ts.timestamp-*.mjs

# Environment / secrets
.env
.env.*
!.env.example
.kamal.env

# OS / editor
.DS_Store
*.swp
.idea/

# Misc
tmp/
*.log
5 changes: 2 additions & 3 deletions backend/app/api/v1/auth_info.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request
from ...core.auth import authenticate_request, auth_cache
from ...core.tenant_resolver import TenantResolver
from ...models.auth import AuthenticatedUser
from ...database import supabase
import logging
Expand Down Expand Up @@ -90,8 +89,8 @@ async def fetch_metadata():
{"section": p.section, "action": p.action} for p in (user.permissions or [])
]

# This ensures /auth/me returns correct tenant like other endpoints
tenant_id = await TenantResolver.resolve_tenant_id(user_id=user.id, user_email=user.email)
# Authentication already resolved this from verified claims/membership.
tenant_id = user.tenant_id
logger.info(f"AUTH /me: Fresh tenant lookup for {user.email}: {tenant_id}")

# Add smart view permissions if user has access
Expand Down
7 changes: 3 additions & 4 deletions backend/app/api/v1/city_access_fast.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from ...database import supabase
from ...core.redis_client import redis_client
from ...core.tenant_cache import tenant_cache
from ...core.tenant_resolver import TenantResolver
import json
import time
import logging
Expand Down Expand Up @@ -162,11 +161,11 @@ async def get_city_access_fast(
user_id = user.id
user_email = user.email

# ✅ UNIFIED TENANT RESOLUTION: Use same TenantResolver as auth.py for consistency
# Use the tenant assignment established by authentication.
logger.info(f"🔍 TENANT_RESOLUTION: Starting unified tenant resolution for user {user_email}")

# Use the same comprehensive tenant resolver as authentication
tenant_id = await TenantResolver.resolve_tenant_id(user_id=user_id, user_email=user_email)
# Authentication already resolved the authoritative tenant assignment.
tenant_id = user.tenant_id

logger.info(f"✅ TENANT_RESOLUTION: Resolved tenant_id for {user_email}: {tenant_id}")

Expand Down
68 changes: 54 additions & 14 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,65 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from typing import Annotated, Any, Dict, List

from fastapi import APIRouter, Depends, HTTPException, Query

from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user
from app.models.auth import AuthenticatedUser
from app.services.reservations import (
MixedCurrencyError,
PropertyNotFoundError,
get_tenant_properties,
)

router = APIRouter()


def _require_tenant(current_user: AuthenticatedUser) -> str:
tenant_id = current_user.tenant_id
if not tenant_id:
raise HTTPException(
status_code=403,
detail="Authenticated user is not assigned to a tenant",
)
return tenant_id


@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
current_user: dict = Depends(get_current_user)
month: Annotated[int, Query(ge=1, le=12)],
year: Annotated[int, Query(ge=2000, le=2100)],
current_user: Annotated[AuthenticatedUser, 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)

try:
revenue_data = await get_revenue_summary(
property_id, tenant_id, month, year
)
except PropertyNotFoundError:
raise HTTPException(
status_code=404, detail="Property not found"
) from None
except MixedCurrencyError:
raise HTTPException(
status_code=422,
detail="Revenue cannot combine multiple currencies",
) from None

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
"property_id": revenue_data["property_id"],
"month": revenue_data["month"],
"year": revenue_data["year"],
"total_revenue": revenue_data["total"],
"currency": revenue_data["currency"],
"reservations_count": revenue_data["count"],
}


@router.get("/dashboard/properties")
async def get_dashboard_properties(
current_user: Annotated[AuthenticatedUser, Depends(get_current_user)],
) -> List[Dict[str, str]]:
tenant_id = _require_tenant(current_user)
return await get_tenant_properties(tenant_id)
13 changes: 11 additions & 2 deletions backend/app/api/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,23 @@ async def login(request: LoginRequest):
)

# Resolve tenant ID
tenant_id = await TenantResolver.resolve_tenant_id(user_id=user.id, user_email=user.email)
tenant_id = await TenantResolver.resolve_tenant_id(
user_id=user.id,
user_email=user.email,
token_payload={
"app_metadata": user.app_metadata or {}
},
)

# Create JWT token
user_data = {
"id": user.id,
"email": user.email,
"is_admin": is_admin,
"tenant_id": tenant_id,
"app_metadata": {
"role": user.app_metadata.get("role", "user"),
"tenant_id": tenant_id,
},
"exp": datetime.utcnow() + timedelta(hours=24),
"aud": "authenticated"
}
Expand Down
22 changes: 18 additions & 4 deletions backend/app/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,15 @@ 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)
# Resolve only from verified server-controlled claims or DB membership.
tenant_id = await TenantResolver.resolve_tenant_id(
user_id=user.id,
user_email=user.email,
token_payload={
"app_metadata": getattr(user, "app_metadata", {}) or {}
},
membership_tenant_ids=tenant_ids,
)

# If we found a tenant_id and it's not in the user's metadata, update it for next time
current_tenant_in_metadata = None
Expand Down Expand Up @@ -508,9 +515,16 @@ async def verify_token_ws(token: str) -> Optional[AuthenticatedUser]:

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

# Use the comprehensive tenant resolver (same as regular auth)
# Use the same verified claims and membership data as HTTP auth.
logger.info(f"WS_AUTH: Resolving tenant for user {user.email}")
tenant_id = await TenantResolver.resolve_tenant_id(token=token, user_id=user.id, user_email=user.email)
tenant_id = await TenantResolver.resolve_tenant_id(
user_id=user.id,
user_email=user.email,
token_payload={
"app_metadata": getattr(user, "app_metadata", {}) or {}
},
membership_tenant_ids=tenant_ids,
)

auth_user = AuthenticatedUser(
id=user.id,
Expand Down
20 changes: 14 additions & 6 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
from ..config import settings

Expand All @@ -13,13 +11,22 @@ def __init__(self):

async def initialize(self):
"""Initialize database connection pool"""
if self.session_factory:
return

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}"
database_url = settings.database_url
if database_url.startswith("postgresql://"):
database_url = database_url.replace(
"postgresql://", "postgresql+asyncpg://", 1
)
elif database_url.startswith("postgres://"):
database_url = database_url.replace(
"postgres://", "postgresql+asyncpg://", 1
)

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_pre_ping=True, # Validate connections
Expand All @@ -39,13 +46,14 @@ async def initialize(self):
logger.error(f"❌ Database pool initialization failed: {e}")
self.engine = None
self.session_factory = None
raise

async def close(self):
"""Close database connections"""
if self.engine:
await self.engine.dispose()

async def get_session(self) -> AsyncSession:
def get_session(self) -> AsyncSession:
"""Get database session from pool"""
if not self.session_factory:
raise Exception("Database pool not initialized")
Expand Down
71 changes: 32 additions & 39 deletions backend/app/core/tenant_resolver.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""
Minimal tenant resolver for authentication.
"""
from typing import Optional
import logging
from typing import Optional, Sequence

logger = logging.getLogger(__name__)

Expand All @@ -21,24 +21,14 @@ def resolve_tenant_from_token(token_payload: dict) -> Optional[str]:
Returns:
Tenant ID if found, None otherwise
"""
# Try user_metadata first (most common location)
if 'user_metadata' in token_payload:
tenant_id = token_payload['user_metadata'].get('tenant_id')
# app_metadata is signed and server-controlled. user_metadata is not
# authoritative because users may be able to edit it themselves.
if "app_metadata" in token_payload:
tenant_id = token_payload["app_metadata"].get("tenant_id")
if tenant_id:
return tenant_id

# Try app_metadata as fallback
if 'app_metadata' in token_payload:
tenant_id = token_payload['app_metadata'].get('tenant_id')
if tenant_id:
return tenant_id

# Try root level
tenant_id = token_payload.get('tenant_id')
if tenant_id:
return tenant_id

logger.warning("No tenant_id found in token payload")
logger.warning("No tenant_id found in signed app_metadata")
return None

@staticmethod
Expand All @@ -52,24 +42,20 @@ def resolve_tenant_from_user(user_data: dict) -> Optional[str]:
Returns:
Tenant ID if found, None otherwise
"""
# Check various possible locations
if 'tenant_id' in user_data:
return user_data['tenant_id']

if 'user_metadata' in user_data:
tenant_id = user_data['user_metadata'].get('tenant_id')
if tenant_id:
return tenant_id

if 'app_metadata' in user_data:
tenant_id = user_data['app_metadata'].get('tenant_id')
if "app_metadata" in user_data:
tenant_id = user_data["app_metadata"].get("tenant_id")
if tenant_id:
return tenant_id

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_payload: Optional[dict] = None,
membership_tenant_ids: Optional[Sequence[str]] = None,
) -> Optional[str]:
"""
Resolve tenant ID for a user.

Expand All @@ -78,18 +64,25 @@ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str]
user_email: User email

Returns:
Tenant ID
Tenant ID, or None when no authoritative assignment exists.
"""
# Fallback mapping by known user email.
if user_email == "sunset@propertyflow.com":
return "tenant-a"
if user_email == "ocean@propertyflow.com":
return "tenant-b"
if user_email == "candidate@propertyflow.com":
return "tenant-a"

# Default fallback
return "tenant-a"
if token_payload:
tenant_id = TenantResolver.resolve_tenant_from_token(token_payload)
if tenant_id:
return tenant_id

tenant_ids = [
tenant_id for tenant_id in membership_tenant_ids or [] if tenant_id
]
if len(tenant_ids) == 1:
return tenant_ids[0]

logger.warning(
"Unable to resolve one tenant for user %s (%s)",
user_email,
user_id,
)
return None

@staticmethod
async def update_user_tenant_metadata(user_id: str, tenant_id: str) -> None:
Expand Down
Loading