""" 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}")