From 8dccb1f21de7b9490d47433cd08c7c25b5c84275 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Isah Date: Tue, 14 Jul 2026 13:20:49 +0000 Subject: [PATCH 1/3] Fix tenant isolation and timezone-aware monthly revenue; update profile loading and revenue UI --- backend/app/api/v1/dashboard.py | 44 ++++- backend/app/core/database_pool.py | 20 ++- backend/app/services/cache.py | 11 +- backend/app/services/reservations.py | 200 +++++++++++---------- frontend/src/components/RevenueSummary.tsx | 9 +- 5 files changed, 167 insertions(+), 117 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..dc838c0ec 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,53 @@ from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from typing import Dict, Any, Optional +from decimal import Decimal 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.core.database_pool import DatabasePool +from sqlalchemy import text router = APIRouter() @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, - current_user: dict = Depends(get_current_user) + month: Optional[int] = None, + year: Optional[int] = None, + current_user: AuthenticatedUser = Depends(get_current_user) ) -> Dict[str, Any]: + + tenant_id = current_user.tenant_id + if not tenant_id: + raise HTTPException(status_code=403, detail="Tenant context missing") + # Ensure the requested property belongs to the authenticated tenant + try: + db_pool = DatabasePool() + await db_pool.initialize() + async with (await db_pool.get_session()) as session: + ownership = await session.execute( + text("SELECT 1 FROM properties WHERE id = :property_id AND tenant_id = :tenant_id LIMIT 1"), + {"property_id": property_id, "tenant_id": tenant_id}, + ) + if not ownership.fetchone(): + raise HTTPException(status_code=403, detail="Property not found for tenant") + except HTTPException: + raise + except Exception: + # If DB lookup fails for some reason, deny access rather than risk data leakage + raise HTTPException(status_code=403, detail="Property access denied") - tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" - - revenue_data = await get_revenue_summary(property_id, tenant_id) + revenue_data = await get_revenue_summary(property_id, tenant_id, month=month, year=year) - total_revenue_float = float(revenue_data['total']) + # Use Decimal to preserve precision, then round to 2 decimal places + total = Decimal(revenue_data['total']).quantize(Decimal('0.01')) return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + "total_revenue": float(total), "currency": revenue_data['currency'], - "reservations_count": revenue_data['count'] + "reservations_count": revenue_data['count'], + "month": revenue_data.get('month'), + "year": revenue_data.get('year'), + "timezone": revenue_data.get('timezone') } diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..058d0b6cc 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -15,15 +15,23 @@ 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}" + # Prefer a single DATABASE_URL if provided (convert to asyncpg driver), + # otherwise fall back to legacy separate supabase_db_* settings. + database_url = None + try: + database_url = getattr(settings, 'database_url', None) + except Exception: + database_url = None + + if database_url: + # Convert sync driver to asyncpg driver + if database_url.startswith('postgresql://'): + database_url = database_url.replace('postgresql://', 'postgresql+asyncpg://', 1) + else: + 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 ) diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..e51ccccc3 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -2,15 +2,19 @@ import redis.asyncio as redis from typing import Dict, Any import os +from datetime import datetime, timezone # 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, month: int | None = None, year: int | None = None) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" + now = datetime.now(timezone.utc) + month = month or now.month + year = year or now.year + cache_key = f"revenue:{tenant_id}:{property_id}:{year}:{month}" # Try to get from cache cached = await redis_client.get(cache_key) @@ -22,6 +26,9 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any # Calculate revenue result = await calculate_total_revenue(property_id, tenant_id) + if result.get("month") != month or result.get("year") != year: + from app.services.reservations import calculate_monthly_revenue + result = await calculate_monthly_revenue(property_id, tenant_id, month, year) # 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..5f0de3a2f 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,121 @@ -from datetime import datetime -from decimal import Decimal -from typing import Dict, Any, List - -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) +from datetime import datetime, timezone +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Dict +from zoneinfo import ZoneInfo + + +async def _fetch_monthly_summary(session, property_id: str, tenant_id: str, month: int, year: int) -> Dict[str, Any]: + from sqlalchemy import text + + tz_query = text(""" + SELECT timezone + FROM properties + WHERE id = :property_id AND tenant_id = :tenant_id + LIMIT 1 + """) + tz_result = await session.execute(tz_query, {"property_id": property_id, "tenant_id": tenant_id}) + tz_row = tz_result.fetchone() + property_tz = tz_row.timezone if tz_row and tz_row.timezone else "UTC" + + zone = ZoneInfo(property_tz) + start_local = datetime(year, month, 1, tzinfo=zone) + if month == 12: + end_local = datetime(year + 1, 1, 1, tzinfo=zone) else: - end_date = datetime(year + 1, 1, 1) - - print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}") + end_local = datetime(year, month + 1, 1, tzinfo=zone) - # SQL Simulation (This would be executed against the actual DB) - query = """ - SELECT SUM(total_amount) as total + query = text(""" + SELECT + property_id, + COALESCE(SUM(total_amount), 0) AS total_revenue, + COUNT(*) AS reservation_count, + COALESCE(MAX(currency), 'USD') AS currency 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 + WHERE property_id = :property_id + AND tenant_id = :tenant_id + AND check_in_date >= :start_date + AND check_in_date < :end_date + GROUP BY property_id + """) + + result = await session.execute( + query, + { + "property_id": property_id, + "tenant_id": tenant_id, + "start_date": start_local.astimezone(timezone.utc), + "end_date": end_local.astimezone(timezone.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": str(total_revenue), + "currency": row.currency or "USD", + "count": row.reservation_count, + "month": month, + "year": year, + "timezone": property_tz, + } + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": "0.00", + "currency": "USD", + "count": 0, + "month": month, + "year": year, + "timezone": property_tz, + } + + +async def calculate_monthly_revenue( + property_id: str, + tenant_id: str, + month: int, + year: int, + db_session=None, +) -> Dict[str, Any]: + """Calculate a property's revenue for one month in the property's timezone.""" -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 + + if db_session is not None: + return await _fetch_monthly_summary(db_session, property_id, tenant_id, month, year) + 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: + + if not db_pool.session_factory: raise Exception("Database pool not available") - + + async with (await db_pool.get_session()) as session: + return await _fetch_monthly_summary(session, property_id, tenant_id, month, year) + except Exception as e: + # Database unavailable or error occurred. Return a safe zeroed response + # to avoid leaking mocked financial data across tenants. 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 { "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, + "month": month, + "year": year, + "timezone": "UTC", } + + +async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: + """Aggregate revenue for the current month.""" + + now = datetime.now(timezone.utc) + return await calculate_monthly_revenue(property_id, tenant_id, now.month, now.year) diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..3fed033ef 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -14,21 +14,16 @@ interface RevenueSummaryProps { showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { +export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', 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); @@ -41,7 +36,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr }; fetchRevenue(); - }, [propertyId, activeTenant]); + }, [propertyId]); if (loading) { return ( From c8052d4f6abe9e677a947cdd0dbd64118a9d6aed Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Isah Date: Tue, 14 Jul 2026 13:38:39 +0000 Subject: [PATCH 2/3] Wait for auth before loading profile data --- frontend/src/components/profile/ProfilePage.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/profile/ProfilePage.tsx b/frontend/src/components/profile/ProfilePage.tsx index d80b787d8..1b0c3d12f 100644 --- a/frontend/src/components/profile/ProfilePage.tsx +++ b/frontend/src/components/profile/ProfilePage.tsx @@ -8,8 +8,10 @@ import { toast } from 'react-hot-toast'; import { profileService } from '../../services/profileService'; import { ProfileResponse, ProfileUpdateRequest, PreferencesUpdateRequest } from '../../types/profile'; import AvatarUpload from './AvatarUpload'; +import { useAuth } from '../../contexts/AuthContext'; const ProfilePage: React.FC = () => { + const { user, loading: authLoading } = useAuth(); const [profileData, setProfileData] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -22,8 +24,19 @@ const ProfilePage: React.FC = () => { const [preferencesForm, setPreferencesForm] = useState({}); useEffect(() => { + if (authLoading) { + return; + } + + if (!user) { + setLoading(false); + setProfileData(null); + setError('Please sign in to view your profile'); + return; + } + loadProfile(); - }, []); + }, [user, authLoading]); useEffect(() => { if (!profileData) return; From 0b610ee3b192adab2b59b63ddc13659bf4d4a268 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Isah Date: Tue, 14 Jul 2026 13:41:36 +0000 Subject: [PATCH 3/3] Fix profile session reload and API base resolution --- frontend/src/apiConfig.ts | 5 ++++- frontend/src/components/profile/ProfilePage.tsx | 2 +- frontend/src/lib/apiBase.ts | 10 +++++++++- frontend/src/utils/sessionCleanup.ts | 14 +++++++++++++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/frontend/src/apiConfig.ts b/frontend/src/apiConfig.ts index 1535e09f9..f6965f500 100644 --- a/frontend/src/apiConfig.ts +++ b/frontend/src/apiConfig.ts @@ -1,8 +1,11 @@ import axios from 'axios'; import { supabase } from "./lib/supabase"; +const backendBaseUrl = import.meta.env.VITE_BACKEND_URL + || (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8000` : 'http://localhost:8000'); + const Api = axios.create({ - baseURL: import.meta.env.VITE_BACKEND_URL + '/api/v1', + baseURL: `${backendBaseUrl}/api/v1`, }); diff --git a/frontend/src/components/profile/ProfilePage.tsx b/frontend/src/components/profile/ProfilePage.tsx index 1b0c3d12f..abd0c853c 100644 --- a/frontend/src/components/profile/ProfilePage.tsx +++ b/frontend/src/components/profile/ProfilePage.tsx @@ -8,7 +8,7 @@ import { toast } from 'react-hot-toast'; import { profileService } from '../../services/profileService'; import { ProfileResponse, ProfileUpdateRequest, PreferencesUpdateRequest } from '../../types/profile'; import AvatarUpload from './AvatarUpload'; -import { useAuth } from '../../contexts/AuthContext'; +import { useAuth } from '../../contexts/AuthContext.new'; const ProfilePage: React.FC = () => { const { user, loading: authLoading } = useAuth(); diff --git a/frontend/src/lib/apiBase.ts b/frontend/src/lib/apiBase.ts index ecb719940..f7c2d8166 100644 --- a/frontend/src/lib/apiBase.ts +++ b/frontend/src/lib/apiBase.ts @@ -1,7 +1,15 @@ // API base URL utilities export const getApiBase = (): string => { - return import.meta.env.VITE_BACKEND_URL || ''; + if (import.meta.env.VITE_BACKEND_URL) { + return import.meta.env.VITE_BACKEND_URL; + } + + if (typeof window !== 'undefined') { + return `${window.location.protocol}//${window.location.hostname}:8000`; + } + + return 'http://localhost:8000'; }; export const getApiUrl = (path: string): string => { diff --git a/frontend/src/utils/sessionCleanup.ts b/frontend/src/utils/sessionCleanup.ts index 1c0e5fc36..ddaa83609 100644 --- a/frontend/src/utils/sessionCleanup.ts +++ b/frontend/src/utils/sessionCleanup.ts @@ -4,6 +4,18 @@ import { supabase } from '../lib/supabase'; +const getBackendBaseUrl = (): string => { + if (import.meta.env.VITE_BACKEND_URL) { + return import.meta.env.VITE_BACKEND_URL; + } + + if (typeof window !== 'undefined') { + return `${window.location.protocol}//${window.location.hostname}:8000`; + } + + return 'http://localhost:8000'; +}; + export async function ensureCleanSession() { /** * Force a clean session by: @@ -28,7 +40,7 @@ export async function ensureCleanSession() { const sessionUser = session.user; // Verify the session is valid by checking with backend - const response = await fetch('/api/v1/auth/me', { + const response = await fetch(`${getBackendBaseUrl()}/api/v1/auth/me`, { headers: { 'Authorization': `Bearer ${session.access_token}` }