diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..1fd833536 --- /dev/null +++ b/.gitignore @@ -0,0 +1,96 @@ +# ========================= +# Environment / Secrets +# ========================= +.env +.env.* +!.env.example + +# ========================= +# Python +# ========================= +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +.venv/ +venv/ +env/ +ENV/ + +*.egg-info/ +.eggs/ +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.mypy_cache/ +.ruff_cache/ + +# ========================= +# Node / Frontend +# ========================= +frontend/node_modules/ +node_modules/ +frontend/dist/ +node_modules/ +dist/ +build/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +dist/ +build/ +.next/ +.nuxt/ +.cache/ +.parcel-cache/ +.vite/ +*.tsbuildinfo +*.pem +*.key +*.crt + +# ========================= +# Logs +# ========================= +*.log +logs/ + +# ========================= +# Databases / Local Data +# ========================= +*.sqlite +*.sqlite3 +*.db +*.db-journal + +# ========================= +# Docker +# ========================= +docker-data/ +docker-volumes/ +volumes/ + +# ========================= +# IDEs +# ========================= +.idea/ +.vscode/ +*.iml + +# ========================= +# OS +# ========================= +.DS_Store +Thumbs.db +desktop.ini + +# ========================= +# Temporary files +# ========================= +*.tmp +*.temp +*.swp +*~ \ No newline at end of file diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..5fc5bfe98 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,84 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any -from app.services.cache import get_revenue_summary -from app.core.auth import authenticate_request as get_current_user +from fastapi import APIRouter, Depends, Query +from sqlalchemy import text + +from app.core.auth import authenticate_request +from app.core.database_pool import DatabasePool +from app.services.reservations import calculate_monthly_revenue router = APIRouter() + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user=Depends(authenticate_request), +): + tenant_id = current_user.tenant_id + + if not tenant_id: + raise ValueError("Authenticated user has no tenant") + + db_pool = DatabasePool() + await db_pool.initialize() + + if not db_pool.session_factory: + raise RuntimeError("Database pool is not available") + + 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}, + ) + + items = [ + {"id": row.id, "name": row.name} + for row in result.fetchall() + ] + + return {"items": items, "total": len(items)} + finally: + await db_pool.close() + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, - 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'] - } + month: int = Query(..., ge=1, le=12), + year: int = Query(..., ge=2000, le=2100), + current_user=Depends(authenticate_request), +): + tenant_id = current_user.tenant_id + + if not tenant_id: + raise ValueError("Authenticated user has no tenant") + + db_pool = DatabasePool() + await db_pool.initialize() + + if not db_pool.session_factory: + raise RuntimeError("Database pool is not available") + + try: + async with db_pool.get_session() as session: + total = await calculate_monthly_revenue( + property_id=property_id, + tenant_id=tenant_id, + month=month, + year=year, + db_session=session, + ) + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total_revenue": str(total), + "currency": "USD", + } + + finally: + await db_pool.close() \ No newline at end of file diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..b2a2185e1 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,60 +1,70 @@ -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool -import logging +from typing import AsyncIterator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + from ..config import settings -logger = logging.getLogger(__name__) 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}" - - 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 + + async def initialize(self) -> None: + """Initialize the asynchronous database connection pool.""" + if self.session_factory is not None: + return + + database_url = settings.database_url + if database_url.startswith("postgresql://"): + database_url = database_url.replace( + "postgresql://", + "postgresql+asyncpg://", + 1, ) - - 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""" - if not self.session_factory: - raise Exception("Database pool not initialized") + + self.engine = create_async_engine( + database_url, + pool_size=20, + max_overflow=30, + pool_pre_ping=True, + pool_recycle=3600, + echo=False, + ) + + self.session_factory = async_sessionmaker( + bind=self.engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + def get_session(self) -> AsyncSession: + """Create a database session.""" + if self.session_factory is None: + raise RuntimeError("Database pool is not initialized") + return self.session_factory() -# Global database pool instance + async def close(self) -> None: + """Close the database connection pool.""" + if self.engine is not None: + await self.engine.dispose() + + self.engine = None + self.session_factory = None + + db_pool = DatabasePool() -async def get_db_session() -> AsyncSession: - """Dependency to get database session""" + +async def get_db_session() -> AsyncIterator[AsyncSession]: + """FastAPI dependency that provides a database session.""" + if db_pool.session_factory is None: + await db_pool.initialize() + async with db_pool.get_session() as session: - yield session + yield session \ No newline at end of file diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..672e3f9d6 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,7 +10,7 @@ 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}" + cache_key = f"revenue:{tenant_id}:{property_id}" # Try to get from cache cached = await redis_client.get(cache_key) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..650edd059 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,140 @@ -from datetime import datetime +import logging +from datetime import datetime, timezone from decimal import Decimal -from typing import Dict, Any, List +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. - """ +from sqlalchemy import text - start_date = datetime(year, month, 1) - if month < 12: - end_date = datetime(year, month + 1, 1) +logger = logging.getLogger(__name__) + + +async def calculate_monthly_revenue( + property_id: str, + tenant_id: str, + month: int, + year: int, + db_session, +) -> Decimal: + """Calculate revenue for a property during its local calendar month.""" + + if db_session is None: + raise ValueError("db_session is required") + + property_result = await db_session.execute( + text(""" + SELECT timezone + FROM properties + WHERE id = :property_id + AND tenant_id = :tenant_id + """), + { + "property_id": property_id, + "tenant_id": tenant_id, + }, + ) + + property_row = property_result.fetchone() + + if property_row is None: + return Decimal("0") + + property_timezone = ZoneInfo(property_row.timezone) + + local_start = datetime( + year, + month, + 1, + tzinfo=property_timezone, + ) + + if month == 12: + local_end = datetime( + year + 1, + 1, + 1, + tzinfo=property_timezone, + ) 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]: - """ - 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}") - - # 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 { + local_end = datetime( + year, + month + 1, + 1, + tzinfo=property_timezone, + ) + + result = await db_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_date + AND check_in_date < :end_date + """), + { "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] - } + "tenant_id": tenant_id, + "start_date": local_start.astimezone(timezone.utc), + "end_date": local_end.astimezone(timezone.utc), + }, + ) + + row = result.fetchone() + + if row is None or row.total is None: + return Decimal("0") + + return Decimal(str(row.total)) + + +async def calculate_total_revenue( + property_id: str, + tenant_id: str, +) -> Dict[str, Any]: + """Calculate all-time revenue for one tenant property.""" + + from app.core.database_pool import DatabasePool + + db_pool = DatabasePool() + await db_pool.initialize() + + if not db_pool.session_factory: + raise RuntimeError("Database pool is not available") + + try: + async with db_pool.get_session() as session: + result = await session.execute( + text(""" + SELECT + COALESCE(SUM(total_amount), 0) AS total_revenue, + COUNT(*) AS reservation_count + FROM reservations + WHERE property_id = :property_id + AND tenant_id = :tenant_id + """), + { + "property_id": property_id, + "tenant_id": tenant_id, + }, + ) + + row = result.fetchone() + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": str(Decimal(str(row.total_revenue or "0"))), + "currency": "USD", + "count": int(row.reservation_count or 0), + } + + except Exception: + logger.exception( + "Revenue query failed for property %s and tenant %s", + property_id, + tenant_id, + ) + raise + finally: + await db_pool.close() \ No newline at end of file diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..f2bf3fb1d 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,40 @@ -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 PropertyOption { + id: string; + name: string; +} const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [properties, setProperties] = useState([]); + const [selectedProperty, setSelectedProperty] = useState(''); + const [month, setMonth] = useState(3); + const [year, setYear] = useState(2024); + const [error, setError] = useState(''); + + useEffect(() => { + const loadProperties = async () => { + try { + const response = await SecureAPI.getDashboardProperties(); + const availableProperties = (response.items || []) + .map((property) => ({ + id: property.id, + name: property.name || property.id, + })) + .filter((property) => property.id); + + setProperties(availableProperties); + setSelectedProperty((current) => current || availableProperties[0]?.id || ''); + } catch (requestError) { + console.error(requestError); + setError('Failed to load properties'); + } + }; + + loadProperties(); + }, []); return (
@@ -22,31 +46,54 @@ const Dashboard: React.FC = () => {

Revenue Overview

-

- Monthly performance insights for your properties -

+

Monthly performance insights for your properties

- {/* Property Selector */} -
- +
+ +
+ + + + setYear(Number(e.target.value))} + className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm" + /> +
+ {error &&
{error}
}
- + {selectedProperty && ( + + )}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..368d0acf2 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -1,119 +1,108 @@ -import React, { useEffect, useState } from 'react'; -import { SecureAPI } from '../lib/secureApi'; +import React, { useEffect, useState } from "react"; +import { SecureAPI } from "../lib/secureApi"; interface RevenueData { - property_id: string; - total_revenue: number; - currency: string; - reservations_count: number; + property_id: string; + tenant_id: string; + total_revenue: string; + currency: string; } interface RevenueSummaryProps { - propertyId?: string; - debugTenant?: string; - showRaw?: boolean; + propertyId?: string; + month?: number; + year?: number; + showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, 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() - }); - setData(response); - } catch (err) { - setError('Failed to load revenue data'); - console.error(err); - } finally { - setLoading(false); - } - }; - - fetchRevenue(); - }, [propertyId, activeTenant]); - - if (loading) { - return ( -
-
-
-
-
-
-
-
-
-
+export const RevenueSummary: React.FC = ({ + propertyId = "prop-001", + month, + year, + showRaw = false, +}) => { + const currentDate = new Date(); + + const reportMonth = month ?? currentDate.getMonth() + 1; + const reportYear = year ?? currentDate.getFullYear(); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const fetchRevenue = async () => { + setLoading(true); + setError(""); + + try { + const response = await SecureAPI.getDashboardSummary( + propertyId, + reportMonth, + reportYear ); - } - if (error) return
{error}
; - if (!data) return null; + setData(response); + } catch (requestError) { + console.error(requestError); + setError("Failed to load revenue data"); + } finally { + setLoading(false); + } + }; + + fetchRevenue(); + }, [propertyId, reportMonth, reportYear]); - const displayTotal = Math.round(data.total_revenue * 100) / 100; + if (loading) { + return
Loading revenue...
; + } + if (error) { return ( -
- {showRaw && ( -
- Raw API Response -
{JSON.stringify(data, null, 2)}
-
- )} - -
-
-
-

Total Revenue

-
- - {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - - {/* Fake trend indicator for premium feel */} - - - 12% - -
-
-
- -
-
-

Property ID

-

{data.property_id}

-
-
-

Reservations

-

{data.reservations_count} bookings

-
-
- - {/* Precision Warning Area */} -
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( -
- - - - Precision Mismatch Detected -
- )} -
-
-
+
+ {error} +
); -}; + } + + if (!data) { + return null; + } + + const formattedTotal = Number(data.total_revenue).toLocaleString( + undefined, + { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + } + ); + + return ( +
+ {showRaw && ( +
+          {JSON.stringify(data, null, 2)}
+        
+ )} + +
+

+ Monthly Revenue +

+ +

+ {data.currency} {formattedTotal} +

+ +

+ {reportMonth}/{reportYear} +

+ +

+ Property: {data.property_id} +

+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90..96b4d0bba 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -11,7 +11,6 @@ import { supabase } from './supabase'; import { sessionManager } from '../utils/sessionManager'; -import { withRetry, handleApiError, classifyError } from '../utils/apiErrorHandler'; // Get backend URL with fallback for misconfigured production environments const getBackendUrl = () => { @@ -1449,25 +1448,34 @@ export class SecureAPIClient { } // ============= DASHBOARD API ============= + async getDashboardProperties() { + return this.request<{ + items: Array<{ id: string; name: string }>; + total: number; + }>('/api/v1/dashboard/properties'); + } + /** * 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()); - } - - const requestOptions: RequestInit = {}; - if (options?.simulatedTenant) { - requestOptions.headers = { - 'X-Simulated-Tenant': options.simulatedTenant - }; - } - - return this.request(`/api/v1/dashboard/summary?${queryParams}`, requestOptions); - } - +async getDashboardSummary( + propertyId: string, + month: number, + year: number +) { + const queryParams = new URLSearchParams({ + property_id: propertyId, + month: String(month), + year: String(year), + }); + + return this.request<{ + property_id: string; + tenant_id: string; + total_revenue: string; + currency: string; + }>(`/api/v1/dashboard/summary?${queryParams}`); +} async uploadCompanyLogo(logo_url: string) { return this.request('/api/v1/company-settings/logo', { method: 'POST',