diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c156a995a --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.pyo +*.pyd +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +.coverage +htmlcov/ + +# Node / frontend +node_modules/ +frontend/dist/ +dist/ +build/ +*.tsbuildinfo +vite.config.ts.timestamp-*.mjs + +# Environment / secrets +.env +.env.* +!.env.example +.kamal.env + +# OS / editor +.DS_Store +*.swp +.idea/ + +# Misc +tmp/ +*.log diff --git a/backend/app/api/v1/auth_info.py b/backend/app/api/v1/auth_info.py index 5c413de22..8bfd045ad 100644 --- a/backend/app/api/v1/auth_info.py +++ b/backend/app/api/v1/auth_info.py @@ -1,6 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request from ...core.auth import authenticate_request, auth_cache -from ...core.tenant_resolver import TenantResolver from ...models.auth import AuthenticatedUser from ...database import supabase import logging @@ -90,8 +89,8 @@ async def fetch_metadata(): {"section": p.section, "action": p.action} for p in (user.permissions or []) ] - # This ensures /auth/me returns correct tenant like other endpoints - tenant_id = await TenantResolver.resolve_tenant_id(user_id=user.id, user_email=user.email) + # Authentication already resolved this from verified claims/membership. + tenant_id = user.tenant_id logger.info(f"AUTH /me: Fresh tenant lookup for {user.email}: {tenant_id}") # Add smart view permissions if user has access diff --git a/backend/app/api/v1/city_access_fast.py b/backend/app/api/v1/city_access_fast.py index 389469f51..a89902b3a 100644 --- a/backend/app/api/v1/city_access_fast.py +++ b/backend/app/api/v1/city_access_fast.py @@ -10,7 +10,6 @@ from ...database import supabase from ...core.redis_client import redis_client from ...core.tenant_cache import tenant_cache -from ...core.tenant_resolver import TenantResolver import json import time import logging @@ -162,11 +161,11 @@ async def get_city_access_fast( user_id = user.id user_email = user.email - # ✅ UNIFIED TENANT RESOLUTION: Use same TenantResolver as auth.py for consistency + # Use the tenant assignment established by authentication. logger.info(f"🔍 TENANT_RESOLUTION: Starting unified tenant resolution for user {user_email}") - # Use the same comprehensive tenant resolver as authentication - tenant_id = await TenantResolver.resolve_tenant_id(user_id=user_id, user_email=user_email) + # Authentication already resolved the authoritative tenant assignment. + tenant_id = user.tenant_id logger.info(f"✅ TENANT_RESOLUTION: Resolved tenant_id for {user_email}: {tenant_id}") diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..f3b4dc857 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,65 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from typing import Annotated, Any, Dict, List + +from fastapi import APIRouter, Depends, HTTPException, Query + 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.services.reservations import ( + MixedCurrencyError, + PropertyNotFoundError, + get_tenant_properties, +) router = APIRouter() + +def _require_tenant(current_user: AuthenticatedUser) -> str: + tenant_id = current_user.tenant_id + if not tenant_id: + raise HTTPException( + status_code=403, + detail="Authenticated user is not assigned to a tenant", + ) + return tenant_id + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, - current_user: dict = Depends(get_current_user) + month: Annotated[int, Query(ge=1, le=12)], + year: Annotated[int, Query(ge=2000, le=2100)], + current_user: Annotated[AuthenticatedUser, 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']) - + tenant_id = _require_tenant(current_user) + + try: + revenue_data = await get_revenue_summary( + property_id, tenant_id, month, year + ) + except PropertyNotFoundError: + raise HTTPException( + status_code=404, detail="Property not found" + ) from None + except MixedCurrencyError: + raise HTTPException( + status_code=422, + detail="Revenue cannot combine multiple currencies", + ) from None + return { - "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, - "currency": revenue_data['currency'], - "reservations_count": revenue_data['count'] + "property_id": revenue_data["property_id"], + "month": revenue_data["month"], + "year": revenue_data["year"], + "total_revenue": revenue_data["total"], + "currency": revenue_data["currency"], + "reservations_count": revenue_data["count"], } + + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], +) -> List[Dict[str, str]]: + tenant_id = _require_tenant(current_user) + return await get_tenant_properties(tenant_id) diff --git a/backend/app/api/v1/login.py b/backend/app/api/v1/login.py index ede6cf882..3df184f76 100644 --- a/backend/app/api/v1/login.py +++ b/backend/app/api/v1/login.py @@ -144,14 +144,23 @@ async def login(request: LoginRequest): ) # Resolve tenant ID - tenant_id = await TenantResolver.resolve_tenant_id(user_id=user.id, user_email=user.email) + tenant_id = await TenantResolver.resolve_tenant_id( + user_id=user.id, + user_email=user.email, + token_payload={ + "app_metadata": user.app_metadata or {} + }, + ) # Create JWT token user_data = { "id": user.id, "email": user.email, "is_admin": is_admin, - "tenant_id": tenant_id, + "app_metadata": { + "role": user.app_metadata.get("role", "user"), + "tenant_id": tenant_id, + }, "exp": datetime.utcnow() + timedelta(hours=24), "aud": "authenticated" } diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index a85fd5c12..2193e76e0 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -252,8 +252,15 @@ def __init__(self, payload): logger.info(f"==================== TENANT ID EXTRACTION ====================") logger.info(f"User: {user.email} (ID: {user.id})") - # Use TenantResolver for comprehensive tenant resolution - tenant_id = await TenantResolver.resolve_tenant_id(token=token, user_id=user.id, user_email=user.email) + # Resolve only from verified server-controlled claims or DB membership. + tenant_id = await TenantResolver.resolve_tenant_id( + user_id=user.id, + user_email=user.email, + token_payload={ + "app_metadata": getattr(user, "app_metadata", {}) or {} + }, + membership_tenant_ids=tenant_ids, + ) # If we found a tenant_id and it's not in the user's metadata, update it for next time current_tenant_in_metadata = None @@ -508,9 +515,16 @@ async def verify_token_ws(token: str) -> Optional[AuthenticatedUser]: logger.info(f"WS_AUTH: Final user cities after processing: {user_cities}") - # Use the comprehensive tenant resolver (same as regular auth) + # Use the same verified claims and membership data as HTTP auth. logger.info(f"WS_AUTH: Resolving tenant for user {user.email}") - tenant_id = await TenantResolver.resolve_tenant_id(token=token, user_id=user.id, user_email=user.email) + tenant_id = await TenantResolver.resolve_tenant_id( + user_id=user.id, + user_email=user.email, + token_payload={ + "app_metadata": getattr(user, "app_metadata", {}) or {} + }, + membership_tenant_ids=tenant_ids, + ) auth_user = AuthenticatedUser( id=user.id, diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..0c549f123 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,6 +1,4 @@ -import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging from ..config import settings @@ -13,13 +11,22 @@ def __init__(self): async def initialize(self): """Initialize database connection pool""" + if self.session_factory: + 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 = settings.database_url + if database_url.startswith("postgresql://"): + database_url = database_url.replace( + "postgresql://", "postgresql+asyncpg://", 1 + ) + elif database_url.startswith("postgres://"): + database_url = database_url.replace( + "postgres://", "postgresql+asyncpg://", 1 + ) 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 @@ -39,13 +46,14 @@ 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: + def get_session(self) -> AsyncSession: """Get database session from pool""" if not self.session_factory: raise Exception("Database pool not initialized") diff --git a/backend/app/core/tenant_resolver.py b/backend/app/core/tenant_resolver.py index db09a4629..843d99984 100644 --- a/backend/app/core/tenant_resolver.py +++ b/backend/app/core/tenant_resolver.py @@ -1,8 +1,8 @@ """ Minimal tenant resolver for authentication. """ -from typing import Optional import logging +from typing import Optional, Sequence logger = logging.getLogger(__name__) @@ -21,24 +21,14 @@ def resolve_tenant_from_token(token_payload: dict) -> Optional[str]: Returns: Tenant ID if found, None otherwise """ - # Try user_metadata first (most common location) - if 'user_metadata' in token_payload: - tenant_id = token_payload['user_metadata'].get('tenant_id') + # app_metadata is signed and server-controlled. user_metadata is not + # authoritative because users may be able to edit it themselves. + if "app_metadata" in token_payload: + tenant_id = token_payload["app_metadata"].get("tenant_id") if tenant_id: return tenant_id - # Try app_metadata as fallback - if 'app_metadata' in token_payload: - tenant_id = token_payload['app_metadata'].get('tenant_id') - if tenant_id: - return tenant_id - - # Try root level - tenant_id = token_payload.get('tenant_id') - if tenant_id: - return tenant_id - - logger.warning("No tenant_id found in token payload") + logger.warning("No tenant_id found in signed app_metadata") return None @staticmethod @@ -52,24 +42,20 @@ def resolve_tenant_from_user(user_data: dict) -> Optional[str]: Returns: Tenant ID if found, None otherwise """ - # Check various possible locations - if 'tenant_id' in user_data: - return user_data['tenant_id'] - - if 'user_metadata' in user_data: - tenant_id = user_data['user_metadata'].get('tenant_id') - if tenant_id: - return tenant_id - - if 'app_metadata' in user_data: - tenant_id = user_data['app_metadata'].get('tenant_id') + if "app_metadata" in user_data: + tenant_id = user_data["app_metadata"].get("tenant_id") if tenant_id: return tenant_id return None @staticmethod - async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> str: + async def resolve_tenant_id( + user_id: str, + user_email: str, + token_payload: Optional[dict] = None, + membership_tenant_ids: Optional[Sequence[str]] = None, + ) -> Optional[str]: """ Resolve tenant ID for a user. @@ -78,18 +64,25 @@ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] user_email: User email Returns: - Tenant ID + Tenant ID, or None when no authoritative assignment exists. """ - # Fallback mapping by known user email. - if user_email == "sunset@propertyflow.com": - return "tenant-a" - if user_email == "ocean@propertyflow.com": - return "tenant-b" - if user_email == "candidate@propertyflow.com": - return "tenant-a" - - # Default fallback - return "tenant-a" + if token_payload: + tenant_id = TenantResolver.resolve_tenant_from_token(token_payload) + if tenant_id: + return tenant_id + + tenant_ids = [ + tenant_id for tenant_id in membership_tenant_ids or [] if tenant_id + ] + if len(tenant_ids) == 1: + return tenant_ids[0] + + logger.warning( + "Unable to resolve one tenant for user %s (%s)", + user_email, + user_id, + ) + return None @staticmethod async def update_user_tenant_metadata(user_id: str, tenant_id: str) -> None: diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..374a71ab6 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -6,24 +6,42 @@ # 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, + year: int, +) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" + if not tenant_id: + raise ValueError("tenant_id is required for revenue cache access") + + cache_key = ( + f"revenue:{tenant_id}:{property_id}:{year}:{month:02d}" + ) # Try to get from cache cached = await redis_client.get(cache_key) if cached: - return json.loads(cached) + cached_result = json.loads(cached) + if ( + cached_result.get("tenant_id") == tenant_id + and cached_result.get("month") == month + and cached_result.get("year") == year + ): + return cached_result # 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, month, year + ) # Cache the result for 5 minutes - await redis_client.setex(cache_key, 300, json.dumps(result)) + await redis_client.set(cache_key, json.dumps(result), ex=300) return result diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..ce49db0bc 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,111 @@ from datetime import datetime from decimal import Decimal -from typing import Dict, Any, List +from typing import Any, Dict, 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) +class PropertyNotFoundError(Exception): + """Raised when a property is not available to the current tenant.""" + + +class MixedCurrencyError(Exception): + """Raised when a monthly summary would combine multiple currencies.""" + + +def _month_bounds(month: int, year: int) -> tuple[datetime, datetime]: + month_start = datetime(year, month, 1) + if month == 12: + month_end = datetime(year + 1, 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]: - """ - 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 { - "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] - } + month_end = datetime(year, month + 1, 1) + return month_start, month_end + + +async def calculate_monthly_revenue( + property_id: str, + tenant_id: str, + month: int, + year: int, +) -> Dict[str, Any]: + """Calculate one property's revenue using its local calendar month.""" + from sqlalchemy import text + + from app.core.database_pool import db_pool + + month_start, month_end = _month_bounds(month, year) + await db_pool.initialize() + + async with db_pool.get_session() as session: + query = text( + """ + SELECT + p.id AS property_id, + COALESCE(ROUND(SUM(r.total_amount), 2), 0.00) AS total_revenue, + COUNT(r.id) AS reservation_count, + COALESCE(MIN(r.currency), 'USD') AS currency, + COUNT(DISTINCT r.currency) AS currency_count + FROM properties p + LEFT JOIN reservations r + ON r.property_id = p.id + AND r.tenant_id = p.tenant_id + AND (r.check_in_date AT TIME ZONE p.timezone) >= :month_start + AND (r.check_in_date AT TIME ZONE p.timezone) < :month_end + WHERE p.id = :property_id + AND p.tenant_id = :tenant_id + GROUP BY p.id + """ + ) + result = await session.execute( + query, + { + "property_id": property_id, + "tenant_id": tenant_id, + "month_start": month_start, + "month_end": month_end, + }, + ) + row = result.fetchone() + + if not row: + raise PropertyNotFoundError(property_id) + if row.currency_count > 1: + raise MixedCurrencyError( + f"Multiple currencies found for property {property_id}" + ) + + total_revenue = Decimal(str(row.total_revenue)) + return { + "property_id": property_id, + "tenant_id": tenant_id, + "month": month, + "year": year, + "total": format(total_revenue, ".2f"), + "currency": row.currency, + "count": row.reservation_count, + } + + +async def get_tenant_properties(tenant_id: str) -> List[Dict[str, str]]: + """Return the properties visible to a tenant.""" + from sqlalchemy import text + + from app.core.database_pool import db_pool + + 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 name + """ + ), + {"tenant_id": tenant_id}, + ) + rows = result.fetchall() + + return [ + {"id": row.id, "name": row.name, "timezone": row.timezone} + for row in rows + ] diff --git a/backend/tests/test_monthly_revenue.py b/backend/tests/test_monthly_revenue.py new file mode 100644 index 000000000..9c9a006e6 --- /dev/null +++ b/backend/tests/test_monthly_revenue.py @@ -0,0 +1,94 @@ +import unittest +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import patch + +from app.services.reservations import ( + PropertyNotFoundError, + calculate_monthly_revenue, +) + + +class FakeResult: + def __init__(self, row): + self.row = row + + def fetchone(self): + return self.row + + +class FakeSession: + def __init__(self, row): + self.row = row + self.query = None + self.parameters = None + + async def execute(self, query, parameters): + self.query = str(query) + self.parameters = parameters + return FakeResult(self.row) + + +class FakeSessionContext: + def __init__(self, session): + self.session = session + + async def __aenter__(self): + return self.session + + async def __aexit__(self, exc_type, exc_value, traceback): + return False + + +class FakePool: + def __init__(self, session): + self.session = session + self.session_factory = True + + async def initialize(self): + return None + + def get_session(self): + return FakeSessionContext(self.session) + + +class MonthlyRevenueTests(unittest.IsolatedAsyncioTestCase): + async def test_monthly_query_uses_property_timezone_and_decimal_string(self): + row = SimpleNamespace( + property_id="prop-001", + total_revenue=Decimal("2250.00"), + reservation_count=4, + currency="USD", + currency_count=1, + ) + session = FakeSession(row) + + with patch("app.core.database_pool.db_pool", FakePool(session)): + summary = await calculate_monthly_revenue( + "prop-001", "tenant-a", 3, 2024 + ) + + self.assertEqual(summary["total"], "2250.00") + self.assertEqual(summary["count"], 4) + self.assertIn("AT TIME ZONE p.timezone", session.query) + self.assertEqual( + session.parameters["month_start"].isoformat(), + "2024-03-01T00:00:00", + ) + self.assertEqual( + session.parameters["month_end"].isoformat(), + "2024-04-01T00:00:00", + ) + + async def test_foreign_or_unknown_property_is_not_found(self): + session = FakeSession(None) + + with patch("app.core.database_pool.db_pool", FakePool(session)): + with self.assertRaises(PropertyNotFoundError): + await calculate_monthly_revenue( + "prop-004", "tenant-a", 3, 2024 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_revenue_database.py b/backend/tests/test_revenue_database.py new file mode 100644 index 000000000..caff02c66 --- /dev/null +++ b/backend/tests/test_revenue_database.py @@ -0,0 +1,24 @@ +import unittest +from unittest.mock import patch + +from app.core.database_pool import DatabasePool + + +class RevenueDatabaseTests(unittest.IsolatedAsyncioTestCase): + async def test_database_pool_uses_configured_database_url(self): + pool = DatabasePool() + + with patch( + "app.core.database_pool.create_async_engine" + ) as create_engine: + await pool.initialize() + + database_url = str(create_engine.call_args.args[0]) + self.assertEqual( + database_url, + "postgresql+asyncpg://postgres:postgres@db:5432/propertyflow", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_tenant_isolation.py b/backend/tests/test_tenant_isolation.py new file mode 100644 index 000000000..68177271d --- /dev/null +++ b/backend/tests/test_tenant_isolation.py @@ -0,0 +1,116 @@ +import json +import unittest +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException + +from app.api.v1.dashboard import get_dashboard_summary +from app.core.tenant_resolver import TenantResolver +from app.models.auth import AuthenticatedUser +from app.services import cache + + +class FakeRedis: + def __init__(self): + self.values = {} + + async def get(self, key): + return self.values.get(key) + + async def set(self, key, value, ex=None): + self.values[key] = value + + +class TenantIsolationTests(unittest.IsolatedAsyncioTestCase): + async def test_revenue_cache_is_scoped_by_tenant(self): + fake_redis = FakeRedis() + + async def calculate(property_id, tenant_id, month, year): + total = "2250.000" if tenant_id == "tenant-a" else "900.000" + return { + "property_id": property_id, + "tenant_id": tenant_id, + "month": month, + "year": year, + "total": total, + "currency": "USD", + "count": 4, + } + + with ( + patch.object(cache, "redis_client", fake_redis), + patch( + "app.services.reservations.calculate_monthly_revenue", + side_effect=calculate, + ) as calculate_revenue, + ): + sunset = await cache.get_revenue_summary( + "prop-001", "tenant-a", 3, 2024 + ) + ocean = await cache.get_revenue_summary( + "prop-001", "tenant-b", 3, 2024 + ) + sunset_again = await cache.get_revenue_summary( + "prop-001", "tenant-a", 3, 2024 + ) + + self.assertEqual(sunset["total"], "2250.000") + self.assertEqual(ocean["total"], "900.000") + self.assertEqual(sunset_again["total"], "2250.000") + self.assertEqual(calculate_revenue.await_count, 2) + self.assertEqual( + json.loads( + fake_redis.values["revenue:tenant-a:prop-001:2024:03"] + ), + sunset, + ) + self.assertEqual( + json.loads( + fake_redis.values["revenue:tenant-b:prop-001:2024:03"] + ), + ocean, + ) + + async def test_dashboard_rejects_user_without_tenant(self): + user = AuthenticatedUser( + id="user-without-tenant", + email="missing-tenant@example.com", + permissions=[], + cities=[], + is_admin=False, + tenant_id=None, + ) + + with patch( + "app.api.v1.dashboard.get_revenue_summary", + new_callable=AsyncMock, + ) as get_summary: + with self.assertRaises(HTTPException) as error: + await get_dashboard_summary("prop-001", 3, 2024, user) + + self.assertEqual(error.exception.status_code, 403) + get_summary.assert_not_awaited() + + def test_tenant_resolver_uses_signed_app_metadata_only(self): + tenant_id = TenantResolver.resolve_tenant_from_token( + { + "app_metadata": {"tenant_id": "tenant-a"}, + "user_metadata": {"tenant_id": "tenant-b"}, + "tenant_id": "tenant-b", + } + ) + + self.assertEqual(tenant_id, "tenant-a") + + async def test_unknown_user_has_no_default_tenant(self): + tenant_id = await TenantResolver.resolve_tenant_id( + user_id="unknown-user", + user_email="unknown@example.com", + token_payload={"app_metadata": {}}, + ) + + self.assertIsNone(tenant_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/finding.MD b/finding.MD new file mode 100644 index 000000000..40b8f00de --- /dev/null +++ b/finding.MD @@ -0,0 +1,201 @@ +# Property Revenue Dashboard — Findings and Fixes + +## Executive summary + +The investigation identified three primary production issues: + +1. Sunset Properties received incorrect March revenue because the application was not reading the real database successfully and monthly reservations were not explicitly bucketed in the property's timezone. +2. Ocean Rentals could receive Sunset's cached revenue because the Redis key contained only the property ID, even though property IDs are unique only within a tenant. +3. Revenue was converted from PostgreSQL `NUMERIC` to a floating-point number and rounded again in the frontend, creating a risk of cent-level inaccuracies. + +The changes preserve the existing FastAPI, PostgreSQL, Redis, and React architecture. They correct the database connection, calculate local-calendar monthly revenue, isolate cached data by tenant and period, preserve decimal precision, and remove unsafe tenant fallbacks. + +## Finding 1: the revenue service returned mock data + +### Evidence + +The database contains four March reservations for Sunset's `prop-001`, totalling `2250.000`. The service returned `1000.00` and three reservations. + +The database pool attempted to build its connection URL from `supabase_db_*` settings that do not exist. Initialization failed with: + +```text +Settings object has no attribute 'supabase_db_user' +``` + +The revenue service caught that failure and returned hard-coded property data. This made an unavailable database look like a successful financial response. + +### Fix + +- Build the SQLAlchemy async connection from the configured `DATABASE_URL`. +- Use the `postgresql+asyncpg` driver. +- Share one pool per backend process. +- Remove the fabricated financial fallback. +- Propagate database failures instead of returning believable but incorrect values. + +Financial systems should fail visibly when their source of truth is unavailable. + +## Finding 2: March was not defined in the property's timezone + +Sunset's `prop-001` is in `Europe/Paris`. Its boundary reservation checks in at: + +```text +2024-02-29 23:30 UTC +``` + +In Paris, that timestamp is: + +```text +2024-03-01 00:30 Europe/Paris +``` + +It therefore belongs to March for that property. + +### Correct March calculation + +- Boundary reservation: `1250.000` +- Three additional reservations: `333.333 + 333.333 + 333.334 = 1000.000` +- Correct total: `2250.000` +- Correct reservation count: `4` + +### Fix + +The active revenue query now: + +- Joins the property to obtain its IANA timezone. +- Converts each `TIMESTAMP WITH TIME ZONE` using `AT TIME ZONE properties.timezone`. +- Uses a half-open interval: `month_start <= check_in < next_month_start`. +- Accepts explicit `month` and `year` parameters. + +The frontend now sends the selected reporting month and year, making the period shown by the monthly dashboard explicit. + +## Finding 3: Redis cache keys were not tenant-safe + +### Root cause + +The original key was: + +```text +revenue:{property_id} +``` + +Both Sunset and Ocean own a different `prop-001`. If Sunset populated the key first, Ocean received Sunset's cached object before the tenant-filtered database query executed. + +The `_t` query parameter added by the frontend did not bypass Redis because it was not part of the server-side cache key. + +### Fix + +The key now includes every dimension that changes the result: + +```text +revenue:{tenant_id}:{property_id}:{year}:{month} +``` + +The cache also validates the tenant, month, and year stored in the cached payload before returning it. + +## Finding 4: `default_tenant` failed open + +The dashboard previously converted an absent tenant assignment into `default_tenant`. A multi-tenant financial endpoint should never invent an authorization boundary. + +### Fix + +- Authentication returns a typed `AuthenticatedUser`. +- A user without `tenant_id` receives HTTP 403. +- Revenue is never queried or cached under a placeholder tenant. +- Unknown users no longer default to `tenant-a`. +- Foreign and nonexistent property IDs both return the same generic HTTP 404, preventing property-enumeration leaks. + +Tenant claims are read from signed, server-controlled `app_metadata`, not user-editable `user_metadata`. + +## Finding 5: money crossed the API as a float + +PostgreSQL stores reservation amounts as `NUMERIC(10,3)`, which is exact. The old endpoint converted the aggregate with `float()`, and React rounded it again with `Math.round`. + +Binary floating-point cannot exactly represent many decimal fractions. Repeated conversion and rounding can therefore produce incorrect cents. + +### Fix + +- Keep values as PostgreSQL `NUMERIC` while aggregating. +- Sum first and round once to two decimal places in SQL. +- Serialize the final amount as a string such as `"2250.00"`. +- Format that string for display without converting it to a JavaScript `Number`. + +The frontend `formatMoney` helper only adds thousands separators and guarantees two displayed decimal digits: + +```text +"2250.00" -> "2,250.00" +"-1234.5" -> "-1,234.50" +``` + +## Finding 6: the property selector exposed every tenant's properties + +The original React dashboard contained one hard-coded list with properties belonging to both clients. Every user saw the same options. + +### Fix + +`GET /api/v1/dashboard/properties` now: + +1. Uses the authenticated user's tenant. +2. Queries `properties` with `WHERE tenant_id = :tenant_id`. +3. Returns only `id`, `name`, and `timezone`. + +The frontend selector is populated from this endpoint. Sunset sees only Sunset properties, and Ocean sees only Ocean properties. + +The fabricated 12% trend badge was also removed because it was not backed by data. + +## API behavior after the fixes + +### Monthly summary + +```http +GET /api/v1/dashboard/summary?property_id=prop-001&month=3&year=2024 +``` + +Sunset response: + +```json +{ + "property_id": "prop-001", + "month": 3, + "year": 2024, + "total_revenue": "2250.00", + "currency": "USD", + "reservations_count": 4 +} +``` + +Ocean's separate `prop-001` returns `"0.00"` for the same period. + +### Tenant properties + +```http +GET /api/v1/dashboard/properties +``` + +The response contains only properties belonging to the authenticated tenant. + +## Verification performed + +- Ran the backend unit and regression test suite. +- Verified the timezone-aware monthly query. +- Verified exact decimal string output. +- Verified tenant- and period-scoped cache keys. +- Verified a missing tenant is rejected. +- Verified an unknown or foreign property returns 404. +- Built the frontend successfully. +- Logged in through the live API with both supplied client accounts. +- Confirmed Sunset March `prop-001` returns `"2250.00"` and four reservations. +- Confirmed Sunset February `prop-001` returns `"0.00"`, proving the timezone-boundary reservation is assigned to March. +- Confirmed Ocean `prop-001` remains `"0.00"` after Sunset has populated its own cache. +- Confirmed each account receives only its own property list. + +## Conclusion + +The corrected dashboard now reads revenue from the actual database, assigns reservations to calendar months using each property's timezone, preserves decimal precision, and isolates both cached revenue and property metadata by tenant. + +For the supplied dataset: + +- Sunset `prop-001`, March 2024: `USD 2250.00`, four reservations. +- Sunset `prop-001`, February 2024: `USD 0.00`. +- Ocean `prop-001`, March 2024: `USD 0.00`. + +Repeated requests from either client return only that client's results. diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..477dacc57 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,52 +1,156 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; +import { SecureAPI } from "../lib/secureApi"; 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' } -]; +interface Property { + id: string; + name: string; + timezone: string; +} + +const MONTHS = Array.from({ length: 12 }, (_, index) => ({ + value: index + 1, + label: new Intl.DateTimeFormat(undefined, { month: "long" }).format( + new Date(2024, index, 1), + ), +})); 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 [propertiesError, setPropertiesError] = useState(""); + + useEffect(() => { + let ignore = false; + + const fetchProperties = async () => { + try { + const tenantProperties = await SecureAPI.getDashboardProperties(); + if (ignore) return; + + setProperties(tenantProperties); + setSelectedProperty((current) => + tenantProperties.some((property) => property.id === current) + ? current + : (tenantProperties[0]?.id ?? ""), + ); + } catch (error) { + if (!ignore) setPropertiesError("Failed to load properties"); + console.error(error); + } + }; + + fetchProperties(); + return () => { + ignore = true; + }; + }, []); return (
-

