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
23 changes: 23 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.DS_Store
__pycache__/
*.pyc
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.development
.env.test
.env.production
.pytest_cache
.pytest_cache/
.pytest_cache/v/
.pytest_cache/v/cache/
.pytest_cache/v/cache/lastfailed
.pytest_cache/v/cache/nodeids
.pytest_cache/v/cache/stepwise
.pytest_cache/v/cache/tmp
.pytest_cache/v/cache/tmp/
.pytest_cache/v/cache/tmp/lastfailed
.pytest_cache/v/cache/tmp/nodeids
.pytest_cache/v/cache/tmp/stepwise
49 changes: 41 additions & 8 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,58 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from app.services.cache import get_revenue_summary
from app.services.reservations import RevenueUnavailable, get_tenant_property, list_tenant_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="Tenant is required")
return tenant_id

@router.get("/dashboard/properties")
async def get_dashboard_properties(
current_user: dict = Depends(get_current_user)
) -> Dict[str, Any]:
tenant_id = _require_tenant(current_user)
try:
properties = await list_tenant_properties(tenant_id)
except RevenueUnavailable as exc:
raise HTTPException(status_code=503, detail="Property data is temporarily unavailable") from exc
return {"properties": properties}

@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
month: int,
year: int,
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'])
if month < 1 or month > 12:
raise HTTPException(status_code=400, detail="month must be between 1 and 12")

tenant_id = _require_tenant(current_user)

try:
property_row = await get_tenant_property(property_id, tenant_id)
except RevenueUnavailable as exc:
raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable") from exc
if property_row is None:
raise HTTPException(status_code=404, detail="Property not found")

try:
revenue_data = await get_revenue_summary(property_id, tenant_id, month, year)
except RevenueUnavailable as exc:
raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable") from exc

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"total_revenue": revenue_data['total'],
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
"reservations_count": revenue_data['count'],
"month": month,
"year": year,
}
89 changes: 60 additions & 29 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,91 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
from contextlib import asynccontextmanager

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from ..config import settings

logger = logging.getLogger(__name__)


def to_async_database_url(url: str) -> str:
"""Rewrite a libpq URL so SQLAlchemy uses the asyncpg driver."""
if url.startswith("postgresql+asyncpg://"):
return url
if url.startswith("postgres://"):
url = "postgresql://" + url[len("postgres://"):]
if url.startswith("postgresql://"):
return "postgresql+asyncpg://" + url[len("postgresql://"):]
raise ValueError("DATABASE_URL must be a postgresql URL")


class DatabasePool:
def __init__(self):
self.engine = None
self.session_factory = None

async def initialize(self):
"""Initialize database connection pool"""
"""Initialize database connection pool from DATABASE_URL."""
if self.engine is not None:
await self.engine.dispose()
self.engine = None
self.session_factory = None

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 = to_async_database_url(settings.database_url)

# The async engine supplies its own pool. A sync QueuePool cannot be used here.
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
pool_recycle=3600, # Recycle connections every hour
echo=False # Set to True for SQL debugging
pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
pool_pre_ping=True,
pool_recycle=settings.database_pool_recycle,
echo=False,
)

self.session_factory = async_sessionmaker(
bind=self.engine,
class_=AsyncSession,
expire_on_commit=False
expire_on_commit=False,
)
logger.info("✅ Database connection pool initialized")

logger.info("Database connection pool initialized")

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

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

async def get_session(self) -> AsyncSession:
"""Get database session from pool"""
self.engine = None
self.session_factory = None

@asynccontextmanager
async def get_session(self):
"""Get database session from pool."""
if not self.session_factory:
raise Exception("Database pool not initialized")
return self.session_factory()
raise RuntimeError("Database pool not initialized")
session = self.session_factory()
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()


# Global database pool instance
db_pool = DatabasePool()

async def get_db_session() -> AsyncSession:
"""Dependency to get database session"""

async def get_db_session():
"""Dependency to get database session."""
async with db_pool.get_session() as session:
yield session
51 changes: 33 additions & 18 deletions backend/app/core/tenant_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,18 @@
from typing import Optional
import logging

from jose import JWTError, jwt

from app.config import settings

logger = logging.getLogger(__name__)

_KNOWN_TENANTS = {
"sunset@propertyflow.com": "tenant-a",
"ocean@propertyflow.com": "tenant-b",
"candidate@propertyflow.com": "tenant-a",
}


class TenantResolver:
"""Minimal tenant resolver that extracts tenant_id from JWT claims."""
Expand Down Expand Up @@ -69,27 +79,32 @@ 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

Unknown users are not assigned a tenant. Callers must reject that case.
"""
# 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:
tenant_id = TenantResolver._tenant_from_bearer(token)
if tenant_id:
return tenant_id

return _KNOWN_TENANTS.get((user_email or "").lower())

@staticmethod
def _tenant_from_bearer(token: str) -> Optional[str]:
try:
payload = jwt.decode(
token,
settings.secret_key,
algorithms=["HS256"],
options={"verify_aud": False},
)
except JWTError:
logger.warning("Could not read tenant from access token")
return None
return TenantResolver.resolve_tenant_from_token(payload)

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

try:
from .core.database_pool import db_pool

await db_pool.initialize()
except Exception as e:
logger.error(f"Database pool initialization failed: {e}")

# Initialize Redis connection with timeout
try:
await redis_client.initialize()
Expand Down Expand Up @@ -136,6 +143,13 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"⚠️ Error closing connection pool: {e}")

try:
from .core.database_pool import db_pool

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


app = FastAPI(
title="Auth Skeleton API",
Expand Down
46 changes: 28 additions & 18 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,39 @@
import json
import redis.asyncio as redis
from typing import Dict, Any
import os
from typing import Any, Dict

import redis.asyncio as redis

# 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]:

def revenue_cache_key(tenant_id: str, property_id: str, year: int, month: int) -> str:
"""Property ids are unique per tenant, and totals differ by local month."""
return f"revenue:{tenant_id}:{property_id}:{year:04d}-{month:02d}"


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

# Try to get from cache
cache_key = revenue_cache_key(tenant_id, property_id, year, month)

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)

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

data = json.loads(cached)
if (
isinstance(data, dict)
and data.get("tenant_id") == tenant_id
and data.get("property_id") == property_id
and data.get("month") == month
and data.get("year") == year
):
return data

from app.services.reservations import calculate_monthly_revenue

result = await calculate_monthly_revenue(property_id, tenant_id, month, year)

await redis_client.set(cache_key, json.dumps(result), ex=300)

return result
Loading