Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from typing import Dict, Any, Optional
from app.services.cache import get_revenue_summary
from app.services.reservations import calculate_monthly_revenue
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()

@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
month: Optional[int] = None,
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)


if month is not None and year is not None:
revenue_data = await calculate_monthly_revenue(property_id, tenant_id, month, year)
else:
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,
Expand Down
8 changes: 4 additions & 4 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -15,11 +14,12 @@ 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
Expand All @@ -45,7 +45,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")
Expand Down
2 changes: 1 addition & 1 deletion backend/app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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}"
cache_key = f"revenue:{tenant_id}:{property_id}"

# Try to get from cache
cached = await redis_client.get(cache_key)
Expand Down
66 changes: 47 additions & 19 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,62 @@
from decimal import Decimal
from typing import Dict, Any, List

async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal:
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 calendar month.

Reservations are bucketed by month using the PROPERTY'S OWN local
timezone, not UTC. check_in_date is stored as timestamptz (UTC), so a
booking near midnight local time can fall on the other side of the
month boundary once converted - e.g. 2024-02-29 23:30 UTC is already
2024-03-01 in Europe/Paris. Comparing against naive UTC month
boundaries would silently drop that reservation from March.
"""
from app.core.database_pool import DatabasePool
from sqlalchemy import text

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
db_pool = DatabasePool()
await db_pool.initialize()
Comment on lines +25 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The calculate_monthly_revenue function creates a new DatabasePool on every call without closing it, which will leak database connections.
Severity: CRITICAL

Suggested Fix

Remove the local instantiation of DatabasePool within calculate_monthly_revenue. Instead, import and use the existing global db_pool instance from backend/app/db/database_pool.py. This will ensure that a single, shared connection pool is used for all database operations, preventing connection leaks.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: backend/app/services/reservations.py#L25-L26

Potential issue: The `calculate_monthly_revenue` function instantiates and initializes a
new `DatabasePool` on every invocation. This creates a new connection pool of up to 50
connections that is never closed or disposed of, as `db_pool.close()` is not called
before the function returns. Under load, each request to the corresponding endpoint will
leak database connections, quickly exhausting the PostgreSQL connection limit. This will
cause all subsequent database operations across the application to fail with connection
errors, leading to a service-wide outage.

Did we get this right? 👍 / 👎 to inform future reviews.


if not db_pool.session_factory:
raise Exception("Database pool not available")

async with db_pool.get_session() as session:
query = text("""
SELECT SUM(r.total_amount) as total, COUNT(*) as cnt
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
""")
result = await session.execute(query, {
"property_id": property_id,
"tenant_id": tenant_id,
"start_date": start_date,
"end_date": end_date,
})
row = result.fetchone()

total = Decimal(str(row.total)) if row and row.total is not None else Decimal('0.00')
count = row.cnt if row else 0

return {
"property_id": property_id,
"tenant_id": tenant_id,
"month": month,
"year": year,
"total": str(total),
"currency": "USD",
"count": count,
}

async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]:
"""
Expand Down
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
38 changes: 29 additions & 9 deletions frontend/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { RevenueSummary } from "./RevenueSummary";
import { useAppContext } from "../contexts/AppContext";

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' }
];
// Each property id is only unique WITHIN a tenant (see database/seed.sql,
// properties has a composite PK of (id, tenant_id)) - prop-001 means a
// different property depending on which tenant you are.
const PROPERTIES_BY_TENANT: Record<string, { id: string; name: string }[]> = {
'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' },
],
};

const Dashboard: React.FC = () => {
const [selectedProperty, setSelectedProperty] = useState('prop-001');
const { user } = useAppContext();
const PROPERTIES = (user?.tenant_id && PROPERTIES_BY_TENANT[user.tenant_id]) || [];
const [selectedProperty, setSelectedProperty] = useState('');

// PROPERTIES depends on user.tenant_id, which loads asynchronously after
// mount - sync the selection once the tenant's property list becomes
// available instead of getting stuck on the empty initial value.
useEffect(() => {
if (!selectedProperty && PROPERTIES.length > 0) {
setSelectedProperty(PROPERTIES[0].id);
}
}, [PROPERTIES, selectedProperty]);

return (
<div className="p-4 lg:p-6 min-h-full">
Expand Down