""" Reminder Notification Service Handles multi-channel delivery of reminders (Mattermost, Email, Frontend) Includes rate limiting and user preference merging """ import logging import asyncio from typing import List, Dict, Optional, Tuple from datetime import datetime, timedelta import json from jinja2 import Template from app.core.config import settings from app.core.database import execute_query, execute_insert from app.services.email_service import EmailService from app.backups.backend.notifications import MattermostNotification logger = logging.getLogger(__name__) class ReminderNotificationService: """Service for sending reminders via multiple notification channels""" def __init__(self): self.email_service = EmailService() self.mattermost_service = MattermostNotification() # Reminder delivery has its own enable flag; backup notifications keep # using the general MATTERMOST_ENABLED setting. self.mattermost_service.enabled = settings.REMINDERS_MATTERMOST_ENABLED self.dry_run = settings.REMINDERS_DRY_RUN self.max_per_hour = settings.REMINDERS_MAX_PER_USER_PER_HOUR def _refresh_mattermost_settings(self) -> None: """Load runtime Mattermost configuration from the admin settings table.""" rows = execute_query( """ SELECT key, value FROM settings WHERE key IN ('mattermost_reminders_enabled', 'mattermost_webhook_url', 'mattermost_channel') """ ) or [] values = {row["key"]: str(row.get("value") or "").strip() for row in rows} enabled_value = values.get("mattermost_reminders_enabled") enabled = ( enabled_value.lower() in {"1", "true", "yes", "on"} if enabled_value is not None else settings.REMINDERS_MATTERMOST_ENABLED ) self.mattermost_service.enabled = bool(enabled and settings.REMINDERS_MATTERMOST_ENABLED) self.mattermost_service.webhook_url = ( values.get("mattermost_webhook_url") or settings.MATTERMOST_WEBHOOK_URL ) self.mattermost_service.channel = values.get("mattermost_channel") or settings.MATTERMOST_CHANNEL @staticmethod def _case_url(case_id: int) -> str: base_url = str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").strip().rstrip("/") return f"{base_url}/sag/{case_id}/v3" async def send_reminder( self, reminder_id: int, sag_id: int, case_title: str, customer_name: str, reminder_title: str, reminder_message: Optional[str], recipient_user_ids: List[int], recipient_emails: List[str], priority: str = "normal", notify_mattermost: Optional[bool] = None, notify_email: Optional[bool] = None, notify_frontend: Optional[bool] = None, override_user_preferences: bool = False, additional_info: Optional[str] = None, case_status: Optional[str] = None, deadline: Optional[str] = None, assigned_user: Optional[str] = None ) -> Dict[str, any]: """ Send reminder via configured notification channels Returns dict with: { 'success': bool, 'channels_used': List[str], 'errors': List[str], 'rate_limited_users': List[int], 'logged_id': int } """ result = { 'success': False, 'channels_used': [], 'errors': [], 'rate_limited_users': [], 'logged_id': None } self._refresh_mattermost_settings() if self.dry_run: logger.warning(f"🔒 DRY RUN: Would send reminder '{reminder_title}' for case #{sag_id}") # Get case details case_query = "SELECT id, titel FROM sag_sager WHERE id = %s" case = execute_query(case_query, (sag_id,)) if not case: logger.error(f"❌ Case #{sag_id} not found") result['errors'].append(f"Case #{sag_id} not found") return result # Process each recipient user for user_id in recipient_user_ids: try: # Check rate limit if not await self._check_rate_limit(user_id): logger.warning(f"⚠️ Rate limit exceeded for user {user_id}") result['rate_limited_users'].append(user_id) continue # Get user preferences user_prefs = await self._get_user_preferences(user_id) # Merge with reminder-specific overrides channels = self._determine_channels( user_prefs, notify_mattermost, notify_email, notify_frontend, override_user_preferences ) # Get user email user_query = "SELECT email, username FROM users WHERE user_id = %s" user = execute_query(user_query, (user_id,)) user_email = user_prefs.get('email_override') or (user[0]['email'] if user else None) mattermost_username = ( user_prefs.get('mattermost_username') or (user[0].get('username') if user else None) ) # Send via channels for channel in channels: try: if channel == 'mattermost': if not settings.REMINDERS_MATTERMOST_ENABLED: result['errors'].append('Mattermost reminders are disabled') continue sent = await self._send_mattermost( reminder_title, reminder_message, sag_id, case_title, priority, additional_info, mattermost_username ) if sent: result['channels_used'].append('mattermost') else: result['errors'].append('Mattermost delivery failed') elif channel == 'email': if not settings.REMINDERS_EMAIL_ENABLED: result['errors'].append('Email reminders are disabled') continue if not user_email: result['errors'].append(f'User {user_id} has no email address') continue sent = await self._send_email( user_email, reminder_title, reminder_message, sag_id, case_title, customer_name, priority, case_status, deadline, assigned_user, additional_info ) if sent: result['channels_used'].append('email') else: result['errors'].append(f'Email delivery failed for {user_email}') elif channel == 'frontend': # Frontend notifications are handled by polling, no action needed here result['channels_used'].append('frontend') except Exception as e: error = f"Failed to send via {channel}: {str(e)}" logger.error(f"❌ {error}") result['errors'].append(error) # Log notification log_id = await self._log_reminder( reminder_id, sag_id, user_id, result['channels_used'], { 'title': reminder_title, 'message': reminder_message, 'case': case_title, 'priority': priority }, 'sent' if result['channels_used'] else 'failed' ) result['logged_id'] = log_id except Exception as e: logger.error(f"❌ Error sending reminder to user {user_id}: {e}") result['errors'].append(f"User {user_id}: {str(e)}") # Process additional email addresses for email_addr in recipient_emails: try: if settings.REMINDERS_EMAIL_ENABLED: sent = await self._send_email( email_addr, reminder_title, reminder_message, sag_id, case_title, customer_name, priority, case_status, deadline, assigned_user, additional_info ) if sent: result['channels_used'].append('email') else: result['errors'].append(f'Email delivery failed for {email_addr}') else: result['errors'].append('Email reminders are disabled') except Exception as e: error = f"Failed to send email to {email_addr}: {str(e)}" logger.error(f"❌ {error}") result['errors'].append(error) result['success'] = len(result['errors']) == 0 or len(result['channels_used']) > 0 return result async def _check_rate_limit(self, user_id: int) -> bool: """Check if user has exceeded notification limit (max 5 per hour)""" query = """ SELECT COUNT(*) as count FROM sag_reminder_logs WHERE user_id = %s AND triggered_at > CURRENT_TIMESTAMP - INTERVAL '1 hour' AND status = 'sent' """ result = execute_query(query, (user_id,)) count = result[0]['count'] if result else 0 if count >= self.max_per_hour: logger.warning(f"⚠️ Rate limit reached for user {user_id}: {count}/{self.max_per_hour} notifications") return False return True async def _get_user_preferences(self, user_id: int) -> Dict: """Get user notification preferences""" query = """ SELECT notify_mattermost, notify_email, notify_frontend, email_override, mattermost_username FROM user_notification_preferences WHERE user_id = %s """ result = execute_query(query, (user_id,)) if result: return { 'mattermost': result[0].get('notify_mattermost', True), 'email': result[0].get('notify_email', True), 'frontend': result[0].get('notify_frontend', True), 'email_override': result[0].get('email_override') , 'mattermost_username': result[0].get('mattermost_username') } # Default preferences return { 'mattermost': True, 'email': True, 'frontend': True, 'email_override': None , 'mattermost_username': None } def _determine_channels( self, user_prefs: Dict, notify_mattermost: Optional[bool], notify_email: Optional[bool], notify_frontend: Optional[bool], override: bool ) -> List[str]: """Determine which channels to use (merge user prefs with reminder overrides)""" channels = [] if not override: notify_mattermost = user_prefs.get('mattermost', True) notify_email = user_prefs.get('email', True) notify_frontend = user_prefs.get('frontend', True) # Mattermost mm = notify_mattermost if notify_mattermost is not None else user_prefs.get('mattermost', True) if mm: channels.append('mattermost') # Email em = notify_email if notify_email is not None else user_prefs.get('email', True) if em: channels.append('email') # Frontend fe = notify_frontend if notify_frontend is not None else user_prefs.get('frontend', True) if fe: channels.append('frontend') return channels async def _send_mattermost( self, title: str, message: Optional[str], case_id: int, case_title: str, priority: str, additional_info: Optional[str], mattermost_username: Optional[str] = None, ) -> bool: """Send reminder via Mattermost""" if self.dry_run: logger.warning(f"🔒 DRY RUN: Mattermost reminder '{title}'") return True try: color_map = { 'low': '#6c757d', 'normal': '#0f4c75', 'high': '#ffc107', 'urgent': '#dc3545' } case_url = self._case_url(case_id) payload = { 'text': f'🔔 **{title}**\n[Åbn sag #{case_id}]({case_url})', 'attachments': [{ 'title': case_title, 'title_link': case_url, 'text': message or additional_info or 'Se reminder i systemet', 'color': color_map.get(priority, color_map['normal']), 'fields': [ { 'title': 'Prioritet', 'value': priority.capitalize(), 'short': True }, { 'title': 'Sag ID', 'value': f'#{case_id}', 'short': True } ] }] } if mattermost_username: payload["channel"] = f"@{str(mattermost_username).strip().lstrip('@')}" success, msg = await self.mattermost_service._send_webhook(payload, 'reminder_notification') if not success and mattermost_username: # Some Mattermost webhooks are locked to their configured # channel and reject channel="@user". Fall back to an explicit # mention in that channel so the recipient still gets notified. username = str(mattermost_username).strip().lstrip("@") payload.pop("channel", None) payload["text"] = f"@{username} {payload['text']}" success, msg = await self.mattermost_service._send_webhook( payload, 'reminder_notification_mention', ) if success: logger.info(f"✅ Mattermost reminder sent: {title}") else: logger.error(f"❌ Mattermost error: {msg}") return success except Exception as e: logger.error(f"❌ Mattermost send failed: {e}") return False async def _send_email( self, to_email: str, title: str, message: Optional[str], case_id: int, case_title: str, customer_name: str, priority: str, case_status: Optional[str], deadline: Optional[str], assigned_user: Optional[str], additional_info: Optional[str] ) -> bool: """Send reminder via email""" if self.dry_run: logger.warning(f"🔒 DRY RUN: Email reminder to {to_email}") return True try: # Load email template with open('templates/emails/reminder.html', 'r') as f: template_html = f.read() # Prepare context context = { 'header_title': 'Reminder: ' + title, 'reminder_title': title, 'reminder_message': message or '', 'case_id': case_id, 'case_title': case_title, 'customer_name': customer_name, 'case_status': case_status or 'Unknown', 'priority': priority, 'deadline': deadline, 'assigned_user': assigned_user or 'Ikke tildelt', 'additional_info': additional_info or '', 'action_url': self._case_url(case_id), 'footer_date': datetime.now().strftime("%d. %B %Y") } # Render template template = Template(template_html) body_html = template.render(context) body_text = f"{title}\n\n{message or ''}\n\nSag: {case_title} (#{case_id})\nKunde: {customer_name}" # Send email success, msg = await self.email_service.send_email( to_addresses=[to_email], subject=f"[{priority.upper()}] Reminder: {title}", body_text=body_text, body_html=body_html, reply_to=settings.EMAIL_SMTP_FROM_ADDRESS ) if success: logger.info(f"✅ Email reminder sent to {to_email}") else: logger.error(f"❌ Email error: {msg}") return success except Exception as e: logger.error(f"❌ Email send failed: {e}") return False async def _log_reminder( self, reminder_id: int, sag_id: int, user_id: int, channels: List[str], payload: Dict, status: str = 'sent' ) -> Optional[int]: """Log reminder execution""" try: query = """ INSERT INTO sag_reminder_logs ( reminder_id, sag_id, user_id, channels_used, notification_payload, status, triggered_at ) VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) RETURNING id """ result = execute_insert(query, ( reminder_id, sag_id, user_id, channels, json.dumps(payload), status )) if result: log_id = result[0]['id'] if isinstance(result[0], dict) else result[0] logger.info(f"📝 Reminder logged (ID: {log_id})") return log_id except Exception as e: logger.error(f"❌ Failed to log reminder: {e}") return None # Global instance reminder_notification_service = ReminderNotificationService()