Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.idea/
.venv/
**/.venv/
__pycache__/
**/__pycache__/
*.py[cod]
137 changes: 137 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Property Revenue Dashboard Debugging Assignment

## Loom Walkthrough

I will add the link to my Loom walkthrough here after recording it.

## SetUp

I followed the instructions in ASSIGNMENT.md and investigated the clients and their data in the db.

From the problem description and the client reports, a few possible bugs came to mind:

- Database misconfiguration
- Invalid cache
- Incorrect date handling
- Rounding errors
- Bugs in the UI

## Investigation Approach

I like starting with running the whole test suite, but the project has none. I thus started by installing the project and logging in as both clients.

I compared what I saw in the UI with the API response and the data in the PostgreSQL Docker service:

~~~bash
docker compose exec db psql -U postgres -d propertyflow
~~~

I then checked the backend logs, Redis, the database queries and finally the code that handled each request. I worked from the data source towards the UI so that each later result was based on real data.

## What I Discovered and How

### 1. The application was returning mock revenue

The first thing I noticed was that Sunset's prop-001 result did not match the records in the db. The logs contained:

~~~text
Database pool initialization failed: 'Settings' object has no attribute 'supabase_db_user'
~~~

I searched for this configuration and found that database_pool.py expected settings that did not exist. I then followed the error into reservations.py and found that the error was caught and replaced with hardcoded revenue data. This made a database failure look like a valid financial result.

### 2. Revenue cache entries were shared between tenants

After tracing the revenue request, I checked Redis and found a key in this format:

~~~text
revenue:prop-001
~~~

Sunset and Ocean both have a property with the ID prop-001. This meant that the first client to load the property populated the value returned to the other client for five minutes. The database query included the tenant, but Redis returned its value before that query ran.

### 3. The monthly dashboard calculated all-time revenue

The UI described the result as monthly performance, but the endpoint did not accept a month or year. The active SQL query only filtered by property and tenant.

I also found an unfinished calculate_monthly_revenue function. It returned zero, was not used, and created date boundaries without using the property's timezone.

### 4. March was being interpreted in the wrong timezone

While checking the seeded records, I found this reservation:

~~~text
UTC: 2024-02-29 23:30
Europe/Paris: 2024-03-01 00:30
Amount: 1250.000
~~~

It is a February timestamp in UTC but a March booking for the Paris property. A UTC-only March filter would therefore exclude valid March revenue.

### 5. Exact database decimals were converted to floats

The database stores amounts as NUMERIC(10, 3) and the service initially used Decimal. The route converted the value to a Python float, after which the frontend rounded a JavaScript number using Math.round.

The seeded values 333.333, 333.333 and 333.334 made it clear that the financial rounding rule needed to be explicit and applied in one place.

### 6. Both clients saw the same hardcoded property list

While switching accounts, I noticed that both clients saw the same five properties. Ocean's prop-001 was even labelled as Sunset's “Beach House Alpha” instead of “Mountain Lodge Beta.”

I found that the entire list was hardcoded in Dashboard.tsx. The frontend also sent a simulated tenant header even though the tenant should only come from the authenticated user.

## Fixes Applied

I applied the fixes in the same order in which I found the issues:

### 1. Database connection and mock revenue

[backend/app/core/database_pool.py](backend/app/core/database_pool.py) now uses the configured DATABASE_URL and an async-compatible pool. [backend/app/main.py](backend/app/main.py) manages that shared pool, while [backend/app/services/reservations.py](backend/app/services/reservations.py) no longer replaces database errors with mock financial data. I also updated [backend/requirements.txt](backend/requirements.txt), [backend/pyproject.toml](backend/pyproject.toml) and [backend/uv.lock](backend/uv.lock) with SQLAlchemy's async dependency.

### 2. Cross-tenant cache

[backend/app/services/cache.py](backend/app/services/cache.py) now includes the tenant, property, year and month in every revenue cache key.

### 3. Monthly revenue

[backend/app/api/v1/dashboard.py](backend/app/api/v1/dashboard.py) now requires a reporting month and year. [backend/app/services/reservations.py](backend/app/services/reservations.py) filters reservations using that period instead of returning an all-time total.

### 4. Property timezone

[backend/app/services/reservations.py](backend/app/services/reservations.py) now builds the calendar month in the property's named timezone and converts the boundaries to UTC before querying.

### 5. Financial precision

[backend/app/services/reservations.py](backend/app/services/reservations.py) rounds the final Decimal using ROUND_HALF_UP. [backend/app/api/v1/dashboard.py](backend/app/api/v1/dashboard.py) returns that exact value as a string, and [frontend/src/components/RevenueSummary.tsx](frontend/src/components/RevenueSummary.tsx) displays it without JavaScript rounding.

### 6. Tenant-specific properties

[backend/app/api/v1/dashboard.py](backend/app/api/v1/dashboard.py) exposes a tenant-filtered property endpoint and rejects properties outside the authenticated tenant. [frontend/src/lib/secureApi.ts](frontend/src/lib/secureApi.ts) no longer sends a simulated tenant header, while [frontend/src/components/Dashboard.tsx](frontend/src/components/Dashboard.tsx) loads the authenticated client's properties and provides the month selector.

