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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Hi I'm Uchechukwu Ahunanya

Emails: uahunanya92@gmail.com, wayne@waynejr.dev

Loom video link: https://www.loom.com/share/bff845b2b77b44d39916a2e893cd562d
1 change: 1 addition & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
app/**/__pycache__/
2 changes: 1 addition & 1 deletion backend/.python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3
3.12
29 changes: 17 additions & 12 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Dict, Any
from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user
Expand All @@ -8,18 +8,23 @@
@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
month: int | None = Query(None, ge=1, le=12),
year: int | None = Query(None, ge=2000, le=2100),
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 = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="Tenant context is required")
if (month is None) != (year is None):
raise HTTPException(status_code=422, detail="month and year must be provided together")

revenue_data = await get_revenue_summary(property_id, tenant_id, month=month, year=year)

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"],
"total_revenue": revenue_data["total_revenue"],
"currency": revenue_data["currency"],
"reservations_count": revenue_data["reservations_count"],
"month": revenue_data["month"],
"year": revenue_data["year"],
}
207 changes: 137 additions & 70 deletions backend/app/api/v1/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,114 @@
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
AVATAR_SIZE = (300, 300) # Max avatar dimensions


def _build_default_profile(user: AuthenticatedUser) -> UserProfile:
now_iso = datetime.utcnow().isoformat()
return UserProfile(**{
'id': f'synthetic-{user.id}',
'user_id': user.id,
'display_name': (user.email.split('@')[0] if user.email else 'User'),
'bio': None,
'phone': None,
'department': None,
'job_title': None,
'location': None,
'timezone': 'UTC',
'language': 'en',
'theme': 'light',
'avatar_url': None,
'created_at': now_iso,
'updated_at': now_iso,
})


def _build_default_preferences(user: AuthenticatedUser) -> UserPreferences:
now_iso = datetime.utcnow().isoformat()
return UserPreferences(**{
'id': f'synthetic-{user.id}',
'user_id': user.id,
'notification_email': True,
'notification_push': True,
'notification_desktop': True,
'notification_sound': True,
'auto_refresh': True,
'compact_view': False,
'sidebar_collapsed': False,
'created_at': now_iso,
'updated_at': now_iso,
})


def _default_profile_create_values(user: AuthenticatedUser) -> Dict[str, Any]:
return {
'display_name': (user.email.split('@')[0] if user.email else 'User'),
'bio': None,
'phone': None,
'department': None,
'job_title': None,
'location': None,
'timezone': 'UTC',
'language': 'en',
'theme': 'light',
'avatar_url': None,
}


def _default_preferences_create_values() -> Dict[str, Any]:
return {
'notification_email': True,
'notification_push': True,
'notification_desktop': True,
'notification_sound': True,
'auto_refresh': True,
'compact_view': False,
'sidebar_collapsed': False,
}


def _fetch_single_row(table_name: str, user_id: str) -> Optional[Dict[str, Any]]:
response = supabase.table(table_name).select('*').eq('user_id', user_id).limit(1).execute()
return response.data[0] if response.data else None


def _upsert_user_owned_row(
table_name: str,
user_id: str,
defaults: Dict[str, Any],
update_data: Dict[str, Any],
) -> Dict[str, Any]:
existing = _fetch_single_row(table_name, user_id)
if existing:
response = (
supabase.table(table_name)
.update(update_data)
.eq('user_id', user_id)
.execute()
)
if response.data:
return response.data[0]
refreshed = _fetch_single_row(table_name, user_id)
if refreshed:
return refreshed
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update {table_name}",
)

create_data = {**defaults, **update_data, 'user_id': user_id}
response = supabase.table(table_name).insert(create_data).execute()
if response.data:
return response.data[0]

refreshed = _fetch_single_row(table_name, user_id)
if refreshed:
return refreshed

raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create {table_name}",
)

