feat(notifications): add Phase 4 Slice 2 — multi-channel notification system
Full notification infrastructure with in-app, email, Slack, and Teams channels: Backend: - NotificationConfig, NotificationLog, Notification models + migration - Notification service with event routing, channel delivery, retry logic - 9 API endpoints (config CRUD + in-app notifications) - APScheduler retry job with exponential backoff (30s, 2m, 10m) - Wired into escalation, proposal approval, and knowledge flywheel - Pydantic event key validation, cross-tenant protection on recipients Frontend: - TypeScript types + API client for all notification endpoints - NotificationsPanel: bell icon with unread badge, dropdown, mark-read - NotificationSettings: channel config, event toggles, test, delete confirm - Notifications tab on IntegrationsPage - ARIA attributes, Escape handler, settings link on panel Review fixes (13 issues resolved): - notify() no longer commits/rolls back caller's transaction (critical) - retry_failed_notifications returns count instead of None (critical) - NotificationSettings moved inside dedicated tab (critical) - target_user_ids scoped by account_id (security) - Email loop collects all failures before raising - Slack webhook validates response body - events_enabled rejects unknown event keys - link column widened to String(500) - Dead code removed from _auto_reinforce - Delete confirmation, ARIA, Escape key support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -484,6 +484,45 @@ class EmailService:
|
||||
logger.exception("Failed to send beta signup notification for %s", signup_email)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_notification_email(
|
||||
to_email: str,
|
||||
title: str,
|
||||
body: str,
|
||||
link_url: str | None = None,
|
||||
) -> bool:
|
||||
"""Send a notification email. Fire-and-forget."""
|
||||
if not settings.email_enabled:
|
||||
logger.warning("Email not sent — RESEND_API_KEY not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import resend
|
||||
|
||||
resend.api_key = settings.RESEND_API_KEY
|
||||
|
||||
subject = f"[ResolutionFlow] {title}"
|
||||
html = _render_notification_html(
|
||||
title=title,
|
||||
body=body,
|
||||
link_url=link_url,
|
||||
)
|
||||
|
||||
resend.Emails.send(
|
||||
{
|
||||
"from": settings.FROM_EMAIL,
|
||||
"to": [to_email],
|
||||
"subject": subject,
|
||||
"html": html,
|
||||
}
|
||||
)
|
||||
logger.info("Notification email sent to %s: %s", to_email, title)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to send notification email to %s", to_email)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_survey_invite_email(
|
||||
to_email: str,
|
||||
@@ -856,3 +895,49 @@ def _render_feedback_confirmation_html(
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _render_notification_html(
|
||||
title: str,
|
||||
body: str,
|
||||
link_url: str | None = None,
|
||||
) -> str:
|
||||
import html as html_mod
|
||||
|
||||
safe_title = html_mod.escape(title)
|
||||
safe_body = html_mod.escape(body)
|
||||
|
||||
link_section = ""
|
||||
if link_url:
|
||||
link_section = f"""
|
||||
<tr><td style="padding:0 40px 32px;text-align:center;">
|
||||
<a href="{link_url}" style="display:inline-block;background:linear-gradient(135deg,#06b6d4,#22d3ee);color:#101114;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:10px;">
|
||||
View in ResolutionFlow
|
||||
</a>
|
||||
</td></tr>"""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
|
||||
<tr><td style="padding:40px 40px 24px;text-align:center;">
|
||||
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 12px;">
|
||||
<h2 style="margin:0;color:#f8fafc;font-size:18px;font-weight:600;">{safe_title}</h2>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 24px;">
|
||||
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">{safe_body}</p>
|
||||
</td></tr>
|
||||
{link_section}
|
||||
<tr><td style="padding:0 40px 32px;">
|
||||
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
|
||||
— ResolutionFlow
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
Reference in New Issue
Block a user