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

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 

9 

10 

11class AlertService: 

12 """Service for creating alerts and sending email notifications""" 

13 

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 

19 

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 

33 

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) 

43 

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 ) 

57 

58 if result and len(result) > 0: 

59 alert_id = result[0]['alert_id'] 

60 

61 # Check if user wants email notification for this alert type 

62 should_send_email = self._should_send_email(user_id, alert_type) 

63 

64 if should_send_email: 

65 self._send_email_notification(user_id, alert_id, title, message, action_url) 

66 

67 return alert_id 

68 

69 return None 

70 

71 except Exception as e: 

72 print(f"❌ Error creating alert: {e}") 

73 return None 

74 

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 } 

86 

87 preference_column = preference_map.get(alert_type) 

88 if not preference_column: 

89 return False 

90 

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,)) 

97 

98 if result and len(result) > 0: 

99 return result[0][preference_column] 

100 

101 # Default to True if no preferences found 

102 return True 

103 

104 except Exception as e: 

105 print(f"⚠️ Error checking email preferences: {e}") 

106 return False 

107 

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 

120 

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,)) 

125 

126 if not user_result or len(user_result) == 0: 

127 print(f"⚠️ User {user_id} not found") 

128 return 

129 

130 user_email = user_result[0]['email'] 

131 user_name = user_result[0].get('full_name') or user_email 

132 

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

145 

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;"> 

155 

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> 

160 

161 <p style="margin: 0 0 10px 0; color: #64748b;"> 

162 Hi {user_name}, 

163 </p> 

164 

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> 

174 

175 {action_button} 

176 

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> 

187 

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

197 

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 } 

205 

206 email_response = resend.Emails.send(params) 

207 

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,)) 

215 

216 print(f"✅ Email sent to {user_email} (Alert ID: {alert_id})") 

217 

218 except Exception as e: 

219 print(f"❌ Error sending email notification: {e}") 

220 

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 

230 

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 

240 

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

263 

264 result = self.db.execute_query(query, (user_id, limit)) 

265 return result or [] 

266 

267 except Exception as e: 

268 print(f"❌ Error getting user alerts: {e}") 

269 return [] 

270 

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,)) 

276 

277 if result and len(result) > 0: 

278 return result[0]['count'] 

279 

280 return 0 

281 

282 except Exception as e: 

283 print(f"❌ Error getting unread count: {e}") 

284 return 0 

285 

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 

289 

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) 

297 

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 ) 

309 

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 ) 

321 

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 )