Coverage for src/alprina_cli/services/alert_service.py: 17%
110 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2Alert Service - Creates and manages security alerts and notifications
3"""
4import os
5from typing import Optional, Dict, Any
6from datetime import datetime
7import resend
8from ..api.services.neon_service import NeonService
11class AlertService:
12 """Service for creating alerts and sending email notifications"""
14 def __init__(self):
15 self.db = NeonService()
16 self.resend_api_key = os.getenv('RESEND_API_KEY')
17 if self.resend_api_key:
18 resend.api_key = self.resend_api_key
20 def create_alert(
21 self,
22 user_id: str,
23 scan_id: Optional[str],
24 alert_type: str,
25 severity: str,
26 title: str,
27 message: str,
28 action_url: Optional[str] = None,
29 metadata: Optional[Dict[str, Any]] = None
30 ) -> Optional[str]:
31 """
32 Create a new alert in the database
34 Args:
35 user_id: UUID of the user
36 scan_id: UUID of the related scan (optional)
37 alert_type: Type of alert (critical_finding, high_finding, scan_complete, scan_failed, subscription_expiring)
38 severity: Severity level (critical, high, medium, low, info)
39 title: Alert title
40 message: Alert message
41 action_url: URL for user to take action (optional)
42 metadata: Additional metadata (optional)
44 Returns:
45 Alert ID if successful, None otherwise
46 """
47 try:
48 query = """
49 SELECT create_alert(
50 %s::UUID, %s::UUID, %s, %s, %s, %s, %s, %s::JSONB
51 ) as alert_id
52 """
53 result = self.db.execute_query(
54 query,
55 (user_id, scan_id, alert_type, severity, title, message, action_url, metadata or {})
56 )
58 if result and len(result) > 0:
59 alert_id = result[0]['alert_id']
61 # Check if user wants email notification for this alert type
62 should_send_email = self._should_send_email(user_id, alert_type)
64 if should_send_email:
65 self._send_email_notification(user_id, alert_id, title, message, action_url)
67 return alert_id
69 return None
71 except Exception as e:
72 print(f"❌ Error creating alert: {e}")
73 return None
75 def _should_send_email(self, user_id: str, alert_type: str) -> bool:
76 """Check if user has email notifications enabled for this alert type"""
77 try:
78 # Map alert_type to preference column
79 preference_map = {
80 'critical_finding': 'email_critical_findings',
81 'high_finding': 'email_high_findings',
82 'scan_complete': 'email_scan_complete',
83 'scan_failed': 'email_scan_failed',
84 'subscription_expiring': 'email_subscription_expiring'
85 }
87 preference_column = preference_map.get(alert_type)
88 if not preference_column:
89 return False
91 query = f"""
92 SELECT {preference_column}
93 FROM user_notification_preferences
94 WHERE user_id = %s
95 """
96 result = self.db.execute_query(query, (user_id,))
98 if result and len(result) > 0:
99 return result[0][preference_column]
101 # Default to True if no preferences found
102 return True
104 except Exception as e:
105 print(f"⚠️ Error checking email preferences: {e}")
106 return False
108 def _send_email_notification(
109 self,
110 user_id: str,
111 alert_id: str,
112 title: str,
113 message: str,
114 action_url: Optional[str]
115 ):
116 """Send email notification using Resend"""
117 if not self.resend_api_key:
118 print("⚠️ RESEND_API_KEY not configured, skipping email")
119 return
121 try:
122 # Get user email
123 user_query = "SELECT email, full_name FROM users WHERE id = %s"
124 user_result = self.db.execute_query(user_query, (user_id,))
126 if not user_result or len(user_result) == 0:
127 print(f"⚠️ User {user_id} not found")
128 return
130 user_email = user_result[0]['email']
131 user_name = user_result[0].get('full_name') or user_email
133 # Build email HTML
134 action_button = ""
135 if action_url:
136 action_button = f"""
137 <div style="margin: 30px 0;">
138 <a href="{action_url}"
139 style="background-color: #2563eb; color: white; padding: 12px 24px;
140 text-decoration: none; border-radius: 6px; display: inline-block;">
141 View Details
142 </a>
143 </div>
144 """
146 html_content = f"""
147 <!DOCTYPE html>
148 <html>
149 <head>
150 <meta charset="utf-8">
151 <meta name="viewport" content="width=device-width, initial-scale=1.0">
152 </head>
153 <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
154 line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
156 <div style="background-color: #f8fafc; border-radius: 8px; padding: 30px; margin-bottom: 20px;">
157 <h1 style="color: #1e293b; margin: 0 0 20px 0; font-size: 24px;">
158 🔔 Security Alert
159 </h1>
161 <p style="margin: 0 0 10px 0; color: #64748b;">
162 Hi {user_name},
163 </p>
165 <div style="background-color: white; border-left: 4px solid #ef4444;
166 padding: 20px; border-radius: 4px; margin: 20px 0;">
167 <h2 style="margin: 0 0 10px 0; font-size: 18px; color: #1e293b;">
168 {title}
169 </h2>
170 <p style="margin: 0; color: #475569;">
171 {message}
172 </p>
173 </div>
175 {action_button}
177 <div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e2e8f0;">
178 <p style="margin: 0; font-size: 14px; color: #64748b;">
179 You're receiving this email because you have email notifications enabled for security alerts.
180 <br>
181 <a href="https://www.alprina.com/dashboard/settings" style="color: #2563eb;">
182 Manage your notification preferences
183 </a>
184 </p>
185 </div>
186 </div>
188 <div style="text-align: center; color: #94a3b8; font-size: 12px;">
189 <p>
190 Alprina Security Platform<br>
191 <a href="https://www.alprina.com" style="color: #64748b;">www.alprina.com</a>
192 </p>
193 </div>
194 </body>
195 </html>
196 """
198 # Send email via Resend
199 params = {
200 "from": "Alprina Security <alerts@alprina.com>",
201 "to": [user_email],
202 "subject": f"🔔 {title}",
203 "html": html_content
204 }
206 email_response = resend.Emails.send(params)
208 # Mark email as sent in database
209 update_query = """
210 UPDATE alerts
211 SET email_sent = true, email_sent_at = NOW()
212 WHERE id = %s
213 """
214 self.db.execute_query(update_query, (alert_id,))
216 print(f"✅ Email sent to {user_email} (Alert ID: {alert_id})")
218 except Exception as e:
219 print(f"❌ Error sending email notification: {e}")
221 def mark_alert_read(self, alert_id: str) -> bool:
222 """Mark an alert as read"""
223 try:
224 query = "SELECT mark_alert_read(%s)"
225 self.db.execute_query(query, (alert_id,))
226 return True
227 except Exception as e:
228 print(f"❌ Error marking alert as read: {e}")
229 return False
231 def mark_all_alerts_read(self, user_id: str) -> bool:
232 """Mark all user alerts as read"""
233 try:
234 query = "SELECT mark_all_alerts_read(%s)"
235 self.db.execute_query(query, (user_id,))
236 return True
237 except Exception as e:
238 print(f"❌ Error marking all alerts as read: {e}")
239 return False
241 def get_user_alerts(
242 self,
243 user_id: str,
244 limit: int = 50,
245 unread_only: bool = False
246 ) -> list:
247 """Get alerts for a user"""
248 try:
249 if unread_only:
250 query = """
251 SELECT * FROM alerts
252 WHERE user_id = %s AND is_read = false
253 ORDER BY created_at DESC
254 LIMIT %s
255 """
256 else:
257 query = """
258 SELECT * FROM alerts
259 WHERE user_id = %s
260 ORDER BY created_at DESC
261 LIMIT %s
262 """
264 result = self.db.execute_query(query, (user_id, limit))
265 return result or []
267 except Exception as e:
268 print(f"❌ Error getting user alerts: {e}")
269 return []
271 def get_unread_count(self, user_id: str) -> int:
272 """Get count of unread alerts for a user"""
273 try:
274 query = "SELECT get_unread_alert_count(%s) as count"
275 result = self.db.execute_query(query, (user_id,))
277 if result and len(result) > 0:
278 return result[0]['count']
280 return 0
282 except Exception as e:
283 print(f"❌ Error getting unread count: {e}")
284 return 0
286 def create_scan_completion_alerts(self, scan_id: str, user_id: str, findings: Dict[str, int]):
287 """
288 Create alerts for scan completion based on findings
290 Args:
291 scan_id: UUID of the scan
292 user_id: UUID of the user
293 findings: Dictionary with severity counts (e.g., {'critical': 2, 'high': 5})
294 """
295 critical_count = findings.get('critical', 0)
296 high_count = findings.get('high', 0)
298 # Create alert for critical findings
299 if critical_count > 0:
300 self.create_alert(
301 user_id=user_id,
302 scan_id=scan_id,
303 alert_type='critical_finding',
304 severity='critical',
305 title=f'🚨 {critical_count} Critical Security {"Issue" if critical_count == 1 else "Issues"} Found',
306 message=f'Your recent security scan discovered {critical_count} critical vulnerability{"" if critical_count == 1 else "ies"} that require immediate attention.',
307 action_url=f'https://www.alprina.com/dashboard/scans/{scan_id}'
308 )
310 # Create alert for high severity findings
311 if high_count > 0:
312 self.create_alert(
313 user_id=user_id,
314 scan_id=scan_id,
315 alert_type='high_finding',
316 severity='high',
317 title=f'⚠️ {high_count} High Severity {"Issue" if high_count == 1 else "Issues"} Found',
318 message=f'Your recent security scan found {high_count} high severity vulnerability{"" if high_count == 1 else "ies"} that should be reviewed soon.',
319 action_url=f'https://www.alprina.com/dashboard/scans/{scan_id}'
320 )
322 # Create general scan complete alert if no critical/high findings
323 if critical_count == 0 and high_count == 0:
324 total_findings = sum(findings.values())
325 if total_findings == 0:
326 self.create_alert(
327 user_id=user_id,
328 scan_id=scan_id,
329 alert_type='scan_complete',
330 severity='info',
331 title='✅ Scan Completed Successfully',
332 message='Your security scan completed with no critical or high severity issues found.',
333 action_url=f'https://www.alprina.com/dashboard/scans/{scan_id}'
334 )
335 else:
336 self.create_alert(
337 user_id=user_id,
338 scan_id=scan_id,
339 alert_type='scan_complete',
340 severity='info',
341 title=f'✅ Scan Completed - {total_findings} {"Issue" if total_findings == 1 else "Issues"} Found',
342 message=f'Your security scan completed and found {total_findings} lower severity issue{"" if total_findings == 1 else "s"}.',
343 action_url=f'https://www.alprina.com/dashboard/scans/{scan_id}'
344 )