diff --git a/backend/Dockerfile b/backend/Dockerfile index f78b896ac..307714f7e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,18 +2,9 @@ FROM python:3.11-slim WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -# Install Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy application code COPY . . -# Run the application CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..1da20c101 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,57 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Dict, Any, Optional +from decimal import Decimal, ROUND_HALF_UP from app.services.cache import get_revenue_summary +from app.services.reservations import ( + calculate_monthly_revenue, + DatabaseUnavailableError, +) from app.core.auth import authenticate_request as get_current_user router = APIRouter() + +def _money_string(value) -> str: + amount = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return format(amount, "f") + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, - current_user: dict = Depends(get_current_user) + month: Optional[int] = Query(None, ge=1, le=12), + year: Optional[int] = Query(None, ge=2000, le=2100), + 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']) - - return { - "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, - "currency": revenue_data['currency'], - "reservations_count": revenue_data['count'] + + try: + revenue_data = await get_revenue_summary(property_id, tenant_id) + except DatabaseUnavailableError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + + response: Dict[str, Any] = { + "property_id": revenue_data["property_id"], + "tenant_id": tenant_id, + "total_revenue": _money_string(revenue_data["total"]), + "currency": revenue_data["currency"], + "reservations_count": revenue_data["count"], } + + if month is not None and year is not None: + try: + monthly = await calculate_monthly_revenue( + property_id=property_id, + month=month, + year=year, + tenant_id=tenant_id, + ) + except DatabaseUnavailableError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + + response["monthly_revenue"] = _money_string(monthly) + response["month"] = month + response["year"] = year + + return response diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..cc73a371c 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,25 +1,42 @@ -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.pool import AsyncAdaptedQueuePool import logging from ..config import settings logger = logging.getLogger(__name__) + +def _async_database_url(url: str) -> str: + """Convert a sync Postgres URL to an asyncpg SQLAlchemy URL.""" + if url.startswith("postgresql+asyncpg://"): + return url + if url.startswith("postgresql://"): + return "postgresql+asyncpg://" + url[len("postgresql://") :] + if url.startswith("postgres://"): + return "postgresql+asyncpg://" + url[len("postgres://") :] + return 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 (idempotent).""" + if self.session_factory is not None: + 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 = _async_database_url(settings.database_url) + self.engine = create_async_engine( database_url, - poolclass=QueuePool, + poolclass=AsyncAdaptedQueuePool, pool_size=20, # Number of connections to maintain max_overflow=30, # Additional connections when needed pool_pre_ping=True, # Validate connections @@ -30,7 +47,7 @@ async def initialize(self): self.session_factory = async_sessionmaker( bind=self.engine, class_=AsyncSession, - expire_on_commit=False + expire_on_commit=False, ) logger.info("✅ Database connection pool initialized") @@ -39,22 +56,31 @@ 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: - """Get database session from pool""" + self.engine = None + self.session_factory = None + + def get_session(self) -> AsyncSession: + """Return a new AsyncSession from the shared pool (sync — not a coroutine).""" if not self.session_factory: - raise Exception("Database pool not initialized") + raise RuntimeError("Database pool not initialized") return self.session_factory() -# Global database pool instance + +# Global database pool instance — reuse this; do not construct a new engine per request. db_pool = DatabasePool() + async def get_db_session() -> AsyncSession: - """Dependency to get database session""" - async with db_pool.get_session() as session: + """FastAPI dependency that yields a database session.""" + await db_pool.initialize() + session = db_pool.get_session() + try: yield session + finally: + await session.close() diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..69bc8c8d2 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -6,24 +6,33 @@ # Initialize Redis client (typically configured centrally). redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0")) + +def _revenue_cache_key(tenant_id: str, property_id: str) -> str: + return f"revenue:{tenant_id}:{property_id}" + + async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" - - # 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. + cache_key = _revenue_cache_key(tenant_id, property_id) + + try: + cached = await redis_client.get(cache_key) + if cached: + payload = json.loads(cached) + if payload.get("tenant_id") == tenant_id: + return payload + except Exception as e: + print(f"Redis read error for {cache_key}: {e}") + 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)) - + + try: + await redis_client.setex(cache_key, 300, json.dumps(result)) + except Exception as e: + print(f"Redis write error for {cache_key}: {e}") + return result diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..413a460b3 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,157 @@ from datetime import datetime -from decimal import Decimal -from typing import Dict, Any, List +from decimal import Decimal, ROUND_HALF_UP +from typing import Dict, Any +from zoneinfo import ZoneInfo -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: - """ - Calculates revenue for a specific month. - """ +MONEY_QUANTUM = Decimal("0.01") + + +class DatabaseUnavailableError(RuntimeError): + """Raised when revenue cannot be computed from the live database.""" + + +def _quantize_money(amount: Decimal) -> Decimal: + return amount.quantize(MONEY_QUANTUM, rounding=ROUND_HALF_UP) - start_date = datetime(year, month, 1) + +def _month_bounds_utc(year: int, month: int, timezone_name: str) -> tuple[datetime, datetime]: + tz = ZoneInfo(timezone_name or "UTC") + start_local = datetime(year, month, 1, tzinfo=tz) if month < 12: - end_date = datetime(year, month + 1, 1) + end_local = datetime(year, month + 1, 1, tzinfo=tz) else: - end_date = datetime(year + 1, 1, 1) - - print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}") - - # SQL Simulation (This would be executed against the actual DB) - query = """ - SELECT SUM(total_amount) as total - FROM reservations - WHERE property_id = $1 - AND tenant_id = $2 - AND check_in_date >= $3 - AND check_in_date < $4 + end_local = datetime(year + 1, 1, 1, tzinfo=tz) + return start_local.astimezone(ZoneInfo("UTC")), end_local.astimezone(ZoneInfo("UTC")) + + +async def _fetch_property_timezone(session, property_id: str, tenant_id: str) -> str: + from sqlalchemy import text + + result = await session.execute( + text(""" + SELECT timezone + FROM properties + WHERE id = :property_id AND tenant_id = :tenant_id + """), + {"property_id": property_id, "tenant_id": tenant_id}, + ) + row = result.fetchone() + return row.timezone if row and row.timezone else "UTC" + + +async def _open_session(): + from app.core.database_pool import db_pool + + try: + await db_pool.initialize() + except Exception as e: + raise DatabaseUnavailableError(f"Database pool initialization failed: {e}") from e + + if not db_pool.session_factory: + raise DatabaseUnavailableError("Database pool not available") + + return db_pool.get_session() + + +async def calculate_monthly_revenue(property_id: str, month: int, year: int, tenant_id: str,db_session=None,) -> Decimal: + """ + Calculates revenue for a specific month in the property's local timezone. """ - - # In production this query executes against a database session. - # result = await db.fetch_val(query, property_id, tenant_id, start_date, end_date) - # return result or Decimal('0') - - return Decimal('0') # Placeholder for now until DB connection is finalized + session = db_session + owns_session = session is None + + try: + if session is None: + session = await _open_session() + + timezone_name = await _fetch_property_timezone(session, property_id, tenant_id) + start_utc, end_utc = _month_bounds_utc(year, month, timezone_name) + + from sqlalchemy import text + + result = await session.execute( + text(""" + SELECT COALESCE(SUM(total_amount), 0) as total + FROM reservations + WHERE property_id = :property_id + AND tenant_id = :tenant_id + AND check_in_date >= :start_utc + AND check_in_date < :end_utc + """), + { + "property_id": property_id, + "tenant_id": tenant_id, + "start_utc": start_utc, + "end_utc": end_utc, + }, + ) + row = result.fetchone() + total = Decimal(str(row.total)) if row else Decimal("0") + return _quantize_money(total) + + except DatabaseUnavailableError: + raise + except Exception as e: + raise DatabaseUnavailableError( + f"Monthly revenue query failed for {property_id} " + f"(tenant: {tenant_id}, {year}-{month:02d}): {e}" + ) from e + + finally: + if owns_session and session is not None: + await session.close() + async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: """ Aggregates revenue from database. """ + session = None try: - # Import database pool - from app.core.database_pool import DatabasePool - - # Initialize pool if needed - db_pool = DatabasePool() - await db_pool.initialize() - - if db_pool.session_factory: - async with db_pool.get_session() as session: - # Use SQLAlchemy text for raw SQL - from sqlalchemy import text - - query = text(""" - SELECT - property_id, - SUM(total_amount) as total_revenue, - COUNT(*) as reservation_count - FROM reservations - WHERE property_id = :property_id AND tenant_id = :tenant_id - GROUP BY property_id - """) - - result = await session.execute(query, { - "property_id": property_id, - "tenant_id": tenant_id - }) - row = result.fetchone() - - if row: - total_revenue = Decimal(str(row.total_revenue)) - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": str(total_revenue), - "currency": "USD", - "count": row.reservation_count - } - else: - # No reservations found for this property - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": "0.00", - "currency": "USD", - "count": 0 - } - else: - raise Exception("Database pool not available") - - except Exception as e: - print(f"Database error for {property_id} (tenant: {tenant_id}): {e}") - - # Create property-specific mock data for testing when DB is unavailable - # This ensures each property shows different figures - mock_data = { - 'prop-001': {'total': '1000.00', 'count': 3}, - 'prop-002': {'total': '4975.50', 'count': 4}, - 'prop-003': {'total': '6100.50', 'count': 2}, - 'prop-004': {'total': '1776.50', 'count': 4}, - 'prop-005': {'total': '3256.00', 'count': 3} - } - - mock_property_data = mock_data.get(property_id, {'total': '0.00', 'count': 0}) - + session = await _open_session() + from sqlalchemy import text + + query = text(""" + SELECT + property_id, + SUM(total_amount) as total_revenue, + COUNT(*) as reservation_count + FROM reservations + WHERE property_id = :property_id AND tenant_id = :tenant_id + GROUP BY property_id + """) + + result = await session.execute(query, { + "property_id": property_id, + "tenant_id": tenant_id, + }) + row = result.fetchone() + + if row: + total_revenue = _quantize_money(Decimal(str(row.total_revenue))) + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": str(total_revenue), + "currency": "USD", + "count": row.reservation_count, + } + return { "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], + "tenant_id": tenant_id, + "total": "0.00", "currency": "USD", - "count": mock_property_data['count'] + "count": 0, } + + except DatabaseUnavailableError: + raise + except Exception as e: + raise DatabaseUnavailableError( + f"Revenue query failed for {property_id} (tenant: {tenant_id}): {e}" + ) from e + + finally: + if session is not None: + await session.close() diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..fa36e7f6a 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,54 @@ -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { useAuth } from "../contexts/AuthContext.new"; import { RevenueSummary } from "./RevenueSummary"; -const PROPERTIES = [ - { id: 'prop-001', name: 'Beach House Alpha' }, - { id: 'prop-002', name: 'City Apartment Downtown' }, - { id: 'prop-003', name: 'Country Villa Estate' }, - { id: 'prop-004', name: 'Lakeside Cottage' }, - { id: 'prop-005', name: 'Urban Loft Modern' } +const ALL_PROPERTIES = [ + { id: "prop-001", name: "Beach House Alpha", tenant_id: "tenant-a" }, + { id: "prop-002", name: "City Apartment Downtown", tenant_id: "tenant-a" }, + { id: "prop-003", name: "Country Villa Estate", tenant_id: "tenant-a" }, + { id: "prop-001", name: "Mountain Lodge Beta", tenant_id: "tenant-b" }, + { id: "prop-004", name: "Lakeside Cottage", tenant_id: "tenant-b" }, + { id: "prop-005", name: "Urban Loft Modern", tenant_id: "tenant-b" }, ]; +function resolveTenantId( + user: { + tenant_id?: string; + app_metadata?: Record; + user_metadata?: Record; + } | null, +): string | null { + if (!user) return null; + return ( + user.tenant_id || + user.app_metadata?.tenant_id || + user.user_metadata?.tenant_id || + null + ); +} + const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const { user } = useAuth(); + const tenantId = resolveTenantId(user); + + const properties = useMemo( + () => (tenantId ? ALL_PROPERTIES.filter((p) => p.tenant_id === tenantId) : []), + [tenantId], + ); + + const [selectedProperty, setSelectedProperty] = useState(properties[0]?.id ?? ""); + + useEffect(() => { + if (properties.length === 0) { + setSelectedProperty(""); + return; + } + if (!properties.some((p) => p.id === selectedProperty)) { + setSelectedProperty(properties[0].id); + } + }, [properties, selectedProperty]); + + const selectedMeta = properties.find((p) => p.id === selectedProperty) ?? properties[0]; return (
@@ -25,18 +63,23 @@ const Dashboard: React.FC = () => {

Monthly performance insights for your properties

+ {selectedMeta && ( +

+ Viewing: {selectedMeta.name} +

+ )}
- - {/* Property Selector */} +