Property Management Dashboard

+

+ Property Management Dashboard +

-

Revenue Overview

+

+ Revenue Overview +

Monthly performance insights for your properties

- - {/* Property Selector */} -
- - + +
+
+ + +
+ +
+ + +
+ +
+ + { + if (!Number.isNaN(event.target.valueAsNumber)) { + setYear(event.target.valueAsNumber); + } + }} + 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} +
+ )} + {selectedProperty && ( + + )}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..92030ea8e 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -1,119 +1,134 @@ -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; + month: number; + year: number; + total_revenue: string; + currency: string; + reservations_count: number; } 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'; +const formatMoney = (amount: string): string => { + const isNegative = amount.startsWith("-"); + const unsigned = isNegative ? amount.slice(1) : amount; + const [whole, fraction = "00"] = unsigned.split("."); + const groupedWhole = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return `${isNegative ? "-" : ""}${groupedWhole}.${fraction.padEnd(2, "0").slice(0, 2)}`; +}; - 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); - } - }; +export const RevenueSummary: React.FC = ({ + propertyId, + month, + year, + showRaw, +}) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); - fetchRevenue(); - }, [propertyId, activeTenant]); + useEffect(() => { + let ignore = false; - if (loading) { - return ( -
-
-
-
-
-
-
-
-
-
+ const fetchRevenue = async () => { + setLoading(true); + setError(""); + try { + const response = await SecureAPI.getDashboardSummary( + propertyId, + month, + year, ); - } - - if (error) return
{error}
; - if (!data) return null; + if (!ignore) setData(response); + } catch (err) { + if (!ignore) setError("Failed to load revenue data"); + console.error(err); + } finally { + if (!ignore) setLoading(false); + } + }; - const displayTotal = Math.round(data.total_revenue * 100) / 100; + fetchRevenue(); + return () => { + ignore = true; + }; + }, [propertyId, month, year]); + if (loading) { 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% - -
-
-
+ if (error) + return
{error}
; + if (!data) return null; -
-
-

Property ID

-

{data.property_id}

-
-
-

Reservations

-

{data.reservations_count} bookings

-
-
+ const periodLabel = new Intl.DateTimeFormat(undefined, { + month: "long", + year: "numeric", + }).format(new Date(data.year, data.month - 1, 1)); - {/* Precision Warning Area */} -
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( -
- - - - Precision Mismatch Detected -
- )} -
-
+ return ( +
+ {showRaw && ( +
+ + Raw API Response + +
{JSON.stringify(data, null, 2)}
- ); + )} + +
+
+