Finally, [.gitignore](.gitignore) excludes .idea, virtual environments, __pycache__ directories and compiled Python files.

## How I Will Demonstrate the Fixes

I will rebuild the application from a clean environment and demonstrate the following:

1. The backend connects to PostgreSQL and the API result agrees with the db instead of returning mock data.
2. When the database is unavailable, revenue returns a service error rather than a fabricated total.
3. Sunset and Ocean receive separate results for their shared prop-001 identifier.
4. Redis contains separate keys in this format:

~~~text
revenue:<tenant_id>:<property_id>:<year>:<month>
~~~

5. Sunset's March 2024 report includes the reservation stored at February 29 23:30 UTC because it is March 1 in Paris.
6. The raw API response contains the same exact two-decimal value displayed in the UI.
7. Sunset sees only Sunset properties and Ocean sees only Ocean properties.
8. A direct request for another tenant's property returns no property or revenue data.

## Recommendations

This is what I would do if I had more time:

1. Add regression tests for tenant cache isolation, local month boundaries, property authorization and decimal rounding.
2. Add structured logs for the tenant, property, reporting period and cache hit or miss without logging tokens or financial data.
3. Add cache invalidation when reservations or property timezone information changes.
74 changes: 59 additions & 15 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,69 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from app.services.cache import get_revenue_summary
from typing import Any, Dict, List

from fastapi import APIRouter, Depends, HTTPException, Query

from app.core.auth import authenticate_request as get_current_user
from app.models.auth import AuthenticatedUser
from app.services.cache import get_revenue_summary
from app.services.reservations import (
PropertyNotFound,
RevenueServiceUnavailable,
get_tenant_properties,
)

router = APIRouter()


def _get_tenant_id(current_user: AuthenticatedUser) -> str:
if not current_user.tenant_id:
raise HTTPException(status_code=403, detail="Tenant access is required")
return current_user.tenant_id


@router.get("/dashboard/properties")
async def get_dashboard_properties(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> List[Dict[str, str]]:
tenant_id = _get_tenant_id(current_user)

try:
return await get_tenant_properties(tenant_id)
except RevenueServiceUnavailable as error:
raise HTTPException(
status_code=503,
detail="Property data is temporarily unavailable",
) from error


@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
current_user: dict = Depends(get_current_user)
year: int = Query(..., ge=2000, le=2100),
month: int = Query(..., ge=1, le=12),
current_user: 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 = _get_tenant_id(current_user)

try:
revenue_data = await get_revenue_summary(
property_id,
tenant_id,
month,
year,
)
except PropertyNotFound as error:
raise HTTPException(status_code=404, detail="Property not found") from error
except RevenueServiceUnavailable as error:
raise HTTPException(
status_code=503,
detail="Revenue data is temporarily unavailable",
) from error

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"],
"year": revenue_data["year"],
"month": revenue_data["month"],
"total_revenue": revenue_data["total"],
"currency": revenue_data["currency"],
"reservations_count": revenue_data["count"],
}
31 changes: 24 additions & 7 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
from sqlalchemy import text
import logging
from ..config import settings

Expand All @@ -13,13 +12,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
Expand All @@ -32,23 +40,32 @@ async def initialize(self):
class_=AsyncSession,
expire_on_commit=False
)

# Opening a connection here catches invalid configuration during
# application startup instead of on the first dashboard request.
async with self.engine.connect() as connection:
await connection.execute(text("SELECT 1"))

logger.info("✅ Database connection pool initialized")

except Exception as e:
logger.error(f"❌ Database pool initialization failed: {e}")
if self.engine:
await self.engine.dispose()
self.engine = None
self.session_factory = None

async def close(self):
"""Close database connections"""
if self.engine:
await self.engine.dispose()
self.engine = None
self.session_factory = None

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")
raise RuntimeError("Database pool not initialized")
return self.session_factory()

# Global database pool instance
Expand Down
9 changes: 9 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import time

from app.core.redis_client import redis_client
from app.core.database_pool import db_pool
from .api.v1 import (
users_lightning,
cities,
Expand Down Expand Up @@ -90,6 +91,11 @@ async def lifespan(app: FastAPI):
# Startup
logger.info("Starting up...")

# Initialize the PostgreSQL pool used by revenue reporting.
await db_pool.initialize()
if not db_pool.session_factory:
logger.warning("Revenue database is unavailable")

# Initialize Supabase connection pool
try:
from .core.supabase_connection_pool import supabase_pool
Expand Down Expand Up @@ -127,6 +133,9 @@ async def lifespan(app: FastAPI):
await async_processor.shutdown()
logger.info("Async processor shutdown completed")

await db_pool.close()
logger.info("Revenue database connection pool closed")

# Close connection pool
try:
from .core.supabase_connection_pool import supabase_pool
Expand Down
21 changes: 15 additions & 6 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,34 @@
from typing import Dict, Any
import os

from app.services.reservations import calculate_monthly_revenue

# 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}"
cache_key = f"revenue:{tenant_id}:{property_id}:{year}:{month}"

# Try to get from cache
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)

# Revenue calculation is delegated to the reservation service.
from app.services.reservations import calculate_total_revenue

# Calculate revenue
result = await calculate_total_revenue(property_id, tenant_id)
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))
Expand Down
Loading