diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..4725b14de 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,87 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +import logging +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.exc import SQLAlchemyError +from typing import Dict, Any, Optional +from decimal import Decimal, ROUND_HALF_UP from app.services.cache import get_revenue_summary +from app.services.reservations import get_tenant_properties, get_reservations, property_belongs_to_tenant from app.core.auth import authenticate_request as get_current_user +logger = logging.getLogger(__name__) + router = APIRouter() + +def get_tenant_id(current_user) -> str: + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=403, detail="No tenant for this user") + return tenant_id + + +def db_unavailable(e: Exception): + logger.error(f"Dashboard query failed: {e}") + return HTTPException(status_code=503, detail="Dashboard data is unavailable, try again shortly") + + +async def check_property(property_id: str, tenant_id: str): + try: + found = await property_belongs_to_tenant(property_id, tenant_id) + except (SQLAlchemyError, OSError) as e: + raise db_unavailable(e) + if not found: + raise HTTPException(status_code=404, detail="Property not found") + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + month: Optional[int] = Query(None, ge=1, le=12), + year: Optional[int] = None, current_user: dict = Depends(get_current_user) ) -> Dict[str, Any]: - - tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" - - revenue_data = await get_revenue_summary(property_id, tenant_id) - - total_revenue_float = float(revenue_data['total']) - + + tenant_id = get_tenant_id(current_user) + await check_property(property_id, tenant_id) + + try: + revenue_data = await get_revenue_summary(property_id, tenant_id, month, year) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + except (SQLAlchemyError, OSError) as e: + raise db_unavailable(e) + + total_revenue = Decimal(revenue_data['total']).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + "total_revenue": float(total_revenue), "currency": revenue_data['currency'], "reservations_count": revenue_data['count'] } + + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: dict = Depends(get_current_user) +) -> Dict[str, Any]: + tenant_id = get_tenant_id(current_user) + try: + return {"properties": await get_tenant_properties(tenant_id)} + except (SQLAlchemyError, OSError) as e: + raise db_unavailable(e) + + +@router.get("/dashboard/reservations") +async def get_dashboard_reservations( + property_id: str, + month: Optional[int] = Query(None, ge=1, le=12), + year: Optional[int] = None, + current_user: dict = Depends(get_current_user) +) -> Dict[str, Any]: + tenant_id = get_tenant_id(current_user) + await check_property(property_id, tenant_id) + try: + return {"reservations": await get_reservations(property_id, tenant_id, month, year)} + except (SQLAlchemyError, OSError) as e: + raise db_unavailable(e) diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..eea590f43 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,6 +1,5 @@ import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging from ..config import settings @@ -15,11 +14,10 @@ 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}" + database_url = settings.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 +43,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/core/tenant_resolver.py b/backend/app/core/tenant_resolver.py index db09a4629..0f5f4dfdf 100644 --- a/backend/app/core/tenant_resolver.py +++ b/backend/app/core/tenant_resolver.py @@ -69,7 +69,7 @@ def resolve_tenant_from_user(user_data: dict) -> Optional[str]: 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: Optional[str] = None) -> Optional[str]: """ Resolve tenant ID for a user. @@ -80,6 +80,18 @@ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] Returns: Tenant ID """ + if token: + try: + from jose import jwt + from app.config import settings + + payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"], audience="authenticated") + tenant_id = TenantResolver.resolve_tenant_from_token(payload) + if tenant_id: + return tenant_id + except Exception: + pass + # Fallback mapping by known user email. if user_email == "sunset@propertyflow.com": return "tenant-a" @@ -88,8 +100,8 @@ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] if user_email == "candidate@propertyflow.com": return "tenant-a" - # Default fallback - return "tenant-a" + # Unknown user, don't guess a tenant + 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..22db4ad49 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,29 +1,43 @@ import json import redis.asyncio as redis -from typing import Dict, Any +from typing import Dict, Any, Optional import os +import logging + +logger = logging.getLogger(__name__) # 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: Optional[int] = None, year: Optional[int] = None) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" - - # Try to get from cache - cached = await redis_client.get(cache_key) - if cached: - return json.loads(cached) - + # property ids are not unique across tenants + period = f"{year}-{month:02d}" if month and year else "all" + cache_key = f"revenue:{tenant_id}:{property_id}:{period}" + + # Try to get from cache, if redis is down just go to the db + try: + cached = await redis_client.get(cache_key) + if cached: + return json.loads(cached) + except Exception as e: + logger.warning(f"Redis read failed for {cache_key}: {e}") + # Revenue calculation is delegated to the reservation service. - from app.services.reservations import calculate_total_revenue - + from app.services.reservations import calculate_total_revenue, calculate_monthly_revenue + # Calculate revenue - result = await calculate_total_revenue(property_id, tenant_id) - + if month and year: + result = await calculate_monthly_revenue(property_id, tenant_id, month, year) + else: + result = await calculate_total_revenue(property_id, tenant_id) + # Cache the result for 5 minutes - await redis_client.setex(cache_key, 300, json.dumps(result)) - + try: + await redis_client.setex(cache_key, 300, json.dumps(result)) + except Exception as e: + logger.warning(f"Redis write failed for {cache_key}: {e}") + return result diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..6b25fd0a8 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,147 @@ from datetime import datetime from decimal import Decimal -from typing import Dict, Any, List +from typing import Dict, Any -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: +from sqlalchemy import text + +from app.core.database_pool import db_pool + + +async def _get_session(): + if db_pool.session_factory is None: + await db_pool.initialize() + if db_pool.session_factory is None: + raise ConnectionError("Database pool not available") + return db_pool.get_session() + + +async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Dict[str, Any]: """ - Calculates revenue for a specific month. + Calculates revenue for a specific month, in the property's timezone. """ - 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 + query = text(""" + SELECT + r.currency, + SUM(r.total_amount) as total_revenue, + COUNT(r.id) as reservation_count + FROM reservations r + JOIN properties p ON p.id = r.property_id AND p.tenant_id = r.tenant_id + WHERE r.property_id = :property_id + AND r.tenant_id = :tenant_id + AND (r.check_in_date AT TIME ZONE p.timezone) >= :start_date + AND (r.check_in_date AT TIME ZONE p.timezone) < :end_date + GROUP BY r.currency + """) + + async with await _get_session() as session: + result = await session.execute(query, { + "property_id": property_id, + "tenant_id": tenant_id, + "start_date": start_date, + "end_date": end_date, + }) + rows = result.fetchall() + + return _summary(property_id, tenant_id, rows) + 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 { + query = text(""" + SELECT + 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 currency + """) + + async with await _get_session() as session: + result = await session.execute(query, { "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] - } + "tenant_id": tenant_id + }) + rows = result.fetchall() + + return _summary(property_id, tenant_id, rows) + + +def _summary(property_id: str, tenant_id: str, rows) -> Dict[str, Any]: + if len(rows) > 1: + raise ValueError("Reservations for this property are in more than one currency") + + row = rows[0] if rows else None + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": str(Decimal(str(row.total_revenue))) if row else "0.00", + "currency": row.currency if row else "USD", + "count": row.reservation_count if row else 0 + } + + +async def property_belongs_to_tenant(property_id: str, tenant_id: str) -> bool: + query = text("SELECT 1 FROM properties WHERE id = :property_id AND tenant_id = :tenant_id") + + async with await _get_session() as session: + result = await session.execute(query, {"property_id": property_id, "tenant_id": tenant_id}) + return result.first() is not None + + +async def get_tenant_properties(tenant_id: str) -> list: + query = text(""" + SELECT id, name, timezone + FROM properties + WHERE tenant_id = :tenant_id + ORDER BY id + """) + + async with await _get_session() as session: + result = await session.execute(query, {"tenant_id": tenant_id}) + return [{"id": r.id, "name": r.name, "timezone": r.timezone} for r in result.fetchall()] + + +async def get_reservations(property_id: str, tenant_id: str, month: int = None, year: int = None) -> list: + params = {"property_id": property_id, "tenant_id": tenant_id} + month_filter = "" + if month and year: + params["start_date"] = datetime(year, month, 1) + params["end_date"] = datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1) + month_filter = """ + AND (r.check_in_date AT TIME ZONE p.timezone) >= :start_date + AND (r.check_in_date AT TIME ZONE p.timezone) < :end_date""" + + query = text(f""" + SELECT + r.id, + to_char(r.check_in_date AT TIME ZONE p.timezone, 'YYYY-MM-DD HH24:MI') as check_in, + to_char(r.check_out_date AT TIME ZONE p.timezone, 'YYYY-MM-DD HH24:MI') as check_out, + r.total_amount, + r.currency + FROM reservations r + JOIN properties p ON p.id = r.property_id AND p.tenant_id = r.tenant_id + WHERE r.property_id = :property_id AND r.tenant_id = :tenant_id{month_filter} + ORDER BY r.check_in_date + """) + + async with await _get_session() as session: + result = await session.execute(query, params) + return [ + { + "id": r.id, + "check_in": r.check_in, + "check_out": r.check_out, + "amount": str(r.total_amount), + "currency": r.currency, + } + for r in result.fetchall() + ] diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b777d2aa..06cb8bd4a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,7 +18,7 @@ pytz redis>=5.0.0 requests sendgrid -sqlalchemy>=2.0.0 +sqlalchemy[asyncio]>=2.0.0 supabase tenacity>=8.0.0 uvicorn[standard] diff --git a/frontend/src/components/BookingsList.tsx b/frontend/src/components/BookingsList.tsx new file mode 100644 index 000000000..ed30f2840 --- /dev/null +++ b/frontend/src/components/BookingsList.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useState } from 'react'; +import { SecureAPI } from '../lib/secureApi'; + +interface Booking { + id: string; + check_in: string; + check_out: string; + amount: string; + currency: string; +} + +interface BookingsListProps { + propertyId: string; + month?: string; // "YYYY-MM" +} + +export const BookingsList: React.FC = ({ propertyId, month }) => { + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + setLoading(true); + setError(''); + SecureAPI.getDashboardReservations(propertyId, { + year: month ? Number(month.split('-')[0]) : undefined, + month: month ? Number(month.split('-')[1]) : undefined + }) + .then((res) => setBookings(res.reservations)) + .catch((err) => { + setError('Failed to load bookings'); + console.error(err); + }) + .finally(() => setLoading(false)); + }, [propertyId, month]); + + if (loading) return
Loading bookings...
; + if (error) return
{error}
; + + return ( +
+
+

+ Bookings ({bookings.length}) +

+
+ + {bookings.length === 0 ? ( +

No bookings for this period.

+ ) : ( +
+ + + + + + + + + + + {bookings.map((b) => ( + + + + + + + ))} + +
BookingCheck-inCheck-outAmount
{b.id}{b.check_in}{b.check_out} + {b.currency} {Number(b.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 3 })} +
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..abca9b5f4 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,25 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; 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' } -]; +import { BookingsList } from "./BookingsList"; +import { SecureAPI } from "../lib/secureApi"; const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [properties, setProperties] = useState<{ id: string; name: string }[]>([]); + const [selectedProperty, setSelectedProperty] = useState(''); + const [selectedMonth, setSelectedMonth] = useState(''); // "YYYY-MM", empty = all time + const [error, setError] = useState(''); + + useEffect(() => { + SecureAPI.getDashboardProperties() + .then((res) => { + setProperties(res.properties); + if (res.properties.length) setSelectedProperty(res.properties[0].id); + }) + .catch((err) => { + console.error(err); + setError('Failed to load properties'); + }); + }, []); return (
@@ -29,13 +38,20 @@ const Dashboard: React.FC = () => { {/* Property Selector */}
+ + setSelectedMonth(e.target.value)} + className="block w-full sm:w-auto min-w-[200px] px-3 py-2 mb-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm" + />