diff --git a/backend/app/__pycache__/config.cpython-311.pyc b/backend/app/__pycache__/config.cpython-311.pyc new file mode 100644 index 000000000..70c91b796 Binary files /dev/null and b/backend/app/__pycache__/config.cpython-311.pyc differ diff --git a/backend/app/__pycache__/database.cpython-311.pyc b/backend/app/__pycache__/database.cpython-311.pyc new file mode 100644 index 000000000..8965b241e Binary files /dev/null and b/backend/app/__pycache__/database.cpython-311.pyc differ diff --git a/backend/app/__pycache__/main.cpython-311.pyc b/backend/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 000000000..ed8a5dcaf Binary files /dev/null and b/backend/app/__pycache__/main.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/auth_info.cpython-311.pyc b/backend/app/api/v1/__pycache__/auth_info.cpython-311.pyc new file mode 100644 index 000000000..13330cc91 Binary files /dev/null and b/backend/app/api/v1/__pycache__/auth_info.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/bootstrap.cpython-311.pyc b/backend/app/api/v1/__pycache__/bootstrap.cpython-311.pyc new file mode 100644 index 000000000..cb2419333 Binary files /dev/null and b/backend/app/api/v1/__pycache__/bootstrap.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/cities.cpython-311.pyc b/backend/app/api/v1/__pycache__/cities.cpython-311.pyc new file mode 100644 index 000000000..3c42bebe9 Binary files /dev/null and b/backend/app/api/v1/__pycache__/cities.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/city_access_fast.cpython-311.pyc b/backend/app/api/v1/__pycache__/city_access_fast.cpython-311.pyc new file mode 100644 index 000000000..9abab8cef Binary files /dev/null and b/backend/app/api/v1/__pycache__/city_access_fast.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/city_access_fixed.cpython-311.pyc b/backend/app/api/v1/__pycache__/city_access_fixed.cpython-311.pyc new file mode 100644 index 000000000..dde9c6f2b Binary files /dev/null and b/backend/app/api/v1/__pycache__/city_access_fixed.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/company_settings.cpython-311.pyc b/backend/app/api/v1/__pycache__/company_settings.cpython-311.pyc new file mode 100644 index 000000000..890956101 Binary files /dev/null and b/backend/app/api/v1/__pycache__/company_settings.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/dashboard.cpython-311.pyc b/backend/app/api/v1/__pycache__/dashboard.cpython-311.pyc new file mode 100644 index 000000000..b7c941fcc Binary files /dev/null and b/backend/app/api/v1/__pycache__/dashboard.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/departments.cpython-311.pyc b/backend/app/api/v1/__pycache__/departments.cpython-311.pyc new file mode 100644 index 000000000..d3d4d56b2 Binary files /dev/null and b/backend/app/api/v1/__pycache__/departments.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/health.cpython-311.pyc b/backend/app/api/v1/__pycache__/health.cpython-311.pyc new file mode 100644 index 000000000..7701eabba Binary files /dev/null and b/backend/app/api/v1/__pycache__/health.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/login.cpython-311.pyc b/backend/app/api/v1/__pycache__/login.cpython-311.pyc new file mode 100644 index 000000000..35b90c543 Binary files /dev/null and b/backend/app/api/v1/__pycache__/login.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/persistent_auth.cpython-311.pyc b/backend/app/api/v1/__pycache__/persistent_auth.cpython-311.pyc new file mode 100644 index 000000000..34a8ab0b1 Binary files /dev/null and b/backend/app/api/v1/__pycache__/persistent_auth.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/profile.cpython-311.pyc b/backend/app/api/v1/__pycache__/profile.cpython-311.pyc new file mode 100644 index 000000000..57e036aaa Binary files /dev/null and b/backend/app/api/v1/__pycache__/profile.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/users_lightning.cpython-311.pyc b/backend/app/api/v1/__pycache__/users_lightning.cpython-311.pyc new file mode 100644 index 000000000..00e0cc733 Binary files /dev/null and b/backend/app/api/v1/__pycache__/users_lightning.cpython-311.pyc differ diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..efeb7f903 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,44 @@ from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from typing import Dict, Any, List from app.services.cache import get_revenue_summary +from app.services.reservations import get_tenant_properties from app.core.auth import authenticate_request as get_current_user router = APIRouter() + +def _require_tenant(current_user) -> str: + """The tenant always comes from the authenticated user, never a default.""" + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=403, detail="No tenant associated with this user") + return tenant_id + + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: dict = Depends(get_current_user) +) -> List[Dict[str, Any]]: + tenant_id = _require_tenant(current_user) + return await get_tenant_properties(tenant_id) + + @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" - + + tenant_id = _require_tenant(current_user) + revenue_data = await get_revenue_summary(property_id, tenant_id) - - total_revenue_float = float(revenue_data['total']) - + if revenue_data is None: + raise HTTPException(status_code=404, detail="Property not found") + return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + # Exact decimal string, already rounded to cents; never a float + "total_revenue": revenue_data['total'], "currency": revenue_data['currency'], "reservations_count": revenue_data['count'] } diff --git a/backend/app/core/__pycache__/async_processing.cpython-311.pyc b/backend/app/core/__pycache__/async_processing.cpython-311.pyc new file mode 100644 index 000000000..efee6a45e Binary files /dev/null and b/backend/app/core/__pycache__/async_processing.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/auth.cpython-311.pyc b/backend/app/core/__pycache__/auth.cpython-311.pyc new file mode 100644 index 000000000..9948f2038 Binary files /dev/null and b/backend/app/core/__pycache__/auth.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/circuit_breaker_fallback.cpython-311.pyc b/backend/app/core/__pycache__/circuit_breaker_fallback.cpython-311.pyc new file mode 100644 index 000000000..bea6b3dfe Binary files /dev/null and b/backend/app/core/__pycache__/circuit_breaker_fallback.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/database_pool.cpython-311.pyc b/backend/app/core/__pycache__/database_pool.cpython-311.pyc new file mode 100644 index 000000000..30fdd5b98 Binary files /dev/null and b/backend/app/core/__pycache__/database_pool.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/persistent_sessions.cpython-311.pyc b/backend/app/core/__pycache__/persistent_sessions.cpython-311.pyc new file mode 100644 index 000000000..c15122c85 Binary files /dev/null and b/backend/app/core/__pycache__/persistent_sessions.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/redis_client.cpython-311.pyc b/backend/app/core/__pycache__/redis_client.cpython-311.pyc new file mode 100644 index 000000000..60c2b8d51 Binary files /dev/null and b/backend/app/core/__pycache__/redis_client.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/supabase_connection_pool.cpython-311.pyc b/backend/app/core/__pycache__/supabase_connection_pool.cpython-311.pyc new file mode 100644 index 000000000..c497ad551 Binary files /dev/null and b/backend/app/core/__pycache__/supabase_connection_pool.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/tenant_cache.cpython-311.pyc b/backend/app/core/__pycache__/tenant_cache.cpython-311.pyc new file mode 100644 index 000000000..346cd8792 Binary files /dev/null and b/backend/app/core/__pycache__/tenant_cache.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/tenant_context.cpython-311.pyc b/backend/app/core/__pycache__/tenant_context.cpython-311.pyc new file mode 100644 index 000000000..b178241fe Binary files /dev/null and b/backend/app/core/__pycache__/tenant_context.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/tenant_resolver.cpython-311.pyc b/backend/app/core/__pycache__/tenant_resolver.cpython-311.pyc new file mode 100644 index 000000000..cc0bc94ac Binary files /dev/null and b/backend/app/core/__pycache__/tenant_resolver.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/token_encryption.cpython-311.pyc b/backend/app/core/__pycache__/token_encryption.cpython-311.pyc new file mode 100644 index 000000000..d67c4492d Binary files /dev/null and b/backend/app/core/__pycache__/token_encryption.cpython-311.pyc differ diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..1dddf22ce 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,52 +1,65 @@ import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging from ..config import settings logger = logging.getLogger(__name__) + +def _async_database_url(url: str) -> str: + """Convert a plain postgresql:// URL into the asyncpg driver form.""" + for prefix in ("postgresql+asyncpg://", "postgresql://", "postgres://"): + if url.startswith(prefix): + return "postgresql+asyncpg://" + url[len(prefix):] + return url + + class DatabasePool: def __init__(self): self.engine = None self.session_factory = None - + self._lock = asyncio.Lock() + 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 - + """Initialize database connection pool (idempotent)""" + if self.session_factory: + return + async with self._lock: + if self.session_factory: + return + try: + # Async engines use AsyncAdaptedQueuePool by default + self.engine = create_async_engine( + _async_database_url(settings.database_url), + 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 + 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: + """Get database session from pool (use with `async with`)""" if not self.session_factory: raise Exception("Database pool not initialized") return self.session_factory() @@ -56,5 +69,6 @@ async def get_session(self) -> AsyncSession: async def get_db_session() -> AsyncSession: """Dependency to get database session""" + await db_pool.initialize() async with db_pool.get_session() as session: yield session diff --git a/backend/app/models/__pycache__/auth.cpython-311.pyc b/backend/app/models/__pycache__/auth.cpython-311.pyc new file mode 100644 index 000000000..927570546 Binary files /dev/null and b/backend/app/models/__pycache__/auth.cpython-311.pyc differ diff --git a/backend/app/models/__pycache__/profile.cpython-311.pyc b/backend/app/models/__pycache__/profile.cpython-311.pyc new file mode 100644 index 000000000..39f441990 Binary files /dev/null and b/backend/app/models/__pycache__/profile.cpython-311.pyc differ diff --git a/backend/app/monitoring/__pycache__/__init__.cpython-311.pyc b/backend/app/monitoring/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 000000000..854934a92 Binary files /dev/null and b/backend/app/monitoring/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/app/monitoring/__pycache__/middleware.cpython-311.pyc b/backend/app/monitoring/__pycache__/middleware.cpython-311.pyc new file mode 100644 index 000000000..d3819e487 Binary files /dev/null and b/backend/app/monitoring/__pycache__/middleware.cpython-311.pyc differ diff --git a/backend/app/monitoring/__pycache__/performance.cpython-311.pyc b/backend/app/monitoring/__pycache__/performance.cpython-311.pyc new file mode 100644 index 000000000..cb7799336 Binary files /dev/null and b/backend/app/monitoring/__pycache__/performance.cpython-311.pyc differ diff --git a/backend/app/services/__pycache__/cache.cpython-311.pyc b/backend/app/services/__pycache__/cache.cpython-311.pyc new file mode 100644 index 000000000..c11802bb4 Binary files /dev/null and b/backend/app/services/__pycache__/cache.cpython-311.pyc differ diff --git a/backend/app/services/__pycache__/reservations.cpython-311.pyc b/backend/app/services/__pycache__/reservations.cpython-311.pyc new file mode 100644 index 000000000..b13d047aa Binary files /dev/null and b/backend/app/services/__pycache__/reservations.cpython-311.pyc differ diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..8c884b299 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,29 +1,34 @@ import json import redis.asyncio as redis -from typing import Dict, Any +from typing import Dict, Any, Optional import os # 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) -> Optional[Dict[str, Any]]: """ Fetches revenue summary, utilizing caching to improve performance. + Returns None when the property does not belong to the tenant. """ - cache_key = f"revenue:{property_id}" - + # Property IDs are only unique per tenant, so the tenant must be part + # of the key to keep tenants isolated. + cache_key = f"revenue:{tenant_id}:{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. from app.services.reservations import calculate_total_revenue - + # Calculate revenue result = await calculate_total_revenue(property_id, tenant_id) - + if result is None: + return None + # Cache the result for 5 minutes await redis_client.setex(cache_key, 300, json.dumps(result)) - + return result diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..b385e1a80 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,103 @@ 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, List, Optional -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: +from sqlalchemy import text + +from app.core.database_pool import db_pool + +CENT = Decimal("0.01") + + +def round_money(amount: Decimal) -> Decimal: + """Round once, at the end, to whole cents (half-up, like accounting).""" + return Decimal(amount).quantize(CENT, rounding=ROUND_HALF_UP) + + +async def get_tenant_properties(tenant_id: str) -> List[Dict[str, Any]]: + """Lists the properties that belong to a tenant.""" + await db_pool.initialize() + async with db_pool.get_session() as session: + result = await session.execute( + text(""" + SELECT id, name, timezone + FROM properties + WHERE tenant_id = :tenant_id + ORDER BY id + """), + {"tenant_id": tenant_id}, + ) + return [{"id": r.id, "name": r.name, "timezone": r.timezone} for r in result] + + +async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Decimal: """ Calculates revenue for a specific month. + The month is evaluated in the property's own timezone, not UTC, so a + booking at 23:30 UTC on Feb 29 in Paris counts in March. """ - 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]: + + query = text(""" + SELECT COALESCE(SUM(r.total_amount), 0) AS total + FROM reservations r + JOIN properties p + ON p.id = r.property_id AND p.tenant_id = r.tenant_id + WHERE r.property_id = :property_id + AND r.tenant_id = :tenant_id + AND (r.check_in_date AT TIME ZONE p.timezone) >= :start_date + AND (r.check_in_date AT TIME ZONE p.timezone) < :end_date + """) + + await db_pool.initialize() + async with db_pool.get_session() as session: + result = await session.execute(query, { + "property_id": property_id, + "tenant_id": tenant_id, + "start_date": start_date, + "end_date": end_date, + }) + return round_money(result.scalar_one()) + + +async def calculate_total_revenue(property_id: str, tenant_id: str) -> Optional[Dict[str, Any]]: """ - Aggregates revenue from database. + Aggregates revenue from database for one property of one tenant. + Returns None when the property does not belong to the tenant. """ - 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 { + # LEFT JOIN from properties so that an owned property with no bookings + # returns 0, while a property of another tenant returns no row at all. + query = text(""" + SELECT + p.id AS property_id, + COALESCE(SUM(r.total_amount), 0) AS total_revenue, + COUNT(r.id) AS reservation_count + FROM properties p + LEFT JOIN reservations r + ON r.property_id = p.id AND r.tenant_id = p.tenant_id + WHERE p.id = :property_id AND p.tenant_id = :tenant_id + GROUP BY p.id + """) + + await db_pool.initialize() + async with db_pool.get_session() as session: + result = await session.execute(query, { "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] - } + "tenant_id": tenant_id + }) + row = result.fetchone() + + if row is None: + return None + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": str(round_money(row.total_revenue)), + "currency": "USD", + "count": row.reservation_count, + } 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/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..4007a83bc 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,30 @@ -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; + timezone: string; +} const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [properties, setProperties] = useState([]); + const [selectedProperty, setSelectedProperty] = useState(''); + const [loadError, setLoadError] = useState(''); + + useEffect(() => { + // Only the properties of the logged-in user's tenant are returned + SecureAPI.getDashboardProperties() + .then((list: Property[]) => { + setProperties(list); + setSelectedProperty(list[0]?.id ?? ''); + }) + .catch((err) => { + console.error(err); + setLoadError('Failed to load properties'); + }); + }, []); return (
@@ -26,7 +40,7 @@ const Dashboard: React.FC = () => { Monthly performance insights for your properties

- + {/* Property Selector */}
@@ -35,7 +49,7 @@ const Dashboard: React.FC = () => { onChange={(e) => setSelectedProperty(e.target.value)} className="block w-full sm:w-auto min-w-[200px] 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" > - {PROPERTIES.map((property) => ( + {properties.map((property) => ( @@ -46,7 +60,10 @@ const Dashboard: React.FC = () => {
- + {loadError &&
{loadError}
} + {selectedProperty && ( + + )}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..8c45128c7 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,45 +3,58 @@ import { SecureAPI } from '../lib/secureApi'; interface RevenueData { property_id: string; - total_revenue: number; + total_revenue: string; // exact decimal string, already rounded to cents by the server currency: string; reservations_count: number; } interface RevenueSummaryProps { - propertyId?: string; - debugTenant?: string; + propertyId: string; showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { +// Adds thousands separators to a decimal string without converting it to a float +const formatAmount = (amount: string): string => { + const [whole, fraction = '00'] = amount.split('.'); + const sign = whole.startsWith('-') ? '-' : ''; + const digits = whole.replace('-', '').replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return `${sign}${digits}.${fraction.padEnd(2, '0')}`; +}; + +export const RevenueSummary: React.FC = ({ propertyId, showRaw }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const activeTenant = debugTenant || 'candidate'; - useEffect(() => { + let cancelled = false; + const fetchRevenue = async () => { setLoading(true); + setError(''); try { - // Use SecureAPI to handle authentication automatically - // We pass the simulatedTenant option which SecureAPI will attach as a header + // The tenant is resolved by the backend from the auth token const response = await SecureAPI.getDashboardSummary(propertyId, { - simulatedTenant: activeTenant, timestamp: Date.now() }); - setData(response); + // Ignore responses for a property the user already switched away from + if (!cancelled) setData(response); } catch (err) { - setError('Failed to load revenue data'); + if (!cancelled) { + setData(null); + setError('Failed to load revenue data'); + } console.error(err); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; fetchRevenue(); - }, [propertyId, activeTenant]); + return () => { + cancelled = true; + }; + }, [propertyId]); if (loading) { return ( @@ -61,8 +74,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,14 +89,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr

Total Revenue

- {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - - {/* Fake trend indicator for premium feel */} - - - 12% + {data.currency} {formatAmount(data.total_revenue)}
@@ -101,18 +105,6 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr

{data.reservations_count} bookings

- - {/* 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..16fcf46f2 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1450,22 +1450,22 @@ export class SecureAPIClient { // ============= DASHBOARD API ============= /** - * Get dashboard summary with optional simulation header + * Get the properties owned by the current user's tenant */ - async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number }) { + async getDashboardProperties() { + return this.request('/api/v1/dashboard/properties'); + } + + /** + * Get dashboard summary; the tenant is always resolved server-side from the auth token + */ + async getDashboardSummary(propertyId: string, options?: { 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); + return this.request(`/api/v1/dashboard/summary?${queryParams}`); } async uploadCompanyLogo(logo_url: string) { diff --git a/frontend/src/utils/StorageHealthChecker.ts b/frontend/src/utils/StorageHealthChecker.ts index a7da9a0ee..ac5b37b42 100644 --- a/frontend/src/utils/StorageHealthChecker.ts +++ b/frontend/src/utils/StorageHealthChecker.ts @@ -168,9 +168,14 @@ export class StorageHealthChecker { try { const value = localStorage.getItem(key); if (!value) continue; - + + // Plain strings (version marker, language, ...) are legitimate values, + // not corruption. Only JSON-shaped values are validated below. + const trimmed = value.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) continue; + // Try to parse as JSON - const parsed = JSON.parse(value); + const parsed = JSON.parse(trimmed); // Check if it's our storage format if (typeof parsed === 'object' && parsed !== null) { diff --git a/frontend/src/utils/localStorageManager.ts b/frontend/src/utils/localStorageManager.ts index 735938973..7355b8a5d 100644 --- a/frontend/src/utils/localStorageManager.ts +++ b/frontend/src/utils/localStorageManager.ts @@ -14,6 +14,13 @@ import { storageRecoverySystem } from './StorageRecoverySystem'; const STORAGE_VERSION_KEY = 'app_storage_version'; const CURRENT_VERSION = '2.0.0'; // Keep at 2.0.0 to avoid forcing migration on existing users +// Session key used by localAuthClient; wiping it logs the user out +const AUTH_TOKEN_KEY = 'base360-auth-token'; + +// Keys that must survive any storage cleanup or migration +const isAuthKey = (key: string): boolean => + key === AUTH_TOKEN_KEY || key.includes('supabase') || key.includes('sb-'); + interface StorageSchema { version: string; timestamp: number; @@ -113,11 +120,11 @@ class LocalStorageManager { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key && (key.includes('supabase') || key.includes('sb-'))) { + if (key && isAuthKey(key)) { const value = localStorage.getItem(key); if (value) { preservedData[key] = value; - console.log(`Preserving Supabase key: ${key}`); + console.log(`Preserving auth key: ${key}`); } } } @@ -239,14 +246,14 @@ class LocalStorageManager { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key && (key.includes('supabase') || key.includes('sb-'))) { + if (key && isAuthKey(key)) { const value = localStorage.getItem(key); if (value) { preservedData[key] = value; } } } - + // Also preserve version key const versionValue = localStorage.getItem(STORAGE_VERSION_KEY); if (versionValue) { @@ -271,7 +278,12 @@ class LocalStorageManager { private clearCorruptedStorage(): void { console.warn('Clearing corrupted localStorage'); try { + // Never log the user out as a side effect of a storage cleanup + const authToken = localStorage.getItem(AUTH_TOKEN_KEY); localStorage.clear(); + if (authToken) { + localStorage.setItem(AUTH_TOKEN_KEY, authToken); + } localStorage.setItem(STORAGE_VERSION_KEY, CURRENT_VERSION); } catch (error) { console.error('Failed to clear corrupted storage:', error);