def allowed_file(filename: str) -> bool:
"""Check if the file extension is allowed"""
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
Expand Down Expand Up @@ -65,41 +173,7 @@ async def get_profile(
"""Get current user's profile, preferences, and notification settings"""
try:
logger.info(f"User {user.email} is fetching their profile.")

# Create default profile data in case tables don't exist or profile is missing
now_iso = datetime.utcnow().isoformat()
# Build safe defaults matching the response models when DB rows are missing
default_profile = {
'id': f'synthetic-{user.id}',
'user_id': user.id,
'display_name': (user.email.split('@')[0] if user.email else 'User'),
'bio': None,
'phone': None,
'department': None,
'job_title': None,
'location': None,
'timezone': 'UTC',
'language': 'en',
'theme': 'light',
'avatar_url': None,
'created_at': now_iso,
'updated_at': now_iso,
}

default_preferences = {
'id': f'synthetic-{user.id}',
'user_id': user.id,
'notification_email': True,
'notification_push': True,
'notification_desktop': True,
'notification_sound': True,
'auto_refresh': True,
'compact_view': False,
'sidebar_collapsed': False,
'created_at': now_iso,
'updated_at': now_iso,
}


profile = None
preferences = None
notification_preferences = []
Expand All @@ -113,11 +187,11 @@ async def get_profile(
profile = UserProfile(**profile_data)
else:
logger.info(f"No profile found for user {user.id}, using default profile")
profile = UserProfile(**default_profile)
profile = _build_default_profile(user)
except Exception as profile_error:
logger.warning(f"Error accessing user_profiles table for user {user.id}: {profile_error}")
logger.info(f"Using default profile for user {user.id}")
profile = UserProfile(**default_profile)
profile = _build_default_profile(user)

# Try to get user preferences
try:
Expand All @@ -127,11 +201,11 @@ async def get_profile(
preferences = UserPreferences(**preferences_data)
else:
logger.info(f"No preferences found for user {user.id}, using default preferences")
preferences = UserPreferences(**default_preferences)
preferences = _build_default_preferences(user)
except Exception as preferences_error:
logger.warning(f"Error accessing user_preferences table for user {user.id}: {preferences_error}")
logger.info(f"Using default preferences for user {user.id}")
preferences = UserPreferences(**default_preferences)
preferences = _build_default_preferences(user)

# Try to get notification preferences
try:
Expand Down Expand Up @@ -192,16 +266,13 @@ async def update_profile(
detail="No valid fields to update"
)

# Update profile
response = supabase.table('user_profiles').update(update_data).eq('user_id', user.id).execute()

if not response.data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Profile not found"
)

updated_profile = UserProfile(**response.data[0])
updated_row = _upsert_user_owned_row(
'user_profiles',
user.id,
_default_profile_create_values(user),
update_data,
)
updated_profile = UserProfile(**updated_row)
logger.info(f"Successfully updated profile for user {user.id}")

return updated_profile
Expand Down Expand Up @@ -233,16 +304,13 @@ async def update_preferences(
detail="No valid fields to update"
)

# Update preferences
response = supabase.table('user_preferences').update(update_data).eq('user_id', user.id).execute()

if not response.data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Preferences not found"
)

updated_preferences = UserPreferences(**response.data[0])
updated_row = _upsert_user_owned_row(
'user_preferences',
user.id,
_default_preferences_create_values(),
update_data,
)
updated_preferences = UserPreferences(**updated_row)
logger.info(f"Successfully updated preferences for user {user.id}")

return updated_preferences
Expand Down Expand Up @@ -373,20 +441,19 @@ async def upload_avatar(
public_url = supabase.storage.from_('profile-pictures').get_public_url(unique_filename)

# Update user profile with new avatar URL
profile_update = supabase.table('user_profiles').update({
'avatar_url': public_url
}).eq('user_id', user.id).execute()

if not profile_update.data:
# Clean up uploaded file if profile update fails
try:
_upsert_user_owned_row(
'user_profiles',
user.id,
_default_profile_create_values(user),
{'avatar_url': public_url},
)
except Exception:
try:
supabase.storage.from_('profile-pictures').remove([unique_filename])
except:
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update profile with new avatar"
)
raise

logger.info(f"Successfully uploaded avatar for user {user.id}")

Expand Down
8 changes: 8 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ def CRON_SECRET(self) -> str:
redis_db: int = 0
redis_password: Optional[str] = None

@property
def has_supabase_credentials(self) -> bool:
return bool(self.supabase_url and self.supabase_service_role_key)

@property
def is_challenge_mode(self) -> bool:
return not self.has_supabase_credentials

def get_hostaway_tokens(self) -> Dict[str, str]:
"""Parse Hostaway tokens from JSON string or fallback to space-separated format"""
try:
Expand Down
27 changes: 15 additions & 12 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import asyncio
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
from ..config import settings

logger = logging.getLogger(__name__)


class DatabasePool:
def __init__(self):
self.engine = None
Expand All @@ -14,17 +14,19 @@ 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}"
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
pool_recycle=3600, # Recycle connections every hour
echo=False # Set to True for SQL debugging
pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
pool_pre_ping=True,
pool_recycle=settings.database_pool_recycle,
echo=False,
)

self.session_factory = async_sessionmaker(
Expand All @@ -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")
Expand All @@ -54,7 +56,8 @@ async def get_session(self) -> AsyncSession:
# Global database pool instance
db_pool = DatabasePool()

async def get_db_session() -> AsyncSession:

async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""Dependency to get database session"""
async with db_pool.get_session() as session:
yield session
Loading