+ {periodLabel} Revenue +

+
+ + {data.currency} {formatMoney(data.total_revenue)} + +
+
+ +
+
+

+ Property ID +

+

+ {data.property_id} +

+
+
+

+ Reservations +

+

+ {data.reservations_count}{" "} + bookings +

+
+
+
+
+ ); }; diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90..6463c8f1e 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1,37 +1,45 @@ /** * SECURE MULTI-TENANT API CLIENT - * + * * This module provides a centralized, secure API client that ensures ALL database * queries go through the backend with proper tenant isolation. - * + * * Always use this SecureAPI client instead. - * + * * @security This prevents cross-tenant data leakage by enforcing backend-only database access */ -import { supabase } from './supabase'; -import { sessionManager } from '../utils/sessionManager'; -import { withRetry, handleApiError, classifyError } from '../utils/apiErrorHandler'; +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 = () => { // For production/staging (non-localhost), use relative URLs to avoid CORS - if (typeof window !== 'undefined' && - window.location.hostname !== 'localhost' && - window.location.hostname !== '127.0.0.1') { - console.log(`[SecureAPI] Using relative URLs for ${window.location.hostname}`); - return ''; // Empty string means relative URLs - the browser will use the same domain + if ( + typeof window !== "undefined" && + window.location.hostname !== "localhost" && + window.location.hostname !== "127.0.0.1" + ) { + console.log( + `[SecureAPI] Using relative URLs for ${window.location.hostname}`, + ); + return ""; // Empty string means relative URLs - the browser will use the same domain } // For local development, check for configured URL const configuredUrl = import.meta.env.VITE_BACKEND_URL; - if (configuredUrl && !configuredUrl.includes('localhost')) { + if (configuredUrl && !configuredUrl.includes("localhost")) { // If it's not localhost but we're in development, it might be a remote backend return configuredUrl; } // Default to localhost for development - return configuredUrl || 'http://localhost:8000'; + return configuredUrl || "http://localhost:8000"; }; const BACKEND_URL = getBackendUrl(); @@ -39,7 +47,7 @@ const BACKEND_URL = getBackendUrl(); export class TenantIsolationError extends Error { constructor(message: string) { super(message); - this.name = 'TenantIsolationError'; + this.name = "TenantIsolationError"; } } @@ -73,38 +81,42 @@ export class SecureAPIClient { */ private interceptDirectQueries() { if (import.meta.env.DEV) { - const ENFORCE = (import.meta.env as any).VITE_ENFORCE_SECURE_API === 'true'; + const ENFORCE = + (import.meta.env as any).VITE_ENFORCE_SECURE_API === "true"; const originalFrom = supabase.from; // Temporary dev allowlist for legacy direct queries while migrating to SecureAPI const DEV_ALLOWLIST = new Set([ - 'user_permissions', - 'users_city', - 'user_preferences', - 'access_logs', - 'landlord_details' + "user_permissions", + "users_city", + "user_preferences", + "access_logs", + "landlord_details", ]); supabase.from = (table: string) => { - const stack = new Error().stack || ''; + const stack = new Error().stack || ""; const violation = `SECURITY VIOLATION: Direct Supabase query to table '${table}'`; if (DEV_ALLOWLIST.has(table) || !ENFORCE) { // Allow in development with a clear warning when not enforcing strict mode - console.warn(`⚠️ Legacy direct query allowed in DEV${ENFORCE ? ' (allowlist)' : ''}:`, table); + console.warn( + `⚠️ Legacy direct query allowed in DEV${ENFORCE ? " (allowlist)" : ""}:`, + table, + ); if (!DEV_ALLOWLIST.has(table) && !ENFORCE) { // Record violation for later review, but don't block - this.securityViolations.push(violation + ' (allowed in DEV)'); + this.securityViolations.push(violation + " (allowed in DEV)"); } return originalFrom.call(supabase, table); } // Strict enforcement in DEV when VITE_ENFORCE_SECURE_API=true - console.error('🚨🚨🚨 ' + violation); - console.error('Stack trace:', stack); + console.error("🚨🚨🚨 " + violation); + console.error("Stack trace:", stack); this.securityViolations.push(violation); throw new TenantIsolationError( `Direct database access is forbidden! Use SecureAPI.${table}() instead. ` + - `This query would expose data from ALL tenants.` + `This query would expose data from ALL tenants.`, ); }; } @@ -119,13 +131,17 @@ export class SecureAPIClient { // If no cached token, get a validated session if (!token) { - console.log('[SecureAPI] No cached token, waiting for session or validating...'); + console.log( + "[SecureAPI] No cached token, waiting for session or validating...", + ); // First, wait briefly for a session to appear to avoid racing login const waited = await this.waitForSession(5000); - const session = waited || await sessionManager.ensureValidSession(); + const session = waited || (await sessionManager.ensureValidSession()); if (!session || !session.access_token) { - throw new TenantIsolationError('No valid authentication token available'); + throw new TenantIsolationError( + "No valid authentication token available", + ); } token = session.access_token; @@ -134,10 +150,10 @@ export class SecureAPIClient { } return { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - 'X-Request-ID': `req_${Date.now()}_${++this.requestCount}`, - 'X-Client-Version': '2.0.0-secure' + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-Request-ID": `req_${Date.now()}_${++this.requestCount}`, + "X-Client-Version": "2.0.0-secure", }; } @@ -155,10 +171,13 @@ export class SecureAPIClient { // Log tenant changes for security monitoring if (previousTenant && token) { - console.log('[SecureAPI SECURITY] Tenant context changed - cache cleared', { - previousTenant: previousTenant, - timestamp: new Date().toISOString() - }); + console.log( + "[SecureAPI SECURITY] Tenant context changed - cache cleared", + { + previousTenant: previousTenant, + timestamp: new Date().toISOString(), + }, + ); } } this.cachedToken = token || null; @@ -184,9 +203,10 @@ export class SecureAPIClient { extractedTenantId = "tenant-a"; } // Check if it's a valid JWT - else if (token.includes('.') && token.split('.').length === 3) { - const payload = JSON.parse(atob(token.split('.')[1])); - extractedTenantId = payload.user_metadata?.tenant_id || payload.tenant_id; + else if (token.includes(".") && token.split(".").length === 3) { + const payload = JSON.parse(atob(token.split(".")[1])); + extractedTenantId = + payload.user_metadata?.tenant_id || payload.tenant_id; } if (extractedTenantId) { @@ -195,7 +215,10 @@ export class SecureAPIClient { this.cachedTenantId = extractedTenantId; return this.cachedTenantId; } else { - console.error('[SecureAPI] Invalid tenant ID format:', extractedTenantId); + console.error( + "[SecureAPI] Invalid tenant ID format:", + extractedTenantId, + ); // Clear invalid session to force re-authentication this.cachedToken = null; this.cachedTenantId = null; @@ -203,7 +226,10 @@ export class SecureAPIClient { } } } catch (error) { - console.error('[SecureAPI] JWT parsing failed - clearing session:', error); + console.error( + "[SecureAPI] JWT parsing failed - clearing session:", + error, + ); // Clear potentially corrupted session data this.cachedToken = null; this.cachedTenantId = null; @@ -222,19 +248,19 @@ export class SecureAPIClient { try { const token = this.cachedToken; if (token) { - const payload = JSON.parse(atob(token.split('.')[1])); + const payload = JSON.parse(atob(token.split(".")[1])); // Use user sub (unique ID) + email as session key for isolation const userSub = payload.sub; const userEmail = payload.email; if (userSub && userEmail) { // Create short hash to avoid very long cache keys - const sessionKey = `${userSub.substring(0, 8)}-${userEmail.split('@')[0]}`; + const sessionKey = `${userSub.substring(0, 8)}-${userEmail.split("@")[0]}`; return sessionKey; } } } catch (error) { - console.error('[SecureAPI] Failed to extract user session key:', error); + console.error("[SecureAPI] Failed to extract user session key:", error); } return null; } @@ -244,8 +270,13 @@ export class SecureAPIClient { */ private isValidTenantId(tenantId: string): boolean { // Check for UUID format (basic validation) - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - return typeof tenantId === 'string' && tenantId.length > 0 && uuidRegex.test(tenantId); + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return ( + typeof tenantId === "string" && + tenantId.length > 0 && + uuidRegex.test(tenantId) + ); } /** @@ -254,7 +285,7 @@ export class SecureAPIClient { public clearCache(): void { this.requestCache.clear(); this.pendingRequests.clear(); - console.log('[SecureAPI] Cache cleared due to tenant/token change'); + console.log("[SecureAPI] Cache cleared due to tenant/token change"); } /** @@ -271,7 +302,7 @@ export class SecureAPIClient { } } - keysToDelete.forEach(key => { + keysToDelete.forEach((key) => { this.requestCache.delete(key); }); @@ -283,12 +314,14 @@ export class SecureAPIClient { } } - pendingKeysToDelete.forEach(key => { + pendingKeysToDelete.forEach((key) => { this.pendingRequests.delete(key); }); if (cleared > 0) { - console.log(`[SecureAPI] Cleared ${cleared} cache entries for pattern: ${endpointPattern}`); + console.log( + `[SecureAPI] Cleared ${cleared} cache entries for pattern: ${endpointPattern}`, + ); } return cleared; @@ -309,7 +342,8 @@ export class SecureAPIClient { let cleaningEntries = 0; let oldestAge = 0; let newestAge = Infinity; - const suspiciousEntries: Array<{ key: string; age: number; data: any }> = []; + const suspiciousEntries: Array<{ key: string; age: number; data: any }> = + []; for (const [key, value] of this.requestCache.entries()) { const age = now - value.timestamp; @@ -317,15 +351,19 @@ export class SecureAPIClient { if (age > oldestAge) oldestAge = age; if (age < newestAge) newestAge = age; - if (key.includes('secure/cleaning/reports')) { + if (key.includes("secure/cleaning/reports")) { cleaningEntries++; // Check for suspicious cleaning cache entries const items = value.data?.items || value.data?.data || []; const total = value.data?.total || 0; - if (total === 0 && key.includes('overdue')) { - suspiciousEntries.push({ key, age, data: { total, itemsCount: items.length } }); + if (total === 0 && key.includes("overdue")) { + suspiciousEntries.push({ + key, + age, + data: { total, itemsCount: items.length }, + }); } } } @@ -336,14 +374,16 @@ export class SecureAPIClient { cleaningCacheEntries: cleaningEntries, oldestCacheAge: oldestAge, newestCacheAge: newestAge === Infinity ? 0 : newestAge, - suspiciousEntries + suspiciousEntries, }; } /** */ public emergencySecurityClear(): void { - console.warn('[SecureAPI EMERGENCY] Security clear - all cache and session data cleared'); + console.warn( + "[SecureAPI EMERGENCY] Security clear - all cache and session data cleared", + ); this.cachedToken = null; this.cachedTenantId = null; this.requestCache.clear(); @@ -355,26 +395,30 @@ export class SecureAPIClient { * Generate cache key that includes query parameters to prevent cache collisions * between different API calls to the same endpoint with different parameters */ - private async generateCacheKey(method: string, endpoint: string, tenantId: string): Promise { + private async generateCacheKey( + method: string, + endpoint: string, + tenantId: string, + ): Promise { // This ensures different users don't share cached/pending requests const userSessionKey = await this.getUserSessionKey(); - const sessionPart = userSessionKey ? `:${userSessionKey}` : ''; + const sessionPart = userSessionKey ? `:${userSessionKey}` : ""; // For endpoints with query parameters, include them in the cache key // to prevent cache collisions (e.g., different cleaning tabs) let cacheKey: string; - if (endpoint.includes('?')) { + if (endpoint.includes("?")) { // Parse the endpoint to extract base path and query parameters - const [basePath, queryString] = endpoint.split('?'); + const [basePath, queryString] = endpoint.split("?"); // Sort query parameters for consistent cache keys const params = new URLSearchParams(queryString); const sortedParams = Array.from(params.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) - .join('&'); + .join("&"); // Include sorted parameters, tenant ID, and user session in cache key cacheKey = `${method}:${basePath}?${sortedParams}:${tenantId}${sessionPart}`; @@ -384,11 +428,15 @@ export class SecureAPIClient { } // Debug logging for cache key generation - if (endpoint.includes('secure/cleaning/reports') || endpoint.includes('properties')) { - console.log('[SecureAPI] Generated cache key:', { + if ( + endpoint.includes("secure/cleaning/reports") || + endpoint.includes("properties") + ) { + console.log("[SecureAPI] Generated cache key:", { endpoint, - cacheKey: cacheKey.length > 100 ? cacheKey.substring(0, 100) + '...' : cacheKey, - hasQueryParams: endpoint.includes('?') + cacheKey: + cacheKey.length > 100 ? cacheKey.substring(0, 100) + "..." : cacheKey, + hasQueryParams: endpoint.includes("?"), }); } @@ -401,7 +449,7 @@ export class SecureAPIClient { */ private shouldCacheCleaningResult(endpoint: string, data: any): boolean { // Only apply validation to cleaning endpoints (both secure and legacy) - if (!endpoint.includes('/cleaning/reports')) { + if (!endpoint.includes("/cleaning/reports")) { return true; // Cache all other endpoints normally } @@ -411,13 +459,19 @@ export class SecureAPIClient { // Always cache properly structured responses, even if empty // Empty results can be legitimate for users without tenant access or specific date ranges - if (data && typeof data === 'object' && (data.hasOwnProperty('items') || data.hasOwnProperty('data') || data.hasOwnProperty('total'))) { + if ( + data && + typeof data === "object" && + (data.hasOwnProperty("items") || + data.hasOwnProperty("data") || + data.hasOwnProperty("total")) + ) { return true; // Cache all properly structured responses } // Only skip caching for malformed responses if (data === null || data === undefined) { - console.warn('[SecureAPI] Not caching null/undefined cleaning result'); + console.warn("[SecureAPI] Not caching null/undefined cleaning result"); return false; } @@ -428,9 +482,13 @@ export class SecureAPIClient { * Validates if a cached result is still valid and hasn't become stale * Helps detect and clear corrupted cache entries */ - private isCacheResultValid(endpoint: string, cachedData: any, cacheAge: number): boolean { + private isCacheResultValid( + endpoint: string, + cachedData: any, + cacheAge: number, + ): boolean { // For non-cleaning endpoints, use normal cache validation - if (!endpoint.includes('/cleaning/reports')) { + if (!endpoint.includes("/cleaning/reports")) { return cacheAge < this.CACHE_TTL; } @@ -455,8 +513,11 @@ export class SecureAPIClient { } // Validate that we have expected data structure - if (typeof cachedData !== 'object' || (!('items' in cachedData) && !('data' in cachedData))) { - console.warn('[SecureAPI] Invalidating malformed cached cleaning result'); + if ( + typeof cachedData !== "object" || + (!("items" in cachedData) && !("data" in cachedData)) + ) { + console.warn("[SecureAPI] Invalidating malformed cached cleaning result"); return false; } @@ -466,22 +527,25 @@ export class SecureAPIClient { /** * Wait for a valid Supabase session (with timeout) so API calls don't race login. */ - private async waitForSession(timeoutMs: number = 7000): Promise { + private async waitForSession( + timeoutMs: number = 7000, + ): Promise { // Helper to read token from Supabase storage as last resort const getTokenFromStorage = (): string | null => { try { - if (typeof localStorage === 'undefined') return null; + if (typeof localStorage === "undefined") return null; for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i) || ''; - if (key.startsWith('sb-') && key.endsWith('-auth-token')) { + const key = localStorage.key(i) || ""; + if (key.startsWith("sb-") && key.endsWith("-auth-token")) { const raw = localStorage.getItem(key); if (!raw) continue; const parsed = JSON.parse(raw); - const token = parsed?.access_token || parsed?.currentSession?.access_token; + const token = + parsed?.access_token || parsed?.currentSession?.access_token; if (token) return token; } } - } catch { } + } catch {} return null; }; @@ -502,7 +566,9 @@ export class SecureAPIClient { // Listen + poll concurrently let unsubscribe: (() => void) | null = null; let resolved = false; - const sessionPromise = new Promise((resolve) => { + const sessionPromise = new Promise< + import("@supabase/supabase-js").Session | null + >((resolve) => { const { data } = supabase.auth.onAuthStateChange((_event, session) => { if (!resolved && session?.access_token) { resolved = true; @@ -513,7 +579,9 @@ export class SecureAPIClient { unsubscribe = () => data.subscription.unsubscribe(); }); - const pollingPromise = new Promise(async (resolve) => { + const pollingPromise = new Promise< + import("@supabase/supabase-js").Session | null + >(async (resolve) => { const start = Date.now(); while (Date.now() - start < timeoutMs) { const token = await nowHasToken(); @@ -523,13 +591,19 @@ export class SecureAPIClient { resolve({ access_token: token } as any); return; } - await new Promise(r => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 200)); } resolve(null); }); - const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)); - const result = await Promise.race([sessionPromise, pollingPromise, timeoutPromise]); + const timeoutPromise = new Promise((resolve) => + setTimeout(() => resolve(null), timeoutMs), + ); + const result = await Promise.race([ + sessionPromise, + pollingPromise, + timeoutPromise, + ]); if (unsubscribe) unsubscribe(); return result; } @@ -539,10 +613,10 @@ export class SecureAPIClient { */ private async request( endpoint: string, - options: RequestInit = {} + options: RequestInit = {}, ): Promise { - const method = options.method || 'GET'; - const isGetRequest = method === 'GET'; + const method = options.method || "GET"; + const isGetRequest = method === "GET"; // Log ALL API requests to track order const timestamp = new Date().toISOString(); @@ -552,7 +626,9 @@ export class SecureAPIClient { const tenantId = await this.getTenantId(); if (!tenantId) { - console.warn(`[API SECURITY] No valid tenant ID - bypassing cache for ${endpoint}`); + console.warn( + `[API SECURITY] No valid tenant ID - bypassing cache for ${endpoint}`, + ); return this.executeRequest(endpoint, options, null, false); } @@ -566,11 +642,15 @@ export class SecureAPIClient { if (cached) { const cacheAge = Date.now() - cached.timestamp; if (this.isCacheResultValid(endpoint, cached.data, cacheAge)) { - console.log(`[API CACHE HIT] ${endpoint} (tenant: ${tenantId}) age: ${cacheAge}ms`); + console.log( + `[API CACHE HIT] ${endpoint} (tenant: ${tenantId}) age: ${cacheAge}ms`, + ); return cached.data; } else { // Remove invalid cache entry - console.log(`[API CACHE INVALID] ${endpoint} - removing stale/suspicious entry`); + console.log( + `[API CACHE INVALID] ${endpoint} - removing stale/suspicious entry`, + ); this.requestCache.delete(requestKey); } } @@ -578,20 +658,30 @@ export class SecureAPIClient { // Check if request is already pending const pending = this.pendingRequests.get(requestKey); if (pending) { - console.log(`[API DEDUP] ${endpoint} - waiting for pending request (tenant: ${tenantId})`); + console.log( + `[API DEDUP] ${endpoint} - waiting for pending request (tenant: ${tenantId})`, + ); try { const result = await Promise.race([ pending, new Promise((_, reject) => { - setTimeout(() => reject(new Error('Pending request timeout')), 10000); // 10 second timeout - }) + setTimeout( + () => reject(new Error("Pending request timeout")), + 10000, + ); // 10 second timeout + }), ]); // Empty overdue results are valid and should be returned from deduplication - console.log(`[API DEDUP SUCCESS] ${endpoint} - returned cached result`); + console.log( + `[API DEDUP SUCCESS] ${endpoint} - returned cached result`, + ); return result; } catch (error) { - console.warn('[SecureAPI] Pending request failed or timed out, making fresh request:', error); + console.warn( + "[SecureAPI] Pending request failed or timed out, making fresh request:", + error, + ); this.pendingRequests.delete(requestKey); // Continue to make fresh request } @@ -599,7 +689,12 @@ export class SecureAPIClient { } // Create the request promise - const requestPromise = this.executeRequest(endpoint, options, requestKey, isGetRequest); + const requestPromise = this.executeRequest( + endpoint, + options, + requestKey, + isGetRequest, + ); // Store pending request for deduplication (only with valid cache key) if (isGetRequest && requestKey) { @@ -616,7 +711,7 @@ export class SecureAPIClient { endpoint: string, options: RequestInit, requestKey: string | null, - isGetRequest: boolean + isGetRequest: boolean, ): Promise { const MAX_RETRIES = 3; // Increased for better resilience const RETRY_DELAY_BASE = 1000; // Reasonable delay for retries @@ -627,55 +722,64 @@ export class SecureAPIClient { const headers = await this.getAuthHeaders(); const url = `${this.backendUrl}${endpoint}`; - // Only show attempt number if this is a retry if (attempt === 1) { - console.log(`🔒 Secure API Request: ${options.method || 'GET'} ${endpoint}`); + console.log( + `🔒 Secure API Request: ${options.method || "GET"} ${endpoint}`, + ); } else { - console.log(`🔒 Secure API Request: ${options.method || 'GET'} ${endpoint} (retry ${attempt - 1}/${MAX_RETRIES - 1})`); + console.log( + `🔒 Secure API Request: ${options.method || "GET"} ${endpoint} (retry ${attempt - 1}/${MAX_RETRIES - 1})`, + ); } const response = await fetch(url, { ...options, headers: { ...headers, - ...options.headers - } + ...options.headers, + }, }); if (!response.ok) { - let bodyText = ''; - try { bodyText = await response.text(); } catch { } - let detail = ''; + let bodyText = ""; + try { + bodyText = await response.text(); + } catch {} + let detail = ""; let errorData = null; try { errorData = JSON.parse(bodyText); - detail = errorData?.detail || errorData?.message || ''; - console.error('🔴 API Error Details:', { + detail = errorData?.detail || errorData?.message || ""; + console.error("🔴 API Error Details:", { status: response.status, statusText: response.statusText, errorData, bodyText, - url: response.url + url: response.url, }); } catch (parseError) { - console.error('🔴 Could not parse error response:', bodyText); + console.error("🔴 Could not parse error response:", bodyText); } // Don't retry on 403 (forbidden) - it won't help if (response.status === 403) { throw new TenantIsolationError( - detail || 'Access denied: You can only access data from your organization' + detail || + "Access denied: You can only access data from your organization", ); } // If we get 401, try to refresh the session before retrying if (response.status === 401) { - console.log('[SecureAPI] Got 401, attempting to refresh session...'); + console.log( + "[SecureAPI] Got 401, attempting to refresh session...", + ); // Import sessionValidator dynamically to avoid circular dependency - const { sessionValidator } = await import('../utils/sessionValidator'); + const { sessionValidator } = + await import("../utils/sessionValidator"); // Clear cached token this.cachedToken = null; @@ -684,32 +788,37 @@ export class SecureAPIClient { const refreshedSession = await sessionValidator.validateSession(); if (refreshedSession?.access_token) { - console.log('[SecureAPI] Session refreshed, will retry with new token'); + console.log( + "[SecureAPI] Session refreshed, will retry with new token", + ); this.cachedToken = refreshedSession.access_token; // Only retry if we haven't exceeded max attempts if (attempt < MAX_RETRIES) { - throw new Error('Authentication refreshed, will retry with new token'); + throw new Error( + "Authentication refreshed, will retry with new token", + ); } } // If we can't refresh or no more retries, fail with 401 - throw new Error('Authentication failed - please login again'); + throw new Error("Authentication failed - please login again"); } - const msg = detail || bodyText || `${response.status} ${response.statusText}`; + const msg = + detail || bodyText || `${response.status} ${response.statusText}`; throw new Error(`API request failed: ${msg}`); } // Handle response parsing based on content type and status let data; - const contentType = response.headers.get('content-type'); + const contentType = response.headers.get("content-type"); // Check if response has content to parse if (response.status === 204 || response.status === 205) { // No Content responses - return null or empty object data = null; - } else if (contentType && contentType.includes('application/json')) { + } else if (contentType && contentType.includes("application/json")) { // Only parse JSON if content-type indicates JSON const text = await response.text(); if (text.trim()) { @@ -731,11 +840,15 @@ export class SecureAPIClient { if (shouldCache) { this.requestCache.set(requestKey, { data, - timestamp: Date.now() + timestamp: Date.now(), }); - console.log(`[API CACHE STORE] ${endpoint} (tenant: ${requestKey.split(':')[2]})`); + console.log( + `[API CACHE STORE] ${endpoint} (tenant: ${requestKey.split(":")[2]})`, + ); } else { - console.log(`[API CACHE SKIP] ${endpoint} - result validation failed`); + console.log( + `[API CACHE SKIP] ${endpoint} - result validation failed`, + ); } } @@ -757,13 +870,17 @@ export class SecureAPIClient { throw error; } - console.error(`[SecureAPI] Attempt ${attempt}/${MAX_RETRIES} failed:`, error); + console.error( + `[SecureAPI] Attempt ${attempt}/${MAX_RETRIES} failed:`, + error, + ); // If this isn't the last attempt, retry with exponential backoff if (attempt < MAX_RETRIES) { - const delay = RETRY_DELAY_BASE * Math.pow(2, attempt - 1) + Math.random() * 500; + const delay = + RETRY_DELAY_BASE * Math.pow(2, attempt - 1) + Math.random() * 500; console.log(`[SecureAPI] Retrying in ${Math.round(delay)}ms...`); - await new Promise(resolve => setTimeout(resolve, delay)); + await new Promise((resolve) => setTimeout(resolve, delay)); } } } @@ -773,11 +890,13 @@ export class SecureAPIClient { this.pendingRequests.delete(requestKey); // Also clear any potentially corrupted cache entry this.requestCache.delete(requestKey); - console.log(`[API CACHE CLEAR] ${endpoint} - cleared due to failed request`); + console.log( + `[API CACHE CLEAR] ${endpoint} - cleared due to failed request`, + ); } - console.error('[SecureAPI] All retry attempts failed'); - throw lastError || new Error('Request failed after all retries'); + console.error("[SecureAPI] All retry attempts failed"); + throw lastError || new Error("Request failed after all retries"); } /** @@ -785,20 +904,22 @@ export class SecureAPIClient { */ private async requestPublic( endpoint: string, - options: RequestInit = {} + options: RequestInit = {}, ): Promise { const url = `${this.backendUrl}${endpoint}`; const response = await fetch(url, { ...options, headers: { - 'Content-Type': 'application/json', - 'X-Request-ID': `req_${Date.now()}_${++this.requestCount}`, - 'X-Client-Version': '2.0.0-secure', - ...(options.headers || {}) - } + "Content-Type": "application/json", + "X-Request-ID": `req_${Date.now()}_${++this.requestCount}`, + "X-Client-Version": "2.0.0-secure", + ...(options.headers || {}), + }, }); if (!response.ok) { - throw new Error(`API request failed: ${response.status} ${response.statusText}`); + throw new Error( + `API request failed: ${response.status} ${response.statusText}`, + ); } return response.json(); } @@ -810,7 +931,7 @@ export class SecureAPIClient { */ async getAllReservations() { // Use new secure endpoint - return this.request('/api/v1/reservations/all'); + return this.request("/api/v1/reservations/all"); } /** @@ -830,10 +951,12 @@ export class SecureAPIClient { const cached = await this.getCachedReservations(); const list: any[] = Array.isArray((cached as any)?.data) ? (cached as any).data - : (Array.isArray(cached) ? cached : (cached?.items || [])); - const q = (filters?.search || '').toString().toLowerCase(); - const pid = filters?.property_id || ''; - const st = (filters?.status || '').toString().toLowerCase(); + : Array.isArray(cached) + ? cached + : cached?.items || []; + const q = (filters?.search || "").toString().toLowerCase(); + const pid = filters?.property_id || ""; + const st = (filters?.status || "").toString().toLowerCase(); const df = filters?.date_from ? new Date(filters.date_from) : null; const dt = filters?.date_to ? new Date(filters.date_to) : null; @@ -842,7 +965,7 @@ export class SecureAPIClient { // property filter (UUID equality) if (pid && String(r.property_id) !== String(pid)) return false; // status filter - if (st && String(r.status || '').toLowerCase() !== st) return false; + if (st && String(r.status || "").toLowerCase() !== st) return false; // date filters if (df) { const ci = r.checkin_date ? new Date(r.checkin_date) : null; @@ -854,10 +977,11 @@ export class SecureAPIClient { } // search term in guest_name, guest_email, reservation_id if (q) { - const gn = String(r.guest_name || '').toLowerCase(); - const ge = String(r.guest_email || '').toLowerCase(); - const rid = String(r.reservation_id || r.id || '').toLowerCase(); - if (!(gn.includes(q) || ge.includes(q) || rid.includes(q))) return false; + const gn = String(r.guest_name || "").toLowerCase(); + const ge = String(r.guest_email || "").toLowerCase(); + const rid = String(r.reservation_id || r.id || "").toLowerCase(); + if (!(gn.includes(q) || ge.includes(q) || rid.includes(q))) + return false; } return true; }); @@ -870,15 +994,19 @@ export class SecureAPIClient { /** * Get reservation suggestions (single endpoint, reservations table semantics) */ - async getReservationSuggestions(search: string, limit: number = 10, opts?: { status?: string }) { + async getReservationSuggestions( + search: string, + limit: number = 10, + opts?: { status?: string }, + ) { const body = { search, limit: Math.max(1, Math.min(limit, 1000)), - ...(opts?.status ? { status: opts.status } : {}) + ...(opts?.status ? { status: opts.status } : {}), }; return this.request(`/api/v1/reservations-suggest`, { - method: 'POST', - body: JSON.stringify(body) + method: "POST", + body: JSON.stringify(body), }); } @@ -894,8 +1022,8 @@ export class SecureAPIClient { */ async getCachedReservations(forceRefresh: boolean = false) { const endpoint = forceRefresh - ? '/api/v1/reservations/all?force_refresh=true' - : '/api/v1/reservations/all'; + ? "/api/v1/reservations/all?force_refresh=true" + : "/api/v1/reservations/all"; return this.request(endpoint); } @@ -905,8 +1033,8 @@ export class SecureAPIClient { */ async getReservationsProgressive(offset: number, limit: number) { const params = new URLSearchParams(); - params.set('offset', offset.toString()); - params.set('limit', limit.toString()); + params.set("offset", offset.toString()); + params.set("limit", limit.toString()); return this.request<{ data: any[]; @@ -929,7 +1057,7 @@ export class SecureAPIClient { properties: any[]; statuses: string[]; channels: string[]; - }>('/api/v1/filter-options'); + }>("/api/v1/filter-options"); } /** @@ -938,11 +1066,11 @@ export class SecureAPIClient { async invalidateReservationCache(propertyId?: string) { const params = new URLSearchParams(); if (propertyId) { - params.set('property_id', propertyId); + params.set("property_id", propertyId); } - const query = params.toString() ? `?${params.toString()}` : ''; + const query = params.toString() ? `?${params.toString()}` : ""; return this.request(`/api/v1/cache/invalidate${query}`, { - method: 'POST' + method: "POST", }); } @@ -961,8 +1089,8 @@ export class SecureAPIClient { */ async updateReservation(id: string, updates: any) { return this.request(`/api/v1/reservations/${id}`, { - method: 'PUT', - body: JSON.stringify(updates) + method: "PUT", + body: JSON.stringify(updates), }); } @@ -970,9 +1098,9 @@ export class SecureAPIClient { * Create a new reservation */ async createReservation(reservation: any) { - return this.request('/api/v1/reservations', { - method: 'POST', - body: JSON.stringify(reservation) + return this.request("/api/v1/reservations", { + method: "POST", + body: JSON.stringify(reservation), }); } @@ -981,7 +1109,7 @@ export class SecureAPIClient { */ async deleteReservation(id: string) { return this.request(`/api/v1/reservations/${id}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -989,9 +1117,9 @@ export class SecureAPIClient { * Bulk update reservations */ async bulkUpdateReservations(ids: string[], updates: any) { - return this.request('/api/v1/reservations/bulk-update', { - method: 'POST', - body: JSON.stringify({ ids, updates }) + return this.request("/api/v1/reservations/bulk-update", { + method: "POST", + body: JSON.stringify({ ids, updates }), }); } @@ -1008,22 +1136,25 @@ export class SecureAPIClient { permissions?: Array<{ section: string; action: string }>; cities?: string[]; }) { - return this.request('/api/v1/users', { - method: 'POST', - body: JSON.stringify(payload) - }); - } - - async updateUser(userId: string, payload: { - user_metadata?: Record; - app_metadata?: Record; - email?: string; - phone?: string; - password?: string; - }) { + return this.request("/api/v1/users", { + method: "POST", + body: JSON.stringify(payload), + }); + } + + async updateUser( + userId: string, + payload: { + user_metadata?: Record; + app_metadata?: Record; + email?: string; + phone?: string; + password?: string; + }, + ) { return this.request(`/api/v1/users/${userId}`, { - method: 'PUT', - body: JSON.stringify(payload) + method: "PUT", + body: JSON.stringify(payload), }); } @@ -1041,7 +1172,7 @@ export class SecureAPIClient { page?: number; page_size?: number; }) { - console.log('[SecureAPI.getProperties] Called with filters:', filters); + console.log("[SecureAPI.getProperties] Called with filters:", filters); const params = new URLSearchParams(); if (filters) { Object.entries(filters).forEach(([key, value]) => { @@ -1049,44 +1180,63 @@ export class SecureAPIClient { }); } // Default page_size to 1000 to get all properties - if (!params.has('page_size')) { - params.append('page_size', '1000'); + if (!params.has("page_size")) { + params.append("page_size", "1000"); } // Use the standard properties endpoint that uses the properties table const endpoint = `/api/v1/properties?${params}`; - console.log('[SecureAPI.getProperties] Requesting endpoint:', endpoint); + console.log("[SecureAPI.getProperties] Requesting endpoint:", endpoint); try { const result = await this.request(endpoint); - console.log('[SecureAPI.getProperties] Request successful'); - console.log('[SecureAPI.getProperties] Result type:', typeof result); + console.log("[SecureAPI.getProperties] Request successful"); + console.log("[SecureAPI.getProperties] Result type:", typeof result); // The endpoint returns paginated results in format {items: [...], total: n} - if (result && typeof result === 'object') { - console.log('[SecureAPI.getProperties] Result keys:', Object.keys(result)); + if (result && typeof result === "object") { + console.log( + "[SecureAPI.getProperties] Result keys:", + Object.keys(result), + ); // Return the result in a consistent format that components expect // SimpleReservationForm expects {data: [...]} - if ('items' in result) { - console.log('[SecureAPI.getProperties] Found items array with', result.items?.length || 0, 'properties'); + if ("items" in result) { + console.log( + "[SecureAPI.getProperties] Found items array with", + result.items?.length || 0, + "properties", + ); return { data: result.items || [], total: result.total || 0 }; - } else if ('data' in result) { - console.log('[SecureAPI.getProperties] Found data array with', result.data?.length || 0, 'properties'); + } else if ("data" in result) { + console.log( + "[SecureAPI.getProperties] Found data array with", + result.data?.length || 0, + "properties", + ); return result; // Already in correct format } else if (Array.isArray(result)) { - console.log('[SecureAPI.getProperties] Result is array with', result.length, 'properties'); + console.log( + "[SecureAPI.getProperties] Result is array with", + result.length, + "properties", + ); return { data: result, total: result.length }; } else { - console.warn('[SecureAPI.getProperties] Unexpected result format, returning empty data'); + console.warn( + "[SecureAPI.getProperties] Unexpected result format, returning empty data", + ); return { data: [], total: 0 }; } } - console.log('[SecureAPI.getProperties] Returning empty data for null/undefined result'); + console.log( + "[SecureAPI.getProperties] Returning empty data for null/undefined result", + ); return { data: [], total: 0 }; } catch (error) { - console.error('[SecureAPI.getProperties] Request failed:', error); + console.error("[SecureAPI.getProperties] Request failed:", error); throw error; } } @@ -1103,8 +1253,8 @@ export class SecureAPIClient { */ async updateProperty(id: string, updates: any) { return this.request(`/api/v1/properties/${id}`, { - method: 'PUT', - body: JSON.stringify(updates) + method: "PUT", + body: JSON.stringify(updates), }); } @@ -1120,8 +1270,8 @@ export class SecureAPIClient { */ async createPropertyNote(propertyId: string, note: string) { return this.request(`/api/v1/properties/${propertyId}/notes`, { - method: 'POST', - body: JSON.stringify({ note }) + method: "POST", + body: JSON.stringify({ note }), }); } @@ -1130,7 +1280,7 @@ export class SecureAPIClient { */ async deletePropertyNote(noteId: string) { return this.request(`/api/v1/properties/notes/${noteId}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -1146,8 +1296,8 @@ export class SecureAPIClient { */ async createPropertyAppliance(propertyId: string, applianceData: any) { return this.request(`/api/v1/properties/${propertyId}/appliances`, { - method: 'POST', - body: JSON.stringify(applianceData) + method: "POST", + body: JSON.stringify(applianceData), }); } @@ -1159,11 +1309,18 @@ export class SecureAPIClient { /** * Update an appliance */ - async updatePropertyAppliance(propertyId: string, applianceId: string, updates: any) { - return this.request(`/api/v1/properties/${propertyId}/appliances/${applianceId}`, { - method: 'PUT', - body: JSON.stringify(updates) - }); + async updatePropertyAppliance( + propertyId: string, + applianceId: string, + updates: any, + ) { + return this.request( + `/api/v1/properties/${propertyId}/appliances/${applianceId}`, + { + method: "PUT", + body: JSON.stringify(updates), + }, + ); } // Alias for compatibility @@ -1175,13 +1332,16 @@ export class SecureAPIClient { * Delete an appliance */ async deletePropertyAppliance(propertyId: string, applianceId: string) { - return this.request(`/api/v1/properties/${propertyId}/appliances/${applianceId}`, { - method: 'DELETE', - body: JSON.stringify({ property_id: propertyId }) - }); + return this.request( + `/api/v1/properties/${propertyId}/appliances/${applianceId}`, + { + method: "DELETE", + body: JSON.stringify({ property_id: propertyId }), + }, + ); } - // Alias for compatibility + // Alias for compatibility async deleteAppliance(propertyId: string, applianceId: string) { return this.deletePropertyAppliance(propertyId, applianceId); } @@ -1198,28 +1358,38 @@ export class SecureAPIClient { */ async createPropertyContract(propertyId: string, contractData: any) { return this.request(`/api/v1/properties/${propertyId}/contracts`, { - method: 'POST', - body: JSON.stringify(contractData) + method: "POST", + body: JSON.stringify(contractData), }); } /** * Update a contract */ - async updatePropertyContract(propertyId: string, contractId: string, updates: any) { - return this.request(`/api/v1/properties/${propertyId}/contracts/${contractId}`, { - method: 'PUT', - body: JSON.stringify(updates) - }); + async updatePropertyContract( + propertyId: string, + contractId: string, + updates: any, + ) { + return this.request( + `/api/v1/properties/${propertyId}/contracts/${contractId}`, + { + method: "PUT", + body: JSON.stringify(updates), + }, + ); } /** * Delete a contract */ async deletePropertyContract(propertyId: string, contractId: string) { - return this.request(`/api/v1/properties/${propertyId}/contracts/${contractId}`, { - method: 'DELETE' - }); + return this.request( + `/api/v1/properties/${propertyId}/contracts/${contractId}`, + { + method: "DELETE", + }, + ); } /** @@ -1237,18 +1407,22 @@ export class SecureAPIClient { * Create a new property */ async createProperty(property: any) { - return this.request('/api/v1/properties', { - method: 'POST', - body: JSON.stringify(property) + return this.request("/api/v1/properties", { + method: "POST", + body: JSON.stringify(property), }); } /** * Get property availability */ - async getPropertyAvailability(propertyId: string, startDate: string, endDate: string) { + async getPropertyAvailability( + propertyId: string, + startDate: string, + endDate: string, + ) { return this.request( - `/api/v1/properties/${propertyId}/availability?start=${startDate}&end=${endDate}` + `/api/v1/properties/${propertyId}/availability?start=${startDate}&end=${endDate}`, ); } @@ -1256,17 +1430,14 @@ export class SecureAPIClient { * Availability checks (tenant-scoped) */ async getAvailabilityChecks() { - return this.request('/api/v1/availability-checks'); + return this.request("/api/v1/availability-checks"); } // ============= REPUTATION NOTES ============= /** * Get properties for reputation management (uses properties table) */ - async getReputationProperties(filters?: { - city?: string; - status?: string; - }) { + async getReputationProperties(filters?: { city?: string; status?: string }) { const params = new URLSearchParams(); if (filters) { Object.entries(filters).forEach(([key, value]) => { @@ -1278,20 +1449,24 @@ export class SecureAPIClient { async getReputationNotes(propertyIds: string[]) { const params = new URLSearchParams(); - if (propertyIds && propertyIds.length) params.append('property_ids', propertyIds.join(',')); + if (propertyIds && propertyIds.length) + params.append("property_ids", propertyIds.join(",")); return this.request(`/api/v1/reputation/notes?${params}`); } - async createReputationNote(payload: { property_id: string; content: string }) { - return this.request('/api/v1/reputation/notes', { - method: 'POST', - body: JSON.stringify(payload) + async createReputationNote(payload: { + property_id: string; + content: string; + }) { + return this.request("/api/v1/reputation/notes", { + method: "POST", + body: JSON.stringify(payload), }); } async deleteReputationNote(id: string) { return this.request(`/api/v1/reputation/notes/${id}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -1306,13 +1481,14 @@ export class SecureAPIClient { lockbox_type?: string; }) { const params = new URLSearchParams(); - if (filters?.propertyId) params.append('property_id', filters.propertyId); - if (filters?.city) params.append('city', filters.city); - if (filters?.page) params.append('page', filters.page.toString()); - if (filters?.limit) params.append('limit', filters.limit.toString()); - if (filters?.search) params.append('search', filters.search); - if (filters?.status) params.append('status', filters.status); - if (filters?.lockbox_type) params.append('lockbox_type', filters.lockbox_type); + if (filters?.propertyId) params.append("property_id", filters.propertyId); + if (filters?.city) params.append("city", filters.city); + if (filters?.page) params.append("page", filters.page.toString()); + if (filters?.limit) params.append("limit", filters.limit.toString()); + if (filters?.search) params.append("search", filters.search); + if (filters?.status) params.append("status", filters.status); + if (filters?.lockbox_type) + params.append("lockbox_type", filters.lockbox_type); return this.request(`/api/v1/lockboxes?${params}`); } @@ -1321,22 +1497,22 @@ export class SecureAPIClient { } async createLockbox(payload: any) { - return this.request('/api/v1/lockboxes', { - method: 'POST', - body: JSON.stringify(payload) + return this.request("/api/v1/lockboxes", { + method: "POST", + body: JSON.stringify(payload), }); } async updateLockbox(id: string, updates: any) { return this.request(`/api/v1/lockboxes/${id}`, { - method: 'PUT', - body: JSON.stringify(updates) + method: "PUT", + body: JSON.stringify(updates), }); } async deleteLockbox(id: string) { return this.request(`/api/v1/lockboxes/${id}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -1349,31 +1525,31 @@ export class SecureAPIClient { status?: string; }) { const params = new URLSearchParams(); - if (filters?.city) params.append('city', filters.city); - if (filters?.page) params.append('page', filters.page.toString()); - if (filters?.limit) params.append('limit', filters.limit.toString()); - if (filters?.search) params.append('search', filters.search); - if (filters?.status) params.append('status', filters.status); + if (filters?.city) params.append("city", filters.city); + if (filters?.page) params.append("page", filters.page.toString()); + if (filters?.limit) params.append("limit", filters.limit.toString()); + if (filters?.search) params.append("search", filters.search); + if (filters?.status) params.append("status", filters.status); return this.request(`/api/v1/internal-keys?${params}`); } async createInternalKey(payload: any) { - return this.request('/api/v1/internal-keys', { - method: 'POST', - body: JSON.stringify(payload) + return this.request("/api/v1/internal-keys", { + method: "POST", + body: JSON.stringify(payload), }); } async updateInternalKey(id: string, updates: any) { return this.request(`/api/v1/internal-keys/${id}`, { - method: 'PUT', - body: JSON.stringify(updates) + method: "PUT", + body: JSON.stringify(updates), }); } async deleteInternalKey(id: string) { return this.request(`/api/v1/internal-keys/${id}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -1384,206 +1560,241 @@ export class SecureAPIClient { // ============= KEYNEST ============= async getKeynestKeys(filters?: { city?: string }) { const params = new URLSearchParams(); - if (filters?.city) params.append('city', filters.city); + if (filters?.city) params.append("city", filters.city); return this.request(`/api/v1/keynest-keys?${params}`); } // ============= KEY ASSIGNMENTS ============= async getActiveKeyAssignment(lockboxId: string) { const params = new URLSearchParams(); - params.append('lockbox_id', lockboxId); + params.append("lockbox_id", lockboxId); return this.request(`/api/v1/key-assignments/active?${params}`); } async getBulkActiveKeyAssignments(lockboxIds: string[]) { return this.request(`/api/v1/key-assignments/bulk-active`, { - method: 'POST', - body: JSON.stringify(lockboxIds) + method: "POST", + body: JSON.stringify(lockboxIds), }); } // ============= ACCESS LOGS ============= - async getAccessLogs(entityId: string, entityType: string, action?: string, limit: number = 10) { + async getAccessLogs( + entityId: string, + entityType: string, + action?: string, + limit: number = 10, + ) { const params = new URLSearchParams(); - params.append('entity_id', entityId); - params.append('entity_type', entityType); - if (action) params.append('action', action); - params.append('limit', limit.toString()); + params.append("entity_id", entityId); + params.append("entity_type", entityType); + if (action) params.append("action", action); + params.append("limit", limit.toString()); return this.request(`/api/v1/access-logs?${params}`); } async getBulkLastViewed(entityIds: string[], entityType: string) { return this.request(`/api/v1/access-logs/bulk-last-viewed`, { - method: 'POST', - body: JSON.stringify({ entity_ids: entityIds, entity_type: entityType }) + method: "POST", + body: JSON.stringify({ entity_ids: entityIds, entity_type: entityType }), }); } - async logAccess(entityId: string, entityType: string, action: string = 'view') { - return this.request('/api/v1/access-logs', { - method: 'POST', - body: JSON.stringify({ entity_id: entityId, entity_type: entityType, action }) + async logAccess( + entityId: string, + entityType: string, + action: string = "view", + ) { + return this.request("/api/v1/access-logs", { + method: "POST", + body: JSON.stringify({ + entity_id: entityId, + entity_type: entityType, + action, + }), }); } // ============= COMPANY SETTINGS API ============= async getCompanySettings() { - return this.request('/api/v1/company-settings'); + return this.request("/api/v1/company-settings"); } - async updateCompanySettings(payload: Partial<{ - company_name: string; - logo_url: string | null; - domain: string | null; - header_color: string; - primary_color: string; - secondary_color: string; - accent_color: string; - favicon_url: string | null; - }>) { - return this.request('/api/v1/company-settings', { - method: 'PUT', - body: JSON.stringify(payload) + async updateCompanySettings( + payload: Partial<{ + company_name: string; + logo_url: string | null; + domain: string | null; + header_color: string; + primary_color: string; + secondary_color: string; + accent_color: string; + favicon_url: string | null; + }>, + ) { + return this.request("/api/v1/company-settings", { + method: "PUT", + body: JSON.stringify(payload), }); } // ============= DASHBOARD API ============= /** - * Get dashboard summary with optional simulation header + * Get a property's revenue summary for a local calendar month */ - 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 - }; - } + async getDashboardSummary(propertyId: string, month: number, year: number) { + const queryParams = new URLSearchParams({ + property_id: propertyId, + month: String(month), + year: String(year), + }); + return this.request(`/api/v1/dashboard/summary?${queryParams}`); + } - return this.request(`/api/v1/dashboard/summary?${queryParams}`, requestOptions); + /** + * Get properties visible to the authenticated tenant + */ + async getDashboardProperties() { + return this.request>( + "/api/v1/dashboard/properties", + ); } async uploadCompanyLogo(logo_url: string) { - return this.request('/api/v1/company-settings/logo', { - method: 'POST', - body: JSON.stringify({ logo_url }) + return this.request("/api/v1/company-settings/logo", { + method: "POST", + body: JSON.stringify({ logo_url }), }); } async deleteCompanyLogo() { - return this.request('/api/v1/company-settings/logo', { - method: 'DELETE' + return this.request("/api/v1/company-settings/logo", { + method: "DELETE", }); } // ============= PORTAL CONFIGURATION (Pre-check-in) ============= async getPortalConfiguration() { - return this.request('/api/v1/portal-configuration'); + return this.request("/api/v1/portal-configuration"); } // ============= DEPARTMENTS ============= async getDepartments() { - const res = await this.request('/api/v1/departments'); + const res = await this.request("/api/v1/departments"); // Backend returns array directly, not wrapped in an object - return Array.isArray(res) ? res : (Array.isArray(res?.departments) ? res.departments : []); + return Array.isArray(res) + ? res + : Array.isArray(res?.departments) + ? res.departments + : []; } async createDepartment(department: any) { - return await this.request('/api/v1/departments', { - method: 'POST', + return await this.request("/api/v1/departments", { + method: "POST", body: JSON.stringify(department), }); } async updateDepartment(id: string, department: any) { return await this.request(`/api/v1/departments/${id}`, { - method: 'PUT', + method: "PUT", body: JSON.stringify(department), }); } async deleteDepartment(id: string) { return await this.request(`/api/v1/departments/${id}`, { - method: 'DELETE', + method: "DELETE", }); } async getMyDepartmentsWithPreferences() { - const res = await this.request('/api/v1/departments/my-departments'); + const res = await this.request("/api/v1/departments/my-departments"); return Array.isArray(res) ? res : []; } - async updateMyDepartmentPreference(departmentId: string, showInSidebar: boolean) { - return await this.request(`/api/v1/departments/my-departments/${departmentId}/preference`, { - method: 'PUT', - body: JSON.stringify({ show_in_sidebar: showInSidebar }), - }); + async updateMyDepartmentPreference( + departmentId: string, + showInSidebar: boolean, + ) { + return await this.request( + `/api/v1/departments/my-departments/${departmentId}/preference`, + { + method: "PUT", + body: JSON.stringify({ show_in_sidebar: showInSidebar }), + }, + ); } // ============= PROCESS DOCUMENTS ============= - async getProcessDocuments(departmentId: string, status: string = 'active') { - const res = await this.request(`/api/v1/departments/${departmentId}/process-documents?status=${status}`); + async getProcessDocuments(departmentId: string, status: string = "active") { + const res = await this.request( + `/api/v1/departments/${departmentId}/process-documents?status=${status}`, + ); return Array.isArray(res) ? res : []; } - async createProcessDocument(data: { title: string; content: any; department_id: string }) { + async createProcessDocument(data: { + title: string; + content: any; + department_id: string; + }) { return this.request(`/api/v1/process-documents`, { - method: 'POST', + method: "POST", headers: { ...(await this.getAuthHeaders()), - 'Content-Type': 'application/json' + "Content-Type": "application/json", }, - body: JSON.stringify(data) + body: JSON.stringify(data), }); } - async updateProcessDocument(id: string, data: { title?: string; content?: any; status?: string }) { + async updateProcessDocument( + id: string, + data: { title?: string; content?: any; status?: string }, + ) { // Ensure at least one key present const payload: any = {}; if (data.title !== undefined) payload.title = data.title; if (data.content !== undefined) payload.content = data.content; if (data.status !== undefined) payload.status = data.status; return this.request(`/api/v1/process-documents/${id}`, { - method: 'PUT', + method: "PUT", headers: { ...(await this.getAuthHeaders()), - 'Content-Type': 'application/json' + "Content-Type": "application/json", }, - body: JSON.stringify(payload) + body: JSON.stringify(payload), }); } async archiveProcessDocument(id: string) { - return this.updateProcessDocument(id, { status: 'archived' }); + return this.updateProcessDocument(id, { status: "archived" }); } async restoreProcessDocument(id: string) { - return this.updateProcessDocument(id, { status: 'active' }); + return this.updateProcessDocument(id, { status: "active" }); } async listProcessDocumentAttachments(documentId: string) { return this.request(`/api/v1/process-documents/${documentId}/attachments`, { - method: 'GET', - headers: await this.getAuthHeaders() + method: "GET", + headers: await this.getAuthHeaders(), }); } - async getProcessDocument(documentId: string) { return this.request(`/api/v1/process-documents/${documentId}`); } async deleteProcessDocument(documentId: string) { return this.request(`/api/v1/process-documents/${documentId}`, { - method: 'DELETE', + method: "DELETE", }); } @@ -1591,98 +1802,131 @@ export class SecureAPIClient { async uploadProcessDocumentAttachment(documentId: string, file: File) { const headers: any = await this.getAuthHeaders(); - delete headers['Content-Type']; + delete headers["Content-Type"]; const form = new FormData(); - form.append('file', file); - form.append('size', file.size.toString()); - const res = await fetch(`${this.backendUrl}/api/v1/process-documents/${documentId}/attachments`, { - method: 'POST', - headers, - body: form - }); + form.append("file", file); + form.append("size", file.size.toString()); + const res = await fetch( + `${this.backendUrl}/api/v1/process-documents/${documentId}/attachments`, + { + method: "POST", + headers, + body: form, + }, + ); if (!res.ok) { - const txt = await res.text().catch(() => ''); + const txt = await res.text().catch(() => ""); throw new Error(`Upload failed (${res.status}) ${txt}`); } return res.json(); } - async uploadInlineAttachment(documentId: string, file: File, nodeId: string, context?: any) { + async uploadInlineAttachment( + documentId: string, + file: File, + nodeId: string, + context?: any, + ) { const headers: any = await this.getAuthHeaders(); - delete headers['Content-Type']; // Let the browser set the multipart header + delete headers["Content-Type"]; // Let the browser set the multipart header const form = new FormData(); - form.append('file', file); - form.append('size', file.size.toString()); - form.append('node_id', nodeId); + form.append("file", file); + form.append("size", file.size.toString()); + form.append("node_id", nodeId); if (context) { - form.append('context_data', JSON.stringify(context)); + form.append("context_data", JSON.stringify(context)); } - const res = await fetch(`${this.backendUrl}/api/v1/process-documents/${documentId}/attachments/inline`, { - method: 'POST', - headers, - body: form - }); + const res = await fetch( + `${this.backendUrl}/api/v1/process-documents/${documentId}/attachments/inline`, + { + method: "POST", + headers, + body: form, + }, + ); if (!res.ok) { - const txt = await res.text().catch(() => ''); + const txt = await res.text().catch(() => ""); throw new Error(`Upload failed (${res.status}) ${txt}`); } return res.json(); } async deleteProcessDocumentAttachment(attachmentId: string) { - return this.request(`/api/v1/process-documents/attachments/${attachmentId}`, { - method: 'DELETE', - headers: await this.getAuthHeaders() - }); + return this.request( + `/api/v1/process-documents/attachments/${attachmentId}`, + { + method: "DELETE", + headers: await this.getAuthHeaders(), + }, + ); } async getAttachmentSignedUrl(attachmentId: string, expiresIn: number = 3600) { - return this.request<{ signed_url: string; expires_in: number; file_name: string; mime_type: string }>( + return this.request<{ + signed_url: string; + expires_in: number; + file_name: string; + mime_type: string; + }>( `/api/v1/process-documents/attachments/${attachmentId}/signed-url?expires_in=${expiresIn}`, { - method: 'GET', - headers: await this.getAuthHeaders() - } + method: "GET", + headers: await this.getAuthHeaders(), + }, ); } // ============= DEPARTMENT DOCUMENTS ============= - async getDepartmentDocuments(departmentId: string, status: string = 'active') { - const res = await this.request(`/api/v1/departments/${departmentId}/documents?status=${status}`); + async getDepartmentDocuments( + departmentId: string, + status: string = "active", + ) { + const res = await this.request( + `/api/v1/departments/${departmentId}/documents?status=${status}`, + ); return Array.isArray(res) ? res : []; } // ============= PERMISSION TEMPLATES ============= - async getPermissionTemplates(params?: { department_id?: string; is_active?: boolean }) { + async getPermissionTemplates(params?: { + department_id?: string; + is_active?: boolean; + }) { const queryParams = new URLSearchParams(); - if (params?.department_id) queryParams.append('department_id', params.department_id); - if (params?.is_active !== undefined) queryParams.append('is_active', String(params.is_active)); + if (params?.department_id) + queryParams.append("department_id", params.department_id); + if (params?.is_active !== undefined) + queryParams.append("is_active", String(params.is_active)); - const res = await this.request(`/api/v1/permission_templates?${queryParams}`); - return Array.isArray(res?.permission_templates) ? res.permission_templates : []; + const res = await this.request( + `/api/v1/permission_templates?${queryParams}`, + ); + return Array.isArray(res?.permission_templates) + ? res.permission_templates + : []; } async createPermissionTemplate(template: any) { - return await this.request('/api/v1/permission_templates', { - method: 'POST', + return await this.request("/api/v1/permission_templates", { + method: "POST", body: JSON.stringify(template), }); } async updatePermissionTemplate(id: string, template: any) { return await this.request(`/api/v1/permission_templates/${id}`, { - method: 'PUT', + method: "PUT", body: JSON.stringify(template), }); } async deletePermissionTemplate(id: string) { return await this.request(`/api/v1/permission_templates/${id}`, { - method: 'DELETE', + method: "DELETE", }); } @@ -1694,14 +1938,14 @@ export class SecureAPIClient { section?: string; entity_type?: string; action?: string; - user_type?: 'system' | 'users'; + user_type?: "system" | "users"; sortAscending?: boolean; page?: number; page_size?: number; }) { const qs = new URLSearchParams(); Object.entries(params || {}).forEach(([k, v]) => { - if (v !== undefined && v !== null && v !== '') qs.append(k, String(v)); + if (v !== undefined && v !== null && v !== "") qs.append(k, String(v)); }); return this.request(`/api/v1/logs?${qs.toString()}`); } @@ -1712,12 +1956,12 @@ export class SecureAPIClient { section?: string; entity_type?: string; action?: string; - user_type?: 'system' | 'users'; + user_type?: "system" | "users"; sortAscending?: boolean; }) { const qs = new URLSearchParams(); Object.entries(params || {}).forEach(([k, v]) => { - if (v !== undefined && v !== null && v !== '') qs.append(k, String(v)); + if (v !== undefined && v !== null && v !== "") qs.append(k, String(v)); }); return this.request(`/api/v1/logs/export?${qs.toString()}`); } @@ -1731,28 +1975,30 @@ export class SecureAPIClient { // Delegate to getProperties which already normalizes response and is tenant-scoped const res = await this.getProperties({ page_size: 1000 }); // Ensure consistent format { data, total } - if (res && typeof res === 'object' && 'data' in res) return res as any; + if (res && typeof res === "object" && "data" in res) return res as any; if (Array.isArray(res)) return { data: res, total: res.length }; return { data: [], total: 0 }; } async createLog(payload: any) { - return this.request('/api/v1/logs', { - method: 'POST', - body: JSON.stringify(payload) + return this.request("/api/v1/logs", { + method: "POST", + body: JSON.stringify(payload), }); } - async updatePortalConfiguration(payload: Partial<{ - portal_base_url: string; - enable_auto_creation: boolean; - portal_expiry_days: number; - default_locale: string; - id_verification_auto_approval: boolean; - }>) { - return this.request('/api/v1/portal-configuration', { - method: 'PUT', - body: JSON.stringify(payload) + async updatePortalConfiguration( + payload: Partial<{ + portal_base_url: string; + enable_auto_creation: boolean; + portal_expiry_days: number; + default_locale: string; + id_verification_auto_approval: boolean; + }>, + ) { + return this.request("/api/v1/portal-configuration", { + method: "PUT", + body: JSON.stringify(payload), }); } @@ -1763,16 +2009,16 @@ export class SecureAPIClient { */ async getCustomFields() { // Note: custom_fields table doesn't have entity_type - all fields are for the tenant - return this.request('/api/v1/custom-fields'); + return this.request("/api/v1/custom-fields"); } /** * Create a new custom field */ async createCustomField(fieldData: any) { - return this.request('/api/v1/custom-fields', { - method: 'POST', - body: JSON.stringify(fieldData) + return this.request("/api/v1/custom-fields", { + method: "POST", + body: JSON.stringify(fieldData), }); } @@ -1781,8 +2027,8 @@ export class SecureAPIClient { */ async updateCustomField(fieldId: string, fieldData: any) { return this.request(`/api/v1/custom-fields/${fieldId}`, { - method: 'PUT', - body: JSON.stringify(fieldData) + method: "PUT", + body: JSON.stringify(fieldData), }); } @@ -1791,15 +2037,20 @@ export class SecureAPIClient { */ async getCustomFieldValues(reservationId: string) { try { - return await this.request(`/api/v1/custom-fields/values/${reservationId}`); + return await this.request( + `/api/v1/custom-fields/values/${reservationId}`, + ); } catch (error: any) { // Don't log errors for missing reservations (404) - this is expected // when reservations haven't been synced to consolidated table yet - if (error?.message?.includes('404') || error?.message?.includes('not found')) { + if ( + error?.message?.includes("404") || + error?.message?.includes("not found") + ) { return {}; } // For other errors, still log them but return empty object - console.debug('Custom field values fetch error:', error?.message); + console.debug("Custom field values fetch error:", error?.message); return {}; } } @@ -1807,20 +2058,31 @@ export class SecureAPIClient { /** * Update custom field values */ - async updateCustomFieldValues(reservationId: string, values: Record) { + async updateCustomFieldValues( + reservationId: string, + values: Record, + ) { return this.request(`/api/v1/custom-fields/values/${reservationId}`, { - method: 'PUT', - body: JSON.stringify(values) + method: "PUT", + body: JSON.stringify(values), }); } /** * Bulk update custom field values */ - async bulkUpdateCustomFieldValues(reservationIds: string[], fieldId: string, value: any) { - return this.request('/api/v1/custom-fields/bulk-update', { - method: 'POST', - body: JSON.stringify({ reservation_ids: reservationIds, field_id: fieldId, value }) + async bulkUpdateCustomFieldValues( + reservationIds: string[], + fieldId: string, + value: any, + ) { + return this.request("/api/v1/custom-fields/bulk-update", { + method: "POST", + body: JSON.stringify({ + reservation_ids: reservationIds, + field_id: fieldId, + value, + }), }); } @@ -1848,7 +2110,7 @@ export class SecureAPIClient { */ async getRevenueReport(startDate: string, endDate: string) { return this.request( - `/api/v1/finance/revenue?start=${startDate}&end=${endDate}` + `/api/v1/finance/revenue?start=${startDate}&end=${endDate}`, ); } @@ -1877,7 +2139,7 @@ export class SecureAPIClient { user_metadata?: Record | null; app_metadata?: Record | null; }> { - const url = refresh ? '/api/v1/auth/me?refresh=true' : '/api/v1/auth/me'; + const url = refresh ? "/api/v1/auth/me?refresh=true" : "/api/v1/auth/me"; return this.request(url); } @@ -1892,14 +2154,17 @@ export class SecureAPIClient { notification_preferences: any[]; unread_count: number; }> { - return this.request('/api/v1/profile'); + return this.request("/api/v1/profile"); } /** * Get brief user info for current tenant; optional filter by IDs */ - async getUsersBrief(ids?: string[]): Promise> { - const qs = ids && ids.length ? `?ids=${encodeURIComponent(ids.join(','))}` : ''; + async getUsersBrief( + ids?: string[], + ): Promise> { + const qs = + ids && ids.length ? `?ids=${encodeURIComponent(ids.join(","))}` : ""; const res = await this.request(`/api/v1/users/brief${qs}`); return Array.isArray(res?.users) ? res.users : []; } @@ -1908,7 +2173,9 @@ export class SecureAPIClient { * Get recent access logs for current user */ async getRecentUserLogs(limit: number = 10): Promise { - const res = await this.request(`/api/v1/logs/recent?limit=${limit}&user_only=true`); + const res = await this.request( + `/api/v1/logs/recent?limit=${limit}&user_only=true`, + ); return Array.isArray(res?.data) ? res.data : []; } @@ -1916,10 +2183,9 @@ export class SecureAPIClient { * Get consolidated dashboard data */ async getDashboardData(): Promise { - return this.request('/api/v1/dashboard/data'); + return this.request("/api/v1/dashboard/data"); } - /** * Clear security violations log */ @@ -1935,7 +2201,7 @@ export class SecureAPIClient { totalRequests: this.requestCount, securityViolations: this.securityViolations.length, backendUrl: this.backendUrl, - hasCachedToken: !!this.cachedToken + hasCachedToken: !!this.cachedToken, }; } @@ -1948,17 +2214,17 @@ export class SecureAPIClient { message: string; }> { try { - const data = await this.request('/api/v1/auth/verify-tenant'); + const data = await this.request("/api/v1/auth/verify-tenant"); return { isolated: true, tenantId: data.tenant_id, - message: 'Tenant isolation verified successfully' + message: "Tenant isolation verified successfully", }; } catch (error) { return { isolated: false, - tenantId: 'unknown', - message: 'Tenant isolation verification failed' + tenantId: "unknown", + message: "Tenant isolation verification failed", }; } } @@ -1970,11 +2236,11 @@ export class SecureAPIClient { */ async checkConnection(): Promise { try { - await this.requestPublic('/api/v1/health'); + await this.requestPublic("/api/v1/health"); return true; } catch { try { - await this.requestPublic('/health'); + await this.requestPublic("/health"); return true; } catch { return false; @@ -1990,21 +2256,21 @@ export class SecureAPIClient { try { // First check if we have a cached token if (!this.cachedToken) { - console.log('[SecureAPI] No cached token, auth not ready'); + console.log("[SecureAPI] No cached token, auth not ready"); return false; } // Try to make an authenticated request to verify the token works try { await this.getAuthMe(); - console.log('[SecureAPI] Auth verified successfully'); + console.log("[SecureAPI] Auth verified successfully"); return true; } catch (err) { - console.log('[SecureAPI] Auth token invalid or expired:', err); + console.log("[SecureAPI] Auth token invalid or expired:", err); return false; } } catch (e) { - console.log('[SecureAPI] Auth check failed:', e); + console.log("[SecureAPI] Auth check failed:", e); return false; } } @@ -2036,7 +2302,9 @@ export class SecureAPIClient { */ async checkPropertyExists(hostawayId: string): Promise { try { - const response = await this.request(`/api/v1/properties/check-exists?hostaway_id=${hostawayId}`); + const response = await this.request( + `/api/v1/properties/check-exists?hostaway_id=${hostawayId}`, + ); return response.exists; } catch { return false; @@ -2047,27 +2315,27 @@ export class SecureAPIClient { * Property draft management */ async createPropertyDraft(draft: any) { - return this.request('/api/v1/property-drafts', { - method: 'POST', - body: JSON.stringify(draft) + return this.request("/api/v1/property-drafts", { + method: "POST", + body: JSON.stringify(draft), }); } async getPropertyDraft(id: string, userId?: string) { - const params = userId ? `?user_id=${userId}` : ''; + const params = userId ? `?user_id=${userId}` : ""; return this.request(`/api/v1/property-drafts/${id}${params}`); } async updatePropertyDraft(id: string, updates: any) { return this.request(`/api/v1/property-drafts/${id}`, { - method: 'PUT', - body: JSON.stringify(updates) + method: "PUT", + body: JSON.stringify(updates), }); } async deletePropertyDraft(id: string) { return this.request(`/api/v1/property-drafts/${id}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -2075,7 +2343,7 @@ export class SecureAPIClient { * Organization modules management */ async getMyOrgModules(): Promise { - const res = await this.request('/api/v1/organizations/my-modules'); + const res = await this.request("/api/v1/organizations/my-modules"); if (Array.isArray(res?.modules)) return res.modules as string[]; return []; } @@ -2085,15 +2353,19 @@ export class SecureAPIClient { */ async getCleaningReports(filters: any, signal?: AbortSignal) { const params = new URLSearchParams(); - if (filters.city) params.append('city', String(filters.city)); - if (filters.date_from) params.append('date_from', String(filters.date_from)); - if (filters.date_to) params.append('date_to', String(filters.date_to)); - if (filters.status) params.append('status', String(filters.status)); - if (filters.booking_status) params.append('booking_status', String(filters.booking_status)); - if (filters.property_id) params.append('property_id', String(filters.property_id)); - if (filters.search) params.append('search', String(filters.search)); - if (filters.page) params.append('page', String(filters.page)); - if (filters.itemsPerPage) params.append('itemsPerPage', String(filters.itemsPerPage)); + if (filters.city) params.append("city", String(filters.city)); + if (filters.date_from) + params.append("date_from", String(filters.date_from)); + if (filters.date_to) params.append("date_to", String(filters.date_to)); + if (filters.status) params.append("status", String(filters.status)); + if (filters.booking_status) + params.append("booking_status", String(filters.booking_status)); + if (filters.property_id) + params.append("property_id", String(filters.property_id)); + if (filters.search) params.append("search", String(filters.search)); + if (filters.page) params.append("page", String(filters.page)); + if (filters.itemsPerPage) + params.append("itemsPerPage", String(filters.itemsPerPage)); // Use the secure endpoint that validates user city access const endpoint = `/api/v1/secure/cleaning/reports?${params}`; @@ -2102,43 +2374,63 @@ export class SecureAPIClient { const result = await this.request(endpoint, { signal }); // Additional validation for cleaning results - if (result && typeof result === 'object') { + if (result && typeof result === "object") { const items = result.items || result.data || []; const total = result.total || 0; // Log potential issues for monitoring - if (total === 0 && filters.date_from === 'overdue') { - console.warn('[SecureAPI] Received empty overdue cleaning data - this may indicate a backend issue'); + if (total === 0 && filters.date_from === "overdue") { + console.warn( + "[SecureAPI] Received empty overdue cleaning data - this may indicate a backend issue", + ); } // Specific validation for tomorrow's data - const isTomorrowRequest = filters.date_from && filters.date_to && filters.date_from === filters.date_to && - filters.date_from === new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const isTomorrowRequest = + filters.date_from && + filters.date_to && + filters.date_from === filters.date_to && + filters.date_from === + new Date(Date.now() + 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0]; if (isTomorrowRequest) { - console.log('[SecureAPI] Tomorrow cleaning request completed', { - endpoint: endpoint.substring(0, 80) + '...', + console.log("[SecureAPI] Tomorrow cleaning request completed", { + endpoint: endpoint.substring(0, 80) + "...", total, itemsLength: items.length, dateRequested: filters.date_from, city: filters.city, - cacheKey: await this.generateCacheKey('GET', endpoint, await this.getTenantId() || 'unknown') + cacheKey: await this.generateCacheKey( + "GET", + endpoint, + (await this.getTenantId()) || "unknown", + ), }); if (total === 0) { - console.warn('[SecureAPI] Tomorrow returned 0 cleanings - verify this is correct', { - filters, - cacheAge: 'fresh_request' - }); + console.warn( + "[SecureAPI] Tomorrow returned 0 cleanings - verify this is correct", + { + filters, + cacheAge: "fresh_request", + }, + ); } } - console.log(`[SecureAPI] Cleaning request completed: ${endpoint.substring(0, 80)}... -> ${total} items`); + console.log( + `[SecureAPI] Cleaning request completed: ${endpoint.substring(0, 80)}... -> ${total} items`, + ); } return result; } catch (error) { - console.error(`[SecureAPI] Cleaning request failed: ${endpoint.substring(0, 80)}...`, error); + console.error( + `[SecureAPI] Cleaning request failed: ${endpoint.substring(0, 80)}...`, + error, + ); throw error; } } @@ -2146,23 +2438,23 @@ export class SecureAPIClient { async createCleaningReport(payload: any) { // Use secure endpoint that validates user city access return this.request(`/api/v1/secure/cleaning/reports`, { - method: 'POST', - body: JSON.stringify(payload) + method: "POST", + body: JSON.stringify(payload), }); } async updateCleaningReport(id: string, updates: any) { // Use secure endpoint that validates user city access return this.request(`/api/v1/secure/cleaning/reports/${id}`, { - method: 'PUT', - body: JSON.stringify(updates) + method: "PUT", + body: JSON.stringify(updates), }); } async deleteCleaningReport(id: string) { // Use secure endpoint that validates user city access return this.request(`/api/v1/secure/cleaning/reports/${id}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -2173,23 +2465,23 @@ export class SecureAPIClient { async addCleaningNote(note: { cleaning_id: string; content: string }) { // Use secure endpoint that validates user city access - return this.request('/api/v1/secure/cleaning/notes', { - method: 'POST', - body: JSON.stringify(note) + return this.request("/api/v1/secure/cleaning/notes", { + method: "POST", + body: JSON.stringify(note), }); } async createCleaningNote(note: any) { // Use secure endpoint that validates user city access return this.request(`/api/v1/secure/cleaning/notes`, { - method: 'POST', - body: JSON.stringify(note) + method: "POST", + body: JSON.stringify(note), }); } async deleteCleaningsByParentId(parentId: string) { return this.request(`/api/v1/cleaning-reports/by-parent/${parentId}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -2205,16 +2497,19 @@ export class SecureAPIClient { } async createAnnouncement(announcement: any) { - return this.request('/api/v1/announcements', { - method: 'POST', - body: JSON.stringify(announcement) + return this.request("/api/v1/announcements", { + method: "POST", + body: JSON.stringify(announcement), }); } async acknowledgeAnnouncement(announcementId: string, userId: string) { - return this.request('/api/v1/announcements/acknowledge', { - method: 'POST', - body: JSON.stringify({ announcement_id: announcementId, user_id: userId }) + return this.request("/api/v1/announcements/acknowledge", { + method: "POST", + body: JSON.stringify({ + announcement_id: announcementId, + user_id: userId, + }), }); } @@ -2225,7 +2520,7 @@ export class SecureAPIClient { */ async getCleaners() { // Use secure endpoint with tenant isolation - return this.request('/api/v1/secure/cleaning/cleaners'); + return this.request("/api/v1/secure/cleaning/cleaners"); } /** @@ -2233,9 +2528,9 @@ export class SecureAPIClient { */ async createCleaner(data: { name: string }) { // Use secure endpoint with tenant isolation - return this.request('/api/v1/secure/cleaning/cleaners', { - method: 'POST', - body: JSON.stringify(data) + return this.request("/api/v1/secure/cleaning/cleaners", { + method: "POST", + body: JSON.stringify(data), }); } @@ -2245,7 +2540,7 @@ export class SecureAPIClient { async deleteCleaner(id: string) { // Use secure endpoint with tenant isolation return this.request(`/api/v1/secure/cleaning/cleaners/${id}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -2262,9 +2557,9 @@ export class SecureAPIClient { * Create a contract record */ async createContractRecord(data: any) { - return this.request('/api/v1/contract-records', { - method: 'POST', - body: JSON.stringify(data) + return this.request("/api/v1/contract-records", { + method: "POST", + body: JSON.stringify(data), }); } @@ -2273,8 +2568,8 @@ export class SecureAPIClient { */ async updateContractRecord(id: string, data: any) { return this.request(`/api/v1/contract-records/${id}`, { - method: 'PUT', - body: JSON.stringify(data) + method: "PUT", + body: JSON.stringify(data), }); } @@ -2283,7 +2578,7 @@ export class SecureAPIClient { */ async deleteContractRecord(id: string) { return this.request(`/api/v1/contract-records/${id}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -2293,7 +2588,7 @@ export class SecureAPIClient { * Get all reservation subsections/smart views */ async getAllReservationSubsections(params?: URLSearchParams) { - const query = params ? `?${params.toString()}` : ''; + const query = params ? `?${params.toString()}` : ""; return this.request(`/api/v1/smart-views${query}`); } @@ -2308,9 +2603,9 @@ export class SecureAPIClient { * Create a reservation subsection/smart view */ async createReservationSubsection(data: any) { - return this.request('/api/v1/smart-views', { - method: 'POST', - body: JSON.stringify(data) + return this.request("/api/v1/smart-views", { + method: "POST", + body: JSON.stringify(data), }); } @@ -2319,8 +2614,8 @@ export class SecureAPIClient { */ async updateReservationSubsection(id: string, data: any) { return this.request(`/api/v1/smart-views/${id}`, { - method: 'PUT', - body: JSON.stringify(data) + method: "PUT", + body: JSON.stringify(data), }); } @@ -2329,7 +2624,7 @@ export class SecureAPIClient { */ async deleteReservationSubsection(id: string) { return this.request(`/api/v1/smart-views/${id}`, { - method: 'DELETE' + method: "DELETE", }); } @@ -2338,8 +2633,8 @@ export class SecureAPIClient { */ async duplicateReservationSubsection(id: string, data: any) { return this.request(`/api/v1/smart-views/${id}/duplicate`, { - method: 'POST', - body: JSON.stringify(data) + method: "POST", + body: JSON.stringify(data), }); } @@ -2348,13 +2643,13 @@ export class SecureAPIClient { /** * Get ID verifications based on status filter */ - async getVerifications(statusFilter: string = 'pending') { - let endpoint = '/api/v1/guest-portal/verification-status'; + async getVerifications(statusFilter: string = "pending") { + let endpoint = "/api/v1/guest-portal/verification-status"; - if (statusFilter === 'pending') { - endpoint += '/pending'; - } else if (statusFilter === 'all') { - endpoint += '/all'; + if (statusFilter === "pending") { + endpoint += "/pending"; + } else if (statusFilter === "all") { + endpoint += "/all"; } else if (statusFilter) { endpoint += `/${statusFilter}`; } @@ -2366,10 +2661,13 @@ export class SecureAPIClient { * Review an ID verification */ async reviewVerification(verificationId: string, reviewData: any) { - return this.request(`/api/v1/guest-portal/review-verification/${verificationId}`, { - method: 'POST', - body: JSON.stringify(reviewData) - }); + return this.request( + `/api/v1/guest-portal/review-verification/${verificationId}`, + { + method: "POST", + body: JSON.stringify(reviewData), + }, + ); } // ============= TRANSLATION API ============= @@ -2377,14 +2675,22 @@ export class SecureAPIClient { /** * Trigger AI translation for an entity */ - async translateEntity(entityType: string, entityId: string, fields: string[], languages: string[]) { - return this.request(`/api/v1/translations/${entityType}/${entityId}/translate`, { - method: 'POST', - body: JSON.stringify({ - fields: fields, - languages: languages - }) - }); + async translateEntity( + entityType: string, + entityId: string, + fields: string[], + languages: string[], + ) { + return this.request( + `/api/v1/translations/${entityType}/${entityId}/translate`, + { + method: "POST", + body: JSON.stringify({ + fields: fields, + languages: languages, + }), + }, + ); } // ============= FORMULAS API ============= @@ -2393,26 +2699,38 @@ export class SecureAPIClient { * Get all formulas for the current tenant */ async getFormulas() { - return this.request('/api/v1/formulas'); + return this.request("/api/v1/formulas"); } /** * Create a new formula */ - async createFormula(formula: { name: string; description?: string; composition: string }) { - return this.request('/api/v1/formulas', { - method: 'POST', - body: JSON.stringify(formula) + async createFormula(formula: { + name: string; + description?: string; + composition: string; + }) { + return this.request("/api/v1/formulas", { + method: "POST", + body: JSON.stringify(formula), }); } /** * Update an existing formula */ - async updateFormula(formulaId: string, updates: { name?: string; description?: string; composition?: string; order_index?: number }) { + async updateFormula( + formulaId: string, + updates: { + name?: string; + description?: string; + composition?: string; + order_index?: number; + }, + ) { return this.request(`/api/v1/formulas/${formulaId}`, { - method: 'PUT', - body: JSON.stringify(updates) + method: "PUT", + body: JSON.stringify(updates), }); } @@ -2421,7 +2739,7 @@ export class SecureAPIClient { */ async deleteFormula(formulaId: string) { return this.request(`/api/v1/formulas/${formulaId}`, { - method: 'DELETE' + method: "DELETE", }); } } @@ -2437,8 +2755,10 @@ export const secureReservations = { create: (data: any) => SecureAPI.createReservation(data), delete: (id: string) => SecureAPI.deleteReservation(id), getFilterOptions: () => SecureAPI.getReservationFilterOptions(), - invalidateCache: (propertyId?: string) => SecureAPI.invalidateReservationCache(propertyId), - getCached: (forceRefresh?: boolean) => SecureAPI.getCachedReservations(!!forceRefresh) + invalidateCache: (propertyId?: string) => + SecureAPI.invalidateReservationCache(propertyId), + getCached: (forceRefresh?: boolean) => + SecureAPI.getCachedReservations(!!forceRefresh), }; export const secureProperties = { @@ -2447,29 +2767,46 @@ export const secureProperties = { update: (id: string, data: any) => SecureAPI.updateProperty(id, data), create: (data: any) => SecureAPI.createProperty(data), getAvailability: (id: string, start: string, end: string) => - SecureAPI.getPropertyAvailability(id, start, end) + SecureAPI.getPropertyAvailability(id, start, end), }; export const secureCleaning = { - getReports: (filters: any, signal?: AbortSignal) => SecureAPI.getCleaningReports(filters, signal), - clearCache: () => SecureAPI.clearEndpointCache('secure/cleaning/reports'), - getDiagnostics: () => SecureAPI.getCacheDiagnostics() + getReports: (filters: any, signal?: AbortSignal) => + SecureAPI.getCleaningReports(filters, signal), + clearCache: () => SecureAPI.clearEndpointCache("secure/cleaning/reports"), + getDiagnostics: () => SecureAPI.getCacheDiagnostics(), }; export const secureFormulas = { getAll: () => SecureAPI.getFormulas(), - create: (formula: { name: string; description?: string; composition: string }) => SecureAPI.createFormula(formula), - update: (id: string, updates: { name?: string; description?: string; composition?: string; order_index?: number }) => - SecureAPI.updateFormula(id, updates), - delete: (id: string) => SecureAPI.deleteFormula(id) + create: (formula: { + name: string; + description?: string; + composition: string; + }) => SecureAPI.createFormula(formula), + update: ( + id: string, + updates: { + name?: string; + description?: string; + composition?: string; + order_index?: number; + }, + ) => SecureAPI.updateFormula(id, updates), + delete: (id: string) => SecureAPI.deleteFormula(id), }; // Prevent direct exports of supabase to force secure API usage if (import.meta.env.DEV) { - console.warn('🔒 SecureAPI initialized - Direct Supabase queries are now blocked'); + console.warn( + "🔒 SecureAPI initialized - Direct Supabase queries are now blocked", + ); // Make cache diagnostics available globally for debugging (window as any).secureApiDiagnostics = () => SecureAPI.getCacheDiagnostics(); - (window as any).clearCleaningCache = () => SecureAPI.clearEndpointCache('secure/cleaning/reports'); - console.log('🔧 Debug utilities added: secureApiDiagnostics(), clearCleaningCache()'); + (window as any).clearCleaningCache = () => + SecureAPI.clearEndpointCache("secure/cleaning/reports"); + console.log( + "🔧 Debug utilities added: secureApiDiagnostics(), clearCleaningCache()", + ); }