diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..bef9b2d4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ + +# Python bytecode and local test caches +__pycache__/ +*.py[cod] +.pytest_cache/ \ No newline at end of file diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..31b6c20c6 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from typing import Dict, Any from app.services.cache import get_revenue_summary +from app.services.reservations import property_belongs_to_tenant from app.core.auth import authenticate_request as get_current_user router = APIRouter() @@ -12,14 +13,15 @@ async def get_dashboard_summary( ) -> Dict[str, Any]: tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" + + if not await property_belongs_to_tenant(property_id, tenant_id): + raise HTTPException(status_code=404, detail="Property not found") revenue_data = await get_revenue_summary(property_id, tenant_id) - total_revenue_float = float(revenue_data['total']) - return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + "total_revenue": revenue_data['total'], "currency": revenue_data['currency'], "reservations_count": revenue_data['count'] } diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..5ba0ac0cf 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 @@ -14,12 +12,16 @@ def __init__(self): async def initialize(self): """Initialize database connection pool""" try: - # Create async engine with connection pooling - database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}" + # Compose provides DATABASE_URL using the synchronous PostgreSQL + # scheme. SQLAlchemy's async engine needs the asyncpg scheme. + database_url = settings.database_url + if database_url.startswith("postgresql://"): + database_url = database_url.replace( + "postgresql://", "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 @@ -45,7 +47,7 @@ async def close(self): 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/services/cache.py b/backend/app/services/cache.py index b81474957..ac228602e 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,7 +10,9 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" + # Property IDs are only unique within a tenant. Include both dimensions + # so one tenant can never read another tenant's cached revenue. + cache_key = f"revenue:{tenant_id}:{property_id}" # Try to get from cache cached = await redis_client.get(cache_key) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..4dfa80f26 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,47 +1,108 @@ -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal from typing import Dict, Any, List +from zoneinfo import ZoneInfo -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: + +async def property_belongs_to_tenant(property_id: str, tenant_id: str) -> bool: + """Return whether a property is owned by the requesting tenant.""" + from sqlalchemy import text + from app.core.database_pool import db_pool + + if db_pool.session_factory is None: + await db_pool.initialize() + + if db_pool.session_factory is None: + raise RuntimeError("Database pool not available") + + async with db_pool.get_session() as session: + result = await session.execute( + text(""" + SELECT 1 + FROM properties + WHERE id = :property_id AND tenant_id = :tenant_id + LIMIT 1 + """), + {"property_id": property_id, "tenant_id": tenant_id}, + ) + return result.scalar_one_or_none() is not None + +async def calculate_monthly_revenue( + property_id: str, + month: int, + year: int, + tenant_id: str = None, + db_session=None, +) -> Decimal: """ Calculates revenue for a specific month. """ - start_date = datetime(year, month, 1) - if month < 12: - end_date = datetime(year, month + 1, 1) - else: - end_date = datetime(year + 1, 1, 1) - - print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}") - - # SQL Simulation (This would be executed against the actual DB) - query = """ - SELECT SUM(total_amount) as total - FROM reservations - WHERE property_id = $1 - AND tenant_id = $2 - AND check_in_date >= $3 - AND check_in_date < $4 - """ - - # In production this query executes against a database session. - # result = await db.fetch_val(query, property_id, tenant_id, start_date, end_date) - # return result or Decimal('0') - - return Decimal('0') # Placeholder for now until DB connection is finalized + if not tenant_id: + raise ValueError("tenant_id is required for monthly revenue calculation") + + from sqlalchemy import text + from app.core.database_pool import db_pool + + if db_session is None: + if db_pool.session_factory is None: + await db_pool.initialize() + if db_pool.session_factory is None: + raise RuntimeError("Database pool not available") + + property_query = text(""" + SELECT timezone + FROM properties + WHERE id = :property_id AND tenant_id = :tenant_id + """) + + async def calculate(session) -> Decimal: + property_result = await session.execute(property_query, { + "property_id": property_id, + "tenant_id": tenant_id, + }) + property_row = property_result.fetchone() + if not property_row: + raise ValueError("Property not found") + + property_timezone = ZoneInfo(property_row.timezone) + start_date = datetime(year, month, 1, tzinfo=property_timezone) + if month < 12: + end_date = datetime(year, month + 1, 1, tzinfo=property_timezone) + else: + end_date = datetime(year + 1, 1, 1, tzinfo=property_timezone) + + result = await session.execute(text(""" + SELECT COALESCE(SUM(total_amount), 0) AS total + FROM reservations + WHERE property_id = :property_id + AND tenant_id = :tenant_id + AND check_in_date >= :start_date + AND check_in_date < :end_date + """), { + "property_id": property_id, + "tenant_id": tenant_id, + "start_date": start_date.astimezone(timezone.utc), + "end_date": end_date.astimezone(timezone.utc), + }) + return Decimal(str(result.scalar_one())) + + if db_session is not None: + return await calculate(db_session) + + async with db_pool.get_session() as session: + return await calculate(session) 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 + from app.core.database_pool import db_pool # Initialize pool if needed - db_pool = DatabasePool() - await db_pool.initialize() + if db_pool.session_factory is None: + await db_pool.initialize() if db_pool.session_factory: async with db_pool.get_session() as session: @@ -49,28 +110,33 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, from sqlalchemy import text query = text(""" - SELECT + SELECT property_id, + currency, 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 + GROUP BY property_id, currency """) result = await session.execute(query, { "property_id": property_id, "tenant_id": tenant_id }) - row = result.fetchone() + rows = result.fetchall() - if row: + if len(rows) > 1: + raise ValueError("Cannot aggregate revenue across multiple currencies") + + if rows: + row = rows[0] total_revenue = Decimal(str(row.total_revenue)) return { "property_id": property_id, "tenant_id": tenant_id, "total": str(total_revenue), - "currency": "USD", + "currency": row.currency, "count": row.reservation_count } else: @@ -86,24 +152,6 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, 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'] - } + raise RuntimeError( + f"Unable to calculate revenue for property {property_id}" + ) from e diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..96ec7a64e 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,31 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { RevenueSummary } from "./RevenueSummary"; +import { useAuth } from "../contexts/AuthContext.new"; -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' } -]; +const PROPERTIES_BY_TENANT = { + 'tenant-a': [ + { id: 'prop-001', name: 'Beach House Alpha' }, + { id: 'prop-002', name: 'City Apartment Downtown' }, + { id: 'prop-003', name: 'Country Villa Estate' } + ], + 'tenant-b': [ + { id: 'prop-001', name: 'Mountain Lodge Beta' }, + { id: 'prop-004', name: 'Lakeside Cottage' }, + { id: 'prop-005', name: 'Urban Loft Modern' } + ] +} as const; const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const { user } = useAuth(); + const tenantId = user?.tenant_id; + const properties = tenantId ? PROPERTIES_BY_TENANT[tenantId as keyof typeof PROPERTIES_BY_TENANT] || [] : []; + const [selectedProperty, setSelectedProperty] = useState(''); + + useEffect(() => { + if (properties.length > 0 && !properties.some((property) => property.id === selectedProperty)) { + setSelectedProperty(properties[0].id); + } + }, [properties, selectedProperty]); return (
@@ -35,7 +50,7 @@ const Dashboard: React.FC = () => { onChange={(e) => setSelectedProperty(e.target.value)} className="block w-full sm:w-auto min-w-[200px] px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm" > - {PROPERTIES.map((property) => ( + {properties.map((property) => ( @@ -46,7 +61,7 @@ const Dashboard: React.FC = () => {
- + {selectedProperty && }
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..e244effe8 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,7 +3,7 @@ import { SecureAPI } from '../lib/secureApi'; interface RevenueData { property_id: string; - total_revenue: number; + total_revenue: string; currency: string; reservations_count: number; } @@ -61,7 +61,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr if (error) return
{error}
; if (!data) return null; - const displayTotal = Math.round(data.total_revenue * 100) / 100; + const displayTotal = Number(data.total_revenue); return (
@@ -104,7 +104,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr {/* Precision Warning Area */}
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( + {data.total_revenue !== displayTotal.toFixed(2) && showRaw && (
diff --git a/frontend/src/contexts/AuthContext.new.tsx b/frontend/src/contexts/AuthContext.new.tsx index d19e30f18..6baf02cef 100644 --- a/frontend/src/contexts/AuthContext.new.tsx +++ b/frontend/src/contexts/AuthContext.new.tsx @@ -68,6 +68,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children source = 'user_metadata'; } + // Local challenge authentication returns tenant_id directly on the user. + // Preserve it when the token does not expose the tenant in metadata. + if (!tenant_id && enhancedUser.tenant_id) { + tenant_id = enhancedUser.tenant_id; + source = 'user'; + } + // Add tenant_id as a direct property for backward compatibility enhancedUser.tenant_id = tenant_id; @@ -343,4 +350,4 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children {children} ); -}; \ No newline at end of file +}; diff --git a/package-lock.json b/package-lock.json index 30f49b2c1..b9d7372f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "The-Flex-PMS-Staging", + "name": "New_devs_App", "lockfileVersion": 3, "requires": true, "packages": { @@ -98,6 +98,7 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2124,6 +2125,7 @@ "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2157,6 +2159,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2232,6 +2235,7 @@ "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.41.0", "@typescript-eslint/types": "8.41.0", @@ -2504,6 +2508,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2740,6 +2745,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -3207,6 +3213,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4535,6 +4542,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4979,8 +4987,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -5430,6 +5437,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5514,6 +5522,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5662,6 +5671,7 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5755,6 +5765,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5941,6 +5952,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" },