Files
bakery-ia/services/notification/app/repositories/preference_repository.py
2025-08-08 09:08:41 +02:00

474 lines
18 KiB
Python

"""
Preference Repository
Repository for notification preference operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, and_
from datetime import datetime
import structlog
from .base import NotificationBaseRepository
from app.models.notifications import NotificationPreference
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
class PreferenceRepository(NotificationBaseRepository):
"""Repository for notification preference operations"""
def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 900):
# Preferences are relatively stable, medium cache time (15 minutes)
super().__init__(NotificationPreference, session, cache_ttl)
async def create_preferences(self, preference_data: Dict[str, Any]) -> NotificationPreference:
"""Create user notification preferences with validation"""
try:
# Validate preference data
validation_result = self._validate_notification_data(
preference_data,
["user_id", "tenant_id"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid preference data: {validation_result['errors']}")
# Check if preferences already exist for this user and tenant
existing_prefs = await self.get_user_preferences(
preference_data["user_id"],
preference_data["tenant_id"]
)
if existing_prefs:
raise DuplicateRecordError(f"Preferences already exist for user in this tenant")
# Set default values
defaults = {
"email_enabled": True,
"email_alerts": True,
"email_marketing": False,
"email_reports": True,
"whatsapp_enabled": False,
"whatsapp_alerts": False,
"whatsapp_reports": False,
"push_enabled": True,
"push_alerts": True,
"push_reports": False,
"quiet_hours_start": "22:00",
"quiet_hours_end": "08:00",
"timezone": "Europe/Madrid",
"digest_frequency": "daily",
"max_emails_per_day": 10,
"language": "es"
}
# Apply defaults for any missing fields
for key, default_value in defaults.items():
if key not in preference_data:
preference_data[key] = default_value
# Create preferences
preferences = await self.create(preference_data)
logger.info("User notification preferences created",
preferences_id=preferences.id,
user_id=preferences.user_id,
tenant_id=preferences.tenant_id)
return preferences
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create preferences",
user_id=preference_data.get("user_id"),
tenant_id=preference_data.get("tenant_id"),
error=str(e))
raise DatabaseError(f"Failed to create preferences: {str(e)}")
async def get_user_preferences(
self,
user_id: str,
tenant_id: str
) -> Optional[NotificationPreference]:
"""Get notification preferences for a specific user and tenant"""
try:
preferences = await self.get_multi(
filters={
"user_id": user_id,
"tenant_id": tenant_id
},
limit=1
)
return preferences[0] if preferences else None
except Exception as e:
logger.error("Failed to get user preferences",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get preferences: {str(e)}")
async def update_user_preferences(
self,
user_id: str,
tenant_id: str,
update_data: Dict[str, Any]
) -> Optional[NotificationPreference]:
"""Update user notification preferences"""
try:
preferences = await self.get_user_preferences(user_id, tenant_id)
if not preferences:
# Create preferences if they don't exist
create_data = {
"user_id": user_id,
"tenant_id": tenant_id,
**update_data
}
return await self.create_preferences(create_data)
# Validate specific preference fields
self._validate_preference_updates(update_data)
updated_preferences = await self.update(str(preferences.id), update_data)
logger.info("User preferences updated",
preferences_id=preferences.id,
user_id=user_id,
tenant_id=tenant_id,
updated_fields=list(update_data.keys()))
return updated_preferences
except ValidationError:
raise
except Exception as e:
logger.error("Failed to update user preferences",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to update preferences: {str(e)}")
async def get_users_with_email_enabled(
self,
tenant_id: str,
notification_category: str = "alerts"
) -> List[NotificationPreference]:
"""Get users who have email notifications enabled for a category"""
try:
filters = {
"tenant_id": tenant_id,
"email_enabled": True
}
# Add category-specific filter
if notification_category == "alerts":
filters["email_alerts"] = True
elif notification_category == "marketing":
filters["email_marketing"] = True
elif notification_category == "reports":
filters["email_reports"] = True
return await self.get_multi(filters=filters)
except Exception as e:
logger.error("Failed to get users with email enabled",
tenant_id=tenant_id,
category=notification_category,
error=str(e))
return []
async def get_users_with_whatsapp_enabled(
self,
tenant_id: str,
notification_category: str = "alerts"
) -> List[NotificationPreference]:
"""Get users who have WhatsApp notifications enabled for a category"""
try:
filters = {
"tenant_id": tenant_id,
"whatsapp_enabled": True
}
# Add category-specific filter
if notification_category == "alerts":
filters["whatsapp_alerts"] = True
elif notification_category == "reports":
filters["whatsapp_reports"] = True
return await self.get_multi(filters=filters)
except Exception as e:
logger.error("Failed to get users with WhatsApp enabled",
tenant_id=tenant_id,
category=notification_category,
error=str(e))
return []
async def get_users_with_push_enabled(
self,
tenant_id: str,
notification_category: str = "alerts"
) -> List[NotificationPreference]:
"""Get users who have push notifications enabled for a category"""
try:
filters = {
"tenant_id": tenant_id,
"push_enabled": True
}
# Add category-specific filter
if notification_category == "alerts":
filters["push_alerts"] = True
elif notification_category == "reports":
filters["push_reports"] = True
return await self.get_multi(filters=filters)
except Exception as e:
logger.error("Failed to get users with push enabled",
tenant_id=tenant_id,
category=notification_category,
error=str(e))
return []
async def check_quiet_hours(
self,
user_id: str,
tenant_id: str,
check_time: datetime = None
) -> bool:
"""Check if current time is within user's quiet hours"""
try:
preferences = await self.get_user_preferences(user_id, tenant_id)
if not preferences:
return False # No quiet hours if no preferences
if not check_time:
check_time = datetime.utcnow()
# Convert time to user's timezone (simplified - using hour comparison)
current_hour = check_time.hour
quiet_start = int(preferences.quiet_hours_start.split(":")[0])
quiet_end = int(preferences.quiet_hours_end.split(":")[0])
# Handle quiet hours that span midnight
if quiet_start > quiet_end:
return current_hour >= quiet_start or current_hour < quiet_end
else:
return quiet_start <= current_hour < quiet_end
except Exception as e:
logger.error("Failed to check quiet hours",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
return False
async def get_users_for_digest(
self,
tenant_id: str,
frequency: str = "daily"
) -> List[NotificationPreference]:
"""Get users who want digest notifications for a frequency"""
try:
return await self.get_multi(
filters={
"tenant_id": tenant_id,
"digest_frequency": frequency,
"email_enabled": True
}
)
except Exception as e:
logger.error("Failed to get users for digest",
tenant_id=tenant_id,
frequency=frequency,
error=str(e))
return []
async def can_send_email(
self,
user_id: str,
tenant_id: str,
category: str = "alerts"
) -> Dict[str, Any]:
"""Check if an email can be sent to a user based on their preferences"""
try:
preferences = await self.get_user_preferences(user_id, tenant_id)
if not preferences:
return {
"can_send": True, # Default to allowing if no preferences set
"reason": "No preferences found, using defaults"
}
# Check if email is enabled
if not preferences.email_enabled:
return {
"can_send": False,
"reason": "Email notifications disabled"
}
# Check category-specific settings
category_enabled = True
if category == "alerts" and not preferences.email_alerts:
category_enabled = False
elif category == "marketing" and not preferences.email_marketing:
category_enabled = False
elif category == "reports" and not preferences.email_reports:
category_enabled = False
if not category_enabled:
return {
"can_send": False,
"reason": f"Email {category} notifications disabled"
}
# Check quiet hours
if self.check_quiet_hours(user_id, tenant_id):
return {
"can_send": False,
"reason": "Within user's quiet hours"
}
# Check daily limit (simplified - would need to query recent notifications)
# For now, just return the limit info
return {
"can_send": True,
"max_daily_emails": preferences.max_emails_per_day,
"language": preferences.language,
"timezone": preferences.timezone
}
except Exception as e:
logger.error("Failed to check if email can be sent",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
return {
"can_send": True, # Default to allowing on error
"reason": "Error checking preferences"
}
async def bulk_update_preferences(
self,
tenant_id: str,
update_data: Dict[str, Any],
user_ids: List[str] = None
) -> int:
"""Bulk update preferences for multiple users"""
try:
conditions = ["tenant_id = :tenant_id"]
params = {"tenant_id": tenant_id}
if user_ids:
placeholders = ", ".join([f":user_id_{i}" for i in range(len(user_ids))])
conditions.append(f"user_id IN ({placeholders})")
for i, user_id in enumerate(user_ids):
params[f"user_id_{i}"] = user_id
# Build update clause
update_fields = []
for key, value in update_data.items():
update_fields.append(f"{key} = :update_{key}")
params[f"update_{key}"] = value
params["updated_at"] = datetime.utcnow()
update_fields.append("updated_at = :updated_at")
query_text = f"""
UPDATE notification_preferences
SET {', '.join(update_fields)}
WHERE {' AND '.join(conditions)}
"""
result = await self.session.execute(text(query_text), params)
updated_count = result.rowcount
logger.info("Bulk preferences update completed",
tenant_id=tenant_id,
updated_count=updated_count,
updated_fields=list(update_data.keys()))
return updated_count
except Exception as e:
logger.error("Failed to bulk update preferences",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Bulk update failed: {str(e)}")
async def delete_user_preferences(
self,
user_id: str,
tenant_id: str
) -> bool:
"""Delete user preferences (when user leaves tenant)"""
try:
preferences = await self.get_user_preferences(user_id, tenant_id)
if not preferences:
return False
await self.delete(str(preferences.id))
logger.info("User preferences deleted",
user_id=user_id,
tenant_id=tenant_id)
return True
except Exception as e:
logger.error("Failed to delete user preferences",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to delete preferences: {str(e)}")
def _validate_preference_updates(self, update_data: Dict[str, Any]) -> None:
"""Validate preference update data"""
# Validate boolean fields
boolean_fields = [
"email_enabled", "email_alerts", "email_marketing", "email_reports",
"whatsapp_enabled", "whatsapp_alerts", "whatsapp_reports",
"push_enabled", "push_alerts", "push_reports"
]
for field in boolean_fields:
if field in update_data and not isinstance(update_data[field], bool):
raise ValidationError(f"{field} must be a boolean value")
# Validate time format for quiet hours
time_fields = ["quiet_hours_start", "quiet_hours_end"]
for field in time_fields:
if field in update_data:
time_value = update_data[field]
if not isinstance(time_value, str) or len(time_value) != 5 or ":" not in time_value:
raise ValidationError(f"{field} must be in HH:MM format")
try:
hour, minute = time_value.split(":")
hour, minute = int(hour), int(minute)
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
raise ValueError()
except ValueError:
raise ValidationError(f"{field} must be a valid time in HH:MM format")
# Validate digest frequency
if "digest_frequency" in update_data:
valid_frequencies = ["none", "daily", "weekly"]
if update_data["digest_frequency"] not in valid_frequencies:
raise ValidationError(f"digest_frequency must be one of: {valid_frequencies}")
# Validate max emails per day
if "max_emails_per_day" in update_data:
max_emails = update_data["max_emails_per_day"]
if not isinstance(max_emails, int) or max_emails < 0 or max_emails > 100:
raise ValidationError("max_emails_per_day must be an integer between 0 and 100")
# Validate language
if "language" in update_data:
valid_languages = ["es", "en", "fr", "de"]
if update_data["language"] not in valid_languages:
raise ValidationError(f"language must be one of: {valid_languages}")