diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..c44b44df8 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,70 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Any, Dict, List +from sqlalchemy import text from app.services.cache import get_revenue_summary +from app.services.reservations import PropertyNotFoundError, RevenueServiceError from app.core.auth import authenticate_request as get_current_user +from app.core.database_pool import db_pool router = APIRouter() + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: dict = Depends(get_current_user) +) -> List[Dict[str, str]]: + tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" + + try: + async with db_pool.get_session() as session: + result = await session.execute(text(""" + SELECT id, name + FROM properties + WHERE tenant_id = :tenant_id + ORDER BY name + """), {"tenant_id": tenant_id}) + return [ + {"id": row.id, "name": row.name} + for row in result.fetchall() + ] + except Exception as error: + raise HTTPException( + status_code=503, + detail="Properties are temporarily unavailable", + ) from error + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + year: int = Query(ge=1, le=9998), + month: int = Query(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']) + try: + revenue_data = await get_revenue_summary( + property_id, + tenant_id, + year, + month, + ) + except PropertyNotFoundError as error: + raise HTTPException( + status_code=404, + detail="Property not found", + ) from error + except RevenueServiceError as error: + raise HTTPException( + status_code=503, + detail="Revenue data is temporarily unavailable", + ) from error 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'] } diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..fe56eb608 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,7 +1,12 @@ -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging + +from sqlalchemy.engine import make_url +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + from ..config import settings logger = logging.getLogger(__name__) @@ -13,48 +18,48 @@ def __init__(self): 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}" - - 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 - ) - - 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 + if self.engine is not None: + return + + database_url = make_url(settings.database_url).set( + drivername="postgresql+asyncpg" + ) + + self.engine = create_async_engine( + database_url, + pool_size=settings.database_pool_size, + max_overflow=settings.database_max_overflow, + pool_timeout=settings.database_pool_timeout, + pool_pre_ping=True, + pool_recycle=settings.database_pool_recycle, + ) + + self.session_factory = async_sessionmaker( + bind=self.engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + logger.info("✅ Database connection pool initialized") async def close(self): """Close database connections""" - if self.engine: + if self.engine is not None: await self.engine.dispose() + self.engine = None + self.session_factory = None + logger.info("✅ Database connection pool closed") - 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") + if self.session_factory is None: + raise RuntimeError("Database pool is not initialized") return self.session_factory() # Global database pool instance db_pool = DatabasePool() -async def get_db_session() -> AsyncSession: +async def get_db_session(): """Dependency to get database session""" async with db_pool.get_session() as session: yield session diff --git a/backend/app/main.py b/backend/app/main.py index 00734b2fa..7f1a46307 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ import time from app.core.redis_client import redis_client +from app.core.database_pool import db_pool from .api.v1 import ( users_lightning, cities, @@ -90,6 +91,9 @@ async def lifespan(app: FastAPI): # Startup logger.info("Starting up...") + # Initialize the shared PostgreSQL connection pool. + await db_pool.initialize() + # Initialize Supabase connection pool try: from .core.supabase_connection_pool import supabase_pool @@ -127,6 +131,8 @@ async def lifespan(app: FastAPI): await async_processor.shutdown() logger.info("Async processor shutdown completed") + await db_pool.close() + # Close connection pool try: from .core.supabase_connection_pool import supabase_pool diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..5f73dd7f5 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -6,11 +6,16 @@ # 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: int, + month: int, +) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" + cache_key = f"revenue:{tenant_id}:{property_id}:{year}:{month}" # Try to get from cache cached = await redis_client.get(cache_key) @@ -18,10 +23,10 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any return json.loads(cached) # Revenue calculation is delegated to the reservation service. - from app.services.reservations import calculate_total_revenue + from app.services.reservations import calculate_monthly_revenue # Calculate revenue - result = await calculate_total_revenue(property_id, tenant_id) + result = await calculate_monthly_revenue(property_id, tenant_id, year, month) # Cache the result for 5 minutes await redis_client.setex(cache_key, 300, json.dumps(result)) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..9577eca0d 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,115 @@ -from datetime import datetime -from decimal import Decimal -from typing import Dict, Any, List +import logging +from datetime import datetime, timezone +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Dict +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. - """ - start_date = datetime(year, month, 1) - if month < 12: - end_date = datetime(year, month + 1, 1) - 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 - """ - - # 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 - -async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: +logger = logging.getLogger(__name__) + + +class RevenueServiceError(RuntimeError): + """Raised when revenue data cannot be retrieved from the database.""" + + +class PropertyNotFoundError(LookupError): + """Raised when a property does not belong to the requesting tenant.""" + + +async def calculate_monthly_revenue( + property_id: str, + tenant_id: str, + year: int, + month: int, +) -> Dict[str, Any]: """ Aggregates revenue from database. """ 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}") + # Use the application-wide database pool initialized at startup. + from app.core.database_pool import db_pool - # 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}) - - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] - } + async with db_pool.get_session() as session: + # Use SQLAlchemy text for raw SQL + from sqlalchemy import text + + property_query = text(""" + SELECT timezone + FROM properties + WHERE id = :property_id AND tenant_id = :tenant_id + LIMIT 1 + """) + property_result = await session.execute(property_query, { + "property_id": property_id, + "tenant_id": tenant_id + }) + + property_timezone = property_result.scalar_one_or_none() + if property_timezone is None: + raise PropertyNotFoundError("Property not found") + + local_timezone = ZoneInfo(property_timezone) + start_local = datetime(year, month, 1, tzinfo=local_timezone) + if month == 12: + end_local = datetime(year + 1, 1, 1, tzinfo=local_timezone) + else: + end_local = datetime(year, month + 1, 1, tzinfo=local_timezone) + + start_utc = start_local.astimezone(timezone.utc) + end_utc = end_local.astimezone(timezone.utc) + + 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 + AND currency = 'USD' + AND check_in_date >= :start_utc + AND check_in_date < :end_utc + GROUP BY property_id + """) + + result = await session.execute(query, { + "property_id": property_id, + "tenant_id": tenant_id, + "start_utc": start_utc, + "end_utc": end_utc + }) + row = result.fetchone() + + if row: + total_revenue = Decimal(str(row.total_revenue)).quantize( + Decimal("0.01"), + rounding=ROUND_HALF_UP, + ) + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": format(total_revenue, ".2f"), + "currency": "USD", + "count": row.reservation_count + } + + # No reservations found for this property + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": "0.00", + "currency": "USD", + "count": 0 + } + + except PropertyNotFoundError: + raise + except Exception as error: + logger.exception( + "Revenue query failed for tenant=%s property=%s", + tenant_id, + property_id, + ) + raise RevenueServiceError( + "Revenue data could not be retrieved" + ) from error diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ebaa4bc92..6968736a4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "reportlab>=4.4.3", "requests>=2.32.4", "sendgrid>=6.12.4", - "sqlalchemy>=2.0.0", + "sqlalchemy[asyncio]>=2.0.0", "stripe>=7.0.0", "supabase>=2.18.0", "tenacity>=9.1.2", diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b777d2aa..06cb8bd4a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,7 +18,7 @@ pytz redis>=5.0.0 requests sendgrid -sqlalchemy>=2.0.0 +sqlalchemy[asyncio]>=2.0.0 supabase tenacity>=8.0.0 uvicorn[standard] diff --git a/docker-compose.yml b/docker-compose.yml index e255de0b5..07ef9e1ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=propertyflow ports: - - "5433:5432" + - "5432:5432" volumes: # Initialize DB with schema and seeds - ./database/schema.sql:/docker-entrypoint-initdb.d/1-schema.sql diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..67b803a66 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,27 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { RevenueSummary } from "./RevenueSummary"; +import { SecureAPI } from "../lib/secureApi"; -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' } -]; +interface Property { + id: string; + name: string; +} const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [properties, setProperties] = useState(null); + const [selectedProperty, setSelectedProperty] = useState(''); + const [propertiesError, setPropertiesError] = useState(''); + const [month, setMonth] = useState(3); + const [year, setYear] = useState(2024); + + useEffect(() => { + SecureAPI.getDashboardProperties() + .then((result: Property[]) => { + setProperties(result); + setSelectedProperty(result[0]?.id || ''); + }) + .catch(() => setPropertiesError('Failed to load properties')); + }, []); return (
@@ -27,26 +38,56 @@ const Dashboard: React.FC = () => {

- {/* Property Selector */} -
- - +
+
+ + +
+ +
+ + +
+ +
+ + setYear(Number(e.target.value))} + className="w-24 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm" + /> +
- + {propertiesError &&
{propertiesError}
} + {!propertiesError && properties?.length === 0 &&
No properties available.
} + {selectedProperty && ( + + )}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..e5fada046 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,34 +3,28 @@ import { SecureAPI } from '../lib/secureApi'; interface RevenueData { property_id: string; - total_revenue: number; + total_revenue: string; currency: string; reservations_count: number; } interface RevenueSummaryProps { - propertyId?: string; - debugTenant?: string; + propertyId: string; + year: number; + month: number; showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { +export const RevenueSummary: React.FC = ({ propertyId, year, month, showRaw }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const activeTenant = debugTenant || 'candidate'; - useEffect(() => { const fetchRevenue = async () => { setLoading(true); try { - // Use SecureAPI to handle authentication automatically - // We pass the simulatedTenant option which SecureAPI will attach as a header - const response = await SecureAPI.getDashboardSummary(propertyId, { - simulatedTenant: activeTenant, - timestamp: Date.now() - }); + const response = await SecureAPI.getDashboardSummary(propertyId, year, month); setData(response); } catch (err) { setError('Failed to load revenue data'); @@ -41,7 +35,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr }; fetchRevenue(); - }, [propertyId, activeTenant]); + }, [propertyId, year, month]); if (loading) { return ( @@ -61,8 +55,6 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr if (error) return
{error}
; if (!data) return null; - const displayTotal = Math.round(data.total_revenue * 100) / 100; - return (
{showRaw && ( @@ -78,7 +70,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr

Total Revenue

- {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {data.currency} {data.total_revenue} {/* Fake trend indicator for premium feel */} @@ -102,17 +94,6 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr
- {/* Precision Warning Area */} -
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( -
- - - - Precision Mismatch Detected -
- )} -
); diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90..3c2cd8374 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1449,23 +1449,18 @@ export class SecureAPIClient { } // ============= DASHBOARD API ============= - /** - * Get dashboard summary with optional simulation header - */ - async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number }) { - const queryParams = new URLSearchParams({ property_id: propertyId }); - if (options?.timestamp) { - queryParams.append('_t', options.timestamp.toString()); - } + async getDashboardProperties() { + return this.request('/api/v1/dashboard/properties'); + } - const requestOptions: RequestInit = {}; - if (options?.simulatedTenant) { - requestOptions.headers = { - 'X-Simulated-Tenant': options.simulatedTenant - }; - } + async getDashboardSummary(propertyId: string, year: number, month: number) { + const queryParams = new URLSearchParams({ + property_id: propertyId, + year: year.toString(), + month: month.toString() + }); - return this.request(`/api/v1/dashboard/summary?${queryParams}`, requestOptions); + return this.request(`/api/v1/dashboard/summary?${queryParams}`); } async uploadCompanyLogo(logo_url: string) {