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:
@@ -0,0 +1,83 @@
|
||||
"""add notification tables
|
||||
|
||||
Revision ID: b09c3789b7e6
|
||||
Revises: 3266dd9d8111
|
||||
Create Date: 2026-03-19 06:16:46.817718
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b09c3789b7e6'
|
||||
down_revision: Union[str, None] = '3266dd9d8111'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('notification_configs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('account_id', sa.UUID(), nullable=False),
|
||||
sa.Column('channel', sa.String(length=20), nullable=False),
|
||||
sa.Column('webhook_url', sa.String(length=500), nullable=True),
|
||||
sa.Column('email_addresses', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('events_enabled', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.CheckConstraint("channel IN ('email', 'slack_webhook', 'teams_webhook')", name='ck_notification_configs_channel'),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_notification_configs_account_id'), 'notification_configs', ['account_id'], unique=False)
|
||||
|
||||
op.create_table('notifications',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('account_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('event', sa.String(length=50), nullable=False),
|
||||
sa.Column('title', sa.String(length=200), nullable=False),
|
||||
sa.Column('body', sa.String(length=500), nullable=True),
|
||||
sa.Column('link', sa.String(length=500), nullable=True),
|
||||
sa.Column('is_read', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_notifications_account_id'), 'notifications', ['account_id'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
|
||||
|
||||
op.create_table('notification_logs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('notification_config_id', sa.UUID(), nullable=False),
|
||||
sa.Column('event', sa.String(length=50), nullable=False),
|
||||
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('retry_count', sa.Integer(), nullable=False),
|
||||
sa.Column('max_retries', sa.Integer(), nullable=False),
|
||||
sa.Column('last_error', sa.String(length=1000), nullable=True),
|
||||
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('delivered_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint("status IN ('sent', 'failed', 'retrying', 'exhausted')", name='ck_notification_logs_status'),
|
||||
sa.ForeignKeyConstraint(['notification_config_id'], ['notification_configs.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_notification_logs_notification_config_id'), 'notification_logs', ['notification_config_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_notification_logs_notification_config_id'), table_name='notification_logs')
|
||||
op.drop_table('notification_logs')
|
||||
op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_account_id'), table_name='notifications')
|
||||
op.drop_table('notifications')
|
||||
op.drop_index(op.f('ix_notification_configs_account_id'), table_name='notification_configs')
|
||||
op.drop_table('notification_configs')
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.rate_limit import limiter
|
||||
from app.services.notification_service import notify
|
||||
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin, require_team_admin
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
@@ -262,6 +263,13 @@ async def review_proposal(
|
||||
elif data.action == "dismiss":
|
||||
proposal.status = "dismissed"
|
||||
|
||||
if data.action == "approve":
|
||||
await notify("proposal.approved", proposal.account_id, {
|
||||
"title": proposal.title,
|
||||
"reviewer_name": current_user.display_name if hasattr(current_user, 'display_name') else current_user.email,
|
||||
"link": "/review-queue",
|
||||
}, db, target_user_ids=[proposal.created_by_id] if proposal.created_by_id else None)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return FlowProposalDetail.model_validate(proposal)
|
||||
|
||||
255
backend/app/api/endpoints/notifications.py
Normal file
255
backend/app/api/endpoints/notifications.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Notification endpoints — config CRUD + in-app notification management.
|
||||
|
||||
Config CRUD (team_admin):
|
||||
GET /notifications/configs — List configs for account
|
||||
POST /notifications/configs — Create config
|
||||
PATCH /notifications/configs/{id} — Update config
|
||||
DELETE /notifications/configs/{id} — Delete config
|
||||
POST /notifications/configs/test — Test a config
|
||||
|
||||
In-app notifications (any authenticated user):
|
||||
GET /notifications — List notifications (paginated)
|
||||
GET /notifications/unread-count — Unread count
|
||||
PATCH /notifications/{id}/read — Mark one as read
|
||||
POST /notifications/mark-all-read — Mark all as read
|
||||
"""
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.rate_limit import limiter
|
||||
from app.api.deps import get_current_active_user, require_team_admin
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.notification_config import NotificationConfig
|
||||
from app.models.notification import Notification
|
||||
from app.schemas.notification import (
|
||||
NotificationConfigCreate,
|
||||
NotificationConfigUpdate,
|
||||
NotificationConfigResponse,
|
||||
NotificationResponse,
|
||||
UnreadCountResponse,
|
||||
NotificationTestRequest,
|
||||
NotificationTestResponse,
|
||||
)
|
||||
from app.services.notification_service import send_test_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config CRUD (team_admin required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/configs", response_model=list[NotificationConfigResponse])
|
||||
@limiter.limit("30/minute")
|
||||
async def list_configs(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(require_team_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List all notification configs for the current account."""
|
||||
result = await db.execute(
|
||||
select(NotificationConfig)
|
||||
.where(NotificationConfig.account_id == current_user.account_id)
|
||||
.order_by(NotificationConfig.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/configs", response_model=NotificationConfigResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("10/minute")
|
||||
async def create_config(
|
||||
request: Request,
|
||||
body: NotificationConfigCreate,
|
||||
current_user: Annotated[User, Depends(require_team_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Create a new notification config."""
|
||||
# Validate channel-specific requirements
|
||||
if body.channel in ("slack_webhook", "teams_webhook") and not body.webhook_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"webhook_url is required for {body.channel} channel",
|
||||
)
|
||||
if body.channel == "email" and not body.email_addresses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="email_addresses is required for email channel",
|
||||
)
|
||||
|
||||
config = NotificationConfig(
|
||||
account_id=current_user.account_id,
|
||||
channel=body.channel,
|
||||
webhook_url=body.webhook_url,
|
||||
email_addresses=body.email_addresses,
|
||||
events_enabled=body.events_enabled,
|
||||
)
|
||||
db.add(config)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@router.patch("/configs/{config_id}", response_model=NotificationConfigResponse)
|
||||
@limiter.limit("20/minute")
|
||||
async def update_config(
|
||||
request: Request,
|
||||
config_id: UUID,
|
||||
body: NotificationConfigUpdate,
|
||||
current_user: Annotated[User, Depends(require_team_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Update an existing notification config."""
|
||||
result = await db.execute(
|
||||
select(NotificationConfig)
|
||||
.where(NotificationConfig.id == config_id)
|
||||
.where(NotificationConfig.account_id == current_user.account_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(config, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@router.delete("/configs/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@limiter.limit("10/minute")
|
||||
async def delete_config(
|
||||
request: Request,
|
||||
config_id: UUID,
|
||||
current_user: Annotated[User, Depends(require_team_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Delete a notification config."""
|
||||
result = await db.execute(
|
||||
select(NotificationConfig)
|
||||
.where(NotificationConfig.id == config_id)
|
||||
.where(NotificationConfig.account_id == current_user.account_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
|
||||
|
||||
await db.delete(config)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/configs/test", response_model=NotificationTestResponse)
|
||||
@limiter.limit("5/minute")
|
||||
async def test_config(
|
||||
request: Request,
|
||||
body: NotificationTestRequest,
|
||||
current_user: Annotated[User, Depends(require_team_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Send a test notification through a config."""
|
||||
result = await db.execute(
|
||||
select(NotificationConfig)
|
||||
.where(NotificationConfig.id == body.config_id)
|
||||
.where(NotificationConfig.account_id == current_user.account_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
|
||||
|
||||
success, message = await send_test_notification(config)
|
||||
return NotificationTestResponse(success=success, message=message)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-app notifications (any authenticated user)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("", response_model=list[NotificationResponse])
|
||||
@limiter.limit("60/minute")
|
||||
async def list_notifications(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
):
|
||||
"""List notifications for the current user, unread first."""
|
||||
result = await db.execute(
|
||||
select(Notification)
|
||||
.where(Notification.user_id == current_user.id)
|
||||
.order_by(Notification.is_read.asc(), Notification.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
@limiter.limit("120/minute")
|
||||
async def unread_count(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get count of unread notifications for the current user."""
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(Notification.user_id == current_user.id)
|
||||
.where(Notification.is_read.is_(False))
|
||||
)
|
||||
count = result.scalar_one()
|
||||
return UnreadCountResponse(count=count)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=NotificationResponse)
|
||||
@limiter.limit("60/minute")
|
||||
async def mark_read(
|
||||
request: Request,
|
||||
notification_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Mark a single notification as read."""
|
||||
result = await db.execute(
|
||||
select(Notification)
|
||||
.where(Notification.id == notification_id)
|
||||
.where(Notification.user_id == current_user.id)
|
||||
)
|
||||
notification = result.scalar_one_or_none()
|
||||
if not notification:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
|
||||
|
||||
notification.is_read = True
|
||||
await db.commit()
|
||||
await db.refresh(notification)
|
||||
return notification
|
||||
|
||||
|
||||
@router.post("/mark-all-read", response_model=UnreadCountResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def mark_all_read(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Mark all notifications as read for the current user."""
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(Notification.user_id == current_user.id)
|
||||
.where(Notification.is_read.is_(False))
|
||||
.values(is_read=True)
|
||||
)
|
||||
await db.commit()
|
||||
return UnreadCountResponse(count=0)
|
||||
@@ -24,6 +24,7 @@ from app.api.endpoints import supporting_data
|
||||
from app.api.endpoints import ai_sessions
|
||||
from app.api.endpoints import flow_proposals
|
||||
from app.api.endpoints import flowpilot_analytics
|
||||
from app.api.endpoints import notifications
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -73,3 +74,4 @@ api_router.include_router(supporting_data.router)
|
||||
api_router.include_router(ai_sessions.router)
|
||||
api_router.include_router(flow_proposals.router)
|
||||
api_router.include_router(flowpilot_analytics.router)
|
||||
api_router.include_router(notifications.router)
|
||||
|
||||
@@ -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>"""
|
||||
|
||||
@@ -33,6 +33,7 @@ from app.core.rate_limit import limiter
|
||||
from app.api.router import api_router
|
||||
from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations
|
||||
from app.services.retention_cleanup import cleanup_expired_chats
|
||||
from app.services.notification_service import retry_failed_notifications
|
||||
from app.core.service_account import ensure_service_account
|
||||
|
||||
# Initialize logging configuration
|
||||
@@ -61,6 +62,14 @@ async def archive_stale_ai_sessions():
|
||||
logger.info(f"[archive] Archived {result.rowcount} stale AI chat sessions")
|
||||
|
||||
|
||||
async def _process_notification_retries():
|
||||
"""Retry failed notification deliveries."""
|
||||
async with async_session_maker() as db:
|
||||
retried = await retry_failed_notifications(db)
|
||||
if retried:
|
||||
logger.info("Retried %d failed notifications", retried)
|
||||
|
||||
|
||||
def _configure_seed_module(mod: object, api_url: str, email: str, password: str) -> None:
|
||||
"""Set globals on a seed script module."""
|
||||
mod.API_BASE_URL = api_url # type: ignore[attr-defined]
|
||||
@@ -201,6 +210,16 @@ async def lifespan(app: FastAPI):
|
||||
max_instances=1,
|
||||
)
|
||||
|
||||
# Notification retry (every minute)
|
||||
scheduler.add_job(
|
||||
_process_notification_retries,
|
||||
trigger="interval",
|
||||
minutes=1,
|
||||
id="notification_retry",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
)
|
||||
|
||||
# Auto-seed trees in background on PR environments
|
||||
seed_task = None
|
||||
if settings.SEED_ON_DEPLOY:
|
||||
|
||||
@@ -43,6 +43,9 @@ from .psa_post_log import PsaPostLog
|
||||
from .psa_member_mapping import PsaMemberMapping
|
||||
from .supporting_data import SessionSupportingData
|
||||
from .flow_proposal import FlowProposal
|
||||
from .notification_config import NotificationConfig
|
||||
from .notification_log import NotificationLog
|
||||
from .notification import Notification
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -100,4 +103,7 @@ __all__ = [
|
||||
"PsaMemberMapping",
|
||||
"SessionSupportingData",
|
||||
"FlowProposal",
|
||||
"NotificationConfig",
|
||||
"NotificationLog",
|
||||
"Notification",
|
||||
]
|
||||
|
||||
45
backend/app/models/notification.py
Normal file
45
backend/app/models/notification.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""In-app notification model."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
event: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
body: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
link: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
index=True,
|
||||
)
|
||||
|
||||
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
|
||||
60
backend/app/models/notification_config.py
Normal file
60
backend/app/models/notification_config.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Notification channel configuration per account.
|
||||
|
||||
Each account can have multiple notification configs (email, Slack webhook, Teams webhook).
|
||||
Each config specifies which events it receives.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, CheckConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
|
||||
|
||||
class NotificationConfig(Base):
|
||||
__tablename__ = "notification_configs"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"channel IN ('email', 'slack_webhook', 'teams_webhook')",
|
||||
name="ck_notification_configs_channel",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
channel: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
webhook_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
email_addresses: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
events_enabled: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, default=lambda: {
|
||||
"session.escalated": True,
|
||||
"session.high_priority": True,
|
||||
"proposal.pending": True,
|
||||
"proposal.approved": True,
|
||||
"knowledge_gap.detected": True,
|
||||
}
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id])
|
||||
52
backend/app/models/notification_log.py
Normal file
52
backend/app/models/notification_log.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Notification delivery log with retry tracking."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, CheckConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.notification_config import NotificationConfig
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
__tablename__ = "notification_logs"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('sent', 'failed', 'retrying', 'exhausted')",
|
||||
name="ck_notification_logs_status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
notification_config_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("notification_configs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
event: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="sent")
|
||||
retry_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
max_retries: Mapped[int] = mapped_column(Integer, default=3)
|
||||
last_error: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
|
||||
next_retry_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
delivered_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
config: Mapped[Optional["NotificationConfig"]] = relationship(
|
||||
"NotificationConfig", foreign_keys=[notification_config_id]
|
||||
)
|
||||
85
backend/app/schemas/notification.py
Normal file
85
backend/app/schemas/notification.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Pydantic schemas for notification system."""
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
VALID_EVENTS = {
|
||||
"session.escalated",
|
||||
"session.high_priority",
|
||||
"proposal.pending",
|
||||
"proposal.approved",
|
||||
"knowledge_gap.detected",
|
||||
}
|
||||
|
||||
|
||||
class NotificationConfigCreate(BaseModel):
|
||||
channel: str = Field(..., pattern="^(email|slack_webhook|teams_webhook)$")
|
||||
webhook_url: str | None = None
|
||||
email_addresses: list[str] | None = None
|
||||
events_enabled: dict[str, bool] = Field(
|
||||
default_factory=lambda: {e: True for e in VALID_EVENTS}
|
||||
)
|
||||
|
||||
@field_validator("events_enabled")
|
||||
@classmethod
|
||||
def validate_event_keys(cls, v: dict[str, bool]) -> dict[str, bool]:
|
||||
invalid = set(v) - VALID_EVENTS
|
||||
if invalid:
|
||||
raise ValueError(f"Unknown event keys: {invalid}")
|
||||
return v
|
||||
|
||||
|
||||
class NotificationConfigUpdate(BaseModel):
|
||||
webhook_url: str | None = None
|
||||
email_addresses: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
events_enabled: dict[str, bool] | None = None
|
||||
|
||||
@field_validator("events_enabled")
|
||||
@classmethod
|
||||
def validate_event_keys(cls, v: dict[str, bool] | None) -> dict[str, bool] | None:
|
||||
if v is not None:
|
||||
invalid = set(v) - VALID_EVENTS
|
||||
if invalid:
|
||||
raise ValueError(f"Unknown event keys: {invalid}")
|
||||
return v
|
||||
|
||||
|
||||
class NotificationConfigResponse(BaseModel):
|
||||
id: UUID
|
||||
channel: str
|
||||
webhook_url: str | None
|
||||
email_addresses: list[str] | None
|
||||
is_active: bool
|
||||
events_enabled: dict[str, bool]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
id: UUID
|
||||
event: str
|
||||
title: str
|
||||
body: str | None
|
||||
link: str | None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class NotificationTestRequest(BaseModel):
|
||||
config_id: UUID
|
||||
|
||||
|
||||
class NotificationTestResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.ai_provider import get_ai_provider
|
||||
from app.core.config import settings
|
||||
from app.services.notification_service import notify
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.ai_session_step import AISessionStep
|
||||
from app.schemas.ai_session import (
|
||||
@@ -497,6 +498,15 @@ async def escalate_session(
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Notify about escalation
|
||||
await notify("session.escalated", session.account_id, {
|
||||
"session_id": str(session_id),
|
||||
"engineer_name": session.user.display_name if session.user else "Unknown",
|
||||
"escalation_reason": request.escalation_reason,
|
||||
"problem_summary": session.problem_summary or "N/A",
|
||||
"link": f"/pilot/{session_id}",
|
||||
}, db, target_user_ids=[request.escalated_to_id] if request.escalated_to_id else None)
|
||||
|
||||
# Push documentation to PSA if ticket is linked
|
||||
psa_result = await _push_to_psa(session, user_id, db)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.ai_provider import get_ai_provider
|
||||
from app.core.config import settings
|
||||
from app.services.notification_service import notify
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.ai_session_step import AISessionStep
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
@@ -295,6 +296,7 @@ async def _auto_reinforce(session: AISession, db: AsyncSession) -> None:
|
||||
target_flow_id=session.matched_flow_id,
|
||||
)
|
||||
db.add(proposal)
|
||||
# auto_reinforced proposals don't need review — no notification
|
||||
logger.info("Auto-reinforced flow %s from session %s", session.matched_flow_id, session.id)
|
||||
|
||||
|
||||
@@ -355,6 +357,12 @@ async def _propose_new_flow(session: AISession, db: AsyncSession) -> None:
|
||||
status="pending",
|
||||
)
|
||||
db.add(proposal)
|
||||
await notify("proposal.pending", proposal.account_id, {
|
||||
"title": proposal.title,
|
||||
"proposal_type": proposal.proposal_type,
|
||||
"problem_domain": proposal.problem_domain or "General",
|
||||
"link": "/review-queue",
|
||||
}, db)
|
||||
logger.info("Created new_flow proposal for session %s: %s", session.id, title)
|
||||
|
||||
|
||||
@@ -431,6 +439,12 @@ async def _propose_enhancement(session: AISession, db: AsyncSession) -> None:
|
||||
status="pending",
|
||||
)
|
||||
db.add(proposal)
|
||||
await notify("proposal.pending", proposal.account_id, {
|
||||
"title": proposal.title,
|
||||
"proposal_type": proposal.proposal_type,
|
||||
"problem_domain": proposal.problem_domain or "General",
|
||||
"link": "/review-queue",
|
||||
}, db)
|
||||
logger.info(
|
||||
"Created enhancement proposal for flow %s from session %s: %s",
|
||||
session.matched_flow_id, session.id, title,
|
||||
|
||||
420
backend/app/services/notification_service.py
Normal file
420
backend/app/services/notification_service.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""Notification service — dispatches in-app + external notifications.
|
||||
|
||||
Entry point: `notify(event, account_id, payload, db)`.
|
||||
Retry engine: `retry_failed_notifications(db)` called by APScheduler.
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.email import EmailService
|
||||
from app.models.notification import Notification
|
||||
from app.models.notification_config import NotificationConfig
|
||||
from app.models.notification_log import NotificationLog
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Exponential backoff schedule (seconds): 30s, 2m, 10m
|
||||
_RETRY_DELAYS = [30, 120, 600]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def notify(
|
||||
event: str,
|
||||
account_id: uuid.UUID,
|
||||
payload: dict[str, Any],
|
||||
db: AsyncSession,
|
||||
target_user_ids: Optional[list[uuid.UUID]] = None,
|
||||
) -> None:
|
||||
"""Main entry point — create in-app notifications + route to external channels.
|
||||
|
||||
IMPORTANT: This function does NOT commit or rollback. The caller owns the transaction.
|
||||
In-app notifications are added to the session (flushed, not committed).
|
||||
External channel delivery is fire-and-forget — failures are logged, not raised.
|
||||
"""
|
||||
try:
|
||||
recipients = await _resolve_recipients(account_id, target_user_ids, db)
|
||||
|
||||
title = _build_notification_title(event, payload)
|
||||
body = _build_notification_body(event, payload)
|
||||
link = _build_notification_link(event, payload)
|
||||
|
||||
# Create in-app notification for each recipient
|
||||
for user in recipients:
|
||||
notification = Notification(
|
||||
account_id=account_id,
|
||||
user_id=user.id,
|
||||
event=event,
|
||||
title=title,
|
||||
body=body,
|
||||
link=link,
|
||||
)
|
||||
db.add(notification)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Route to active external channels (fire-and-forget per channel)
|
||||
configs = await _get_active_configs(account_id, event, db)
|
||||
for config in configs:
|
||||
try:
|
||||
await _deliver_to_channel(config, event, payload, db)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"External delivery failed for config=%s event=%s", config.id, event
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to process notification event=%s account=%s", event, account_id)
|
||||
|
||||
|
||||
async def retry_failed_notifications(db: AsyncSession) -> int:
|
||||
"""Retry failed notification deliveries. Called by APScheduler."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(NotificationLog)
|
||||
.where(NotificationLog.status == "retrying")
|
||||
.where(NotificationLog.next_retry_at <= now)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
if not logs:
|
||||
return 0
|
||||
|
||||
logger.info("Retrying %d failed notification deliveries", len(logs))
|
||||
|
||||
for log in logs:
|
||||
# Load the config for this log entry
|
||||
config_result = await db.execute(
|
||||
select(NotificationConfig).where(NotificationConfig.id == log.notification_config_id)
|
||||
)
|
||||
config = config_result.scalar_one_or_none()
|
||||
if not config or not config.is_active:
|
||||
log.status = "exhausted"
|
||||
log.last_error = "Config disabled or deleted"
|
||||
continue
|
||||
|
||||
try:
|
||||
await _attempt_delivery(config, log.event, log.payload)
|
||||
log.status = "sent"
|
||||
log.delivered_at = datetime.now(timezone.utc)
|
||||
log.last_error = None
|
||||
logger.info("Retry succeeded for log=%s event=%s", log.id, log.event)
|
||||
except Exception as exc:
|
||||
log.retry_count += 1
|
||||
log.last_error = str(exc)[:1000]
|
||||
|
||||
if log.retry_count >= log.max_retries:
|
||||
log.status = "exhausted"
|
||||
logger.warning(
|
||||
"Notification exhausted after %d retries: log=%s event=%s",
|
||||
log.retry_count, log.id, log.event,
|
||||
)
|
||||
else:
|
||||
delay = _RETRY_DELAYS[min(log.retry_count, len(_RETRY_DELAYS) - 1)]
|
||||
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=delay)
|
||||
logger.info(
|
||||
"Notification retry %d/%d scheduled in %ds: log=%s",
|
||||
log.retry_count, log.max_retries, delay, log.id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return len(logs)
|
||||
|
||||
|
||||
async def send_test_notification(
|
||||
config: NotificationConfig,
|
||||
) -> tuple[bool, str]:
|
||||
"""Send a test message through a channel config. Returns (success, message)."""
|
||||
event = "test"
|
||||
payload: dict[str, Any] = {}
|
||||
try:
|
||||
await _attempt_delivery(config, event, payload)
|
||||
return True, "Test notification sent successfully"
|
||||
except Exception as exc:
|
||||
return False, f"Delivery failed: {exc}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _get_active_configs(
|
||||
account_id: uuid.UUID,
|
||||
event: str,
|
||||
db: AsyncSession,
|
||||
) -> list[NotificationConfig]:
|
||||
"""Get configs where channel is active and event is enabled."""
|
||||
result = await db.execute(
|
||||
select(NotificationConfig)
|
||||
.where(NotificationConfig.account_id == account_id)
|
||||
.where(NotificationConfig.is_active.is_(True))
|
||||
)
|
||||
configs = result.scalars().all()
|
||||
# Filter to configs where this event is enabled
|
||||
return [
|
||||
c for c in configs
|
||||
if c.events_enabled and c.events_enabled.get(event, False)
|
||||
]
|
||||
|
||||
|
||||
async def _resolve_recipients(
|
||||
account_id: uuid.UUID,
|
||||
target_user_ids: Optional[list[uuid.UUID]],
|
||||
db: AsyncSession,
|
||||
) -> list[User]:
|
||||
"""Resolve notification recipients. Defaults to team admins + account owners + admins."""
|
||||
if target_user_ids:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id.in_(target_user_ids))
|
||||
.where(User.account_id == account_id) # enforce tenant boundary
|
||||
.where(User.is_active.is_(True))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
# Default: account owners, admins, and team admins
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.account_id == account_id)
|
||||
.where(User.is_active.is_(True))
|
||||
)
|
||||
users = result.scalars().all()
|
||||
return [
|
||||
u for u in users
|
||||
if u.account_role in ("owner", "admin") or u.is_team_admin
|
||||
]
|
||||
|
||||
|
||||
async def _deliver_to_channel(
|
||||
config: NotificationConfig,
|
||||
event: str,
|
||||
payload: dict[str, Any],
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Attempt delivery and create a NotificationLog entry."""
|
||||
log = NotificationLog(
|
||||
notification_config_id=config.id,
|
||||
event=event,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
try:
|
||||
await _attempt_delivery(config, event, payload)
|
||||
log.status = "sent"
|
||||
log.delivered_at = datetime.now(timezone.utc)
|
||||
except Exception as exc:
|
||||
log.status = "retrying"
|
||||
log.retry_count = 0
|
||||
log.last_error = str(exc)[:1000]
|
||||
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=_RETRY_DELAYS[0])
|
||||
logger.warning(
|
||||
"Notification delivery failed (will retry): config=%s event=%s error=%s",
|
||||
config.id, event, exc,
|
||||
)
|
||||
|
||||
db.add(log)
|
||||
|
||||
|
||||
async def _attempt_delivery(
|
||||
config: NotificationConfig,
|
||||
event: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Dispatch to the appropriate channel. Raises on failure."""
|
||||
if config.channel == "email":
|
||||
await _send_email(config, event, payload)
|
||||
elif config.channel == "slack_webhook":
|
||||
if not config.webhook_url:
|
||||
raise ValueError("Slack webhook URL not configured")
|
||||
await _send_slack_message(config.webhook_url, event, payload)
|
||||
elif config.channel == "teams_webhook":
|
||||
if not config.webhook_url:
|
||||
raise ValueError("Teams webhook URL not configured")
|
||||
await _send_teams_message(config.webhook_url, event, payload)
|
||||
else:
|
||||
raise ValueError(f"Unknown channel: {config.channel}")
|
||||
|
||||
|
||||
async def _send_email(
|
||||
config: NotificationConfig,
|
||||
event: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Send notification via email using EmailService."""
|
||||
title = _build_notification_title(event, payload)
|
||||
body = _build_notification_body(event, payload)
|
||||
link = _build_notification_link(event, payload)
|
||||
|
||||
full_link = None
|
||||
if link and settings.FRONTEND_URL:
|
||||
full_link = f"{settings.FRONTEND_URL.rstrip('/')}{link}"
|
||||
|
||||
recipients = config.email_addresses or []
|
||||
if not recipients:
|
||||
raise ValueError("No email addresses configured for email channel")
|
||||
|
||||
failures = []
|
||||
for email in recipients:
|
||||
success = await EmailService.send_notification_email(
|
||||
to_email=email,
|
||||
title=title,
|
||||
body=body,
|
||||
link_url=full_link,
|
||||
)
|
||||
if not success:
|
||||
failures.append(email)
|
||||
if failures:
|
||||
raise RuntimeError(f"Failed to send notification email to: {', '.join(failures)}")
|
||||
|
||||
|
||||
async def _send_slack_message(
|
||||
webhook_url: str,
|
||||
event: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""POST notification to Slack incoming webhook."""
|
||||
title = _build_notification_title(event, payload)
|
||||
body = _build_notification_body(event, payload)
|
||||
link = _build_notification_link(event, payload)
|
||||
|
||||
blocks: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {"type": "plain_text", "text": f"\U0001f514 {title}", "emoji": True},
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": body},
|
||||
},
|
||||
]
|
||||
|
||||
if link and settings.FRONTEND_URL:
|
||||
full_url = f"{settings.FRONTEND_URL.rstrip('/')}{link}"
|
||||
blocks.append({
|
||||
"type": "actions",
|
||||
"elements": [{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Open in ResolutionFlow", "emoji": True},
|
||||
"url": full_url,
|
||||
}],
|
||||
})
|
||||
|
||||
slack_payload = {"blocks": blocks}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(webhook_url, json=slack_payload)
|
||||
if resp.status_code != 200 or resp.text.strip() != "ok":
|
||||
raise RuntimeError(
|
||||
f"Slack webhook failed (status={resp.status_code}): {resp.text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
async def _send_teams_message(
|
||||
webhook_url: str,
|
||||
event: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""POST notification to Microsoft Teams incoming webhook (Adaptive Card)."""
|
||||
title = _build_notification_title(event, payload)
|
||||
body = _build_notification_body(event, payload)
|
||||
link = _build_notification_link(event, payload)
|
||||
|
||||
card_body: list[dict[str, Any]] = [
|
||||
{"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Medium"},
|
||||
{"type": "TextBlock", "text": body, "wrap": True},
|
||||
]
|
||||
|
||||
actions: list[dict[str, Any]] = []
|
||||
if link and settings.FRONTEND_URL:
|
||||
full_url = f"{settings.FRONTEND_URL.rstrip('/')}{link}"
|
||||
actions.append({
|
||||
"type": "Action.OpenUrl",
|
||||
"title": "Open in ResolutionFlow",
|
||||
"url": full_url,
|
||||
})
|
||||
|
||||
teams_payload = {
|
||||
"type": "message",
|
||||
"attachments": [{
|
||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||
"content": {
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.4",
|
||||
"body": card_body,
|
||||
"actions": actions,
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(webhook_url, json=teams_payload)
|
||||
if resp.status_code not in (200, 202):
|
||||
raise RuntimeError(
|
||||
f"Teams webhook returned {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_notification_title(event: str, payload: dict[str, Any]) -> str:
|
||||
"""Human-readable title per event type."""
|
||||
titles = {
|
||||
"session.escalated": "Session escalated by {engineer_name}",
|
||||
"session.high_priority": "High-priority session started: {ticket_number}",
|
||||
"proposal.pending": "New flow proposal: {title}",
|
||||
"proposal.approved": "Flow proposal approved: {title}",
|
||||
"knowledge_gap.detected": "Knowledge gap detected: {gap_type}",
|
||||
"test": "Test Notification from ResolutionFlow",
|
||||
}
|
||||
template = titles.get(event, f"Notification: {event}")
|
||||
try:
|
||||
return template.format(**payload)
|
||||
except KeyError:
|
||||
return template
|
||||
|
||||
|
||||
def _build_notification_body(event: str, payload: dict[str, Any]) -> str:
|
||||
"""Body text per event type."""
|
||||
bodies = {
|
||||
"session.escalated": "Engineer {engineer_name} has escalated a FlowPilot session and needs assistance.",
|
||||
"session.high_priority": "A new high-priority troubleshooting session has been started for ticket {ticket_number}.",
|
||||
"proposal.pending": "A new flow proposal \"{title}\" is awaiting review in the review queue.",
|
||||
"proposal.approved": "The flow proposal \"{title}\" has been approved and is ready for use.",
|
||||
"knowledge_gap.detected": "A {gap_type} knowledge gap has been identified. Review recommended.",
|
||||
"test": "This is a test notification to verify your notification channel is working correctly.",
|
||||
}
|
||||
template = bodies.get(event, f"Event: {event}")
|
||||
try:
|
||||
return template.format(**payload)
|
||||
except KeyError:
|
||||
return template
|
||||
|
||||
|
||||
def _build_notification_link(event: str, payload: dict[str, Any]) -> Optional[str]:
|
||||
"""In-app link per event type. Returns path (no host)."""
|
||||
links: dict[str, str] = {
|
||||
"session.escalated": "/pilot/{session_id}",
|
||||
"session.high_priority": "/pilot/{session_id}",
|
||||
"proposal.pending": "/review-queue",
|
||||
"proposal.approved": "/review-queue",
|
||||
"knowledge_gap.detected": "/analytics/flowpilot",
|
||||
}
|
||||
template = links.get(event)
|
||||
if template is None:
|
||||
return None
|
||||
try:
|
||||
return template.format(**payload)
|
||||
except KeyError:
|
||||
return template
|
||||
1570
docs/plans/2026-03-19-phase4-slice2-notifications.md
Normal file
1570
docs/plans/2026-03-19-phase4-slice2-notifications.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,3 +27,4 @@ export { sessionToFlowApi } from './sessionToFlow'
|
||||
export { aiSessionsApi } from './aiSessions'
|
||||
export { flowProposalsApi } from './flowProposals'
|
||||
export { flowpilotAnalyticsApi } from './flowpilotAnalytics'
|
||||
export { notificationsApi } from './notifications'
|
||||
|
||||
57
frontend/src/api/notifications.ts
Normal file
57
frontend/src/api/notifications.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
NotificationConfig,
|
||||
NotificationConfigCreate,
|
||||
NotificationConfigUpdate,
|
||||
AppNotification,
|
||||
UnreadCount,
|
||||
} from '@/types/notification'
|
||||
|
||||
export const notificationsApi = {
|
||||
async listConfigs(): Promise<NotificationConfig[]> {
|
||||
const response = await apiClient.get<NotificationConfig[]>('/notifications/configs')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createConfig(data: NotificationConfigCreate): Promise<NotificationConfig> {
|
||||
const response = await apiClient.post<NotificationConfig>('/notifications/configs', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateConfig(id: string, data: NotificationConfigUpdate): Promise<NotificationConfig> {
|
||||
const response = await apiClient.patch<NotificationConfig>(`/notifications/configs/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteConfig(id: string): Promise<void> {
|
||||
await apiClient.delete(`/notifications/configs/${id}`)
|
||||
},
|
||||
|
||||
async testConfig(configId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.post<{ success: boolean; message: string }>(
|
||||
'/notifications/configs/test',
|
||||
{ config_id: configId }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async list(params?: { skip?: number; limit?: number }): Promise<AppNotification[]> {
|
||||
const response = await apiClient.get<AppNotification[]>('/notifications', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async unreadCount(): Promise<number> {
|
||||
const response = await apiClient.get<UnreadCount>('/notifications/unread-count')
|
||||
return response.data.count
|
||||
},
|
||||
|
||||
async markRead(id: string): Promise<void> {
|
||||
await apiClient.patch(`/notifications/${id}/read`)
|
||||
},
|
||||
|
||||
async markAllRead(): Promise<void> {
|
||||
await apiClient.post('/notifications/mark-all-read')
|
||||
},
|
||||
}
|
||||
|
||||
export default notificationsApi
|
||||
440
frontend/src/components/account/NotificationSettings.tsx
Normal file
440
frontend/src/components/account/NotificationSettings.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Bell,
|
||||
Mail,
|
||||
Hash,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Send,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import { notificationsApi } from '@/api/notifications'
|
||||
import type { NotificationConfig, NotificationConfigCreate, NotificationConfigUpdate } from '@/types/notification'
|
||||
import { NOTIFICATION_EVENTS, CHANNEL_LABELS } from '@/types/notification'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
|
||||
type ChannelType = 'email' | 'slack_webhook' | 'teams_webhook'
|
||||
|
||||
const CHANNEL_ICONS: Record<ChannelType, React.ElementType> = {
|
||||
email: Mail,
|
||||
slack_webhook: Hash,
|
||||
teams_webhook: MessageSquare,
|
||||
}
|
||||
|
||||
function maskWebhookUrl(url: string): string {
|
||||
if (url.length <= 8) return url
|
||||
return '\u2022'.repeat(12) + url.slice(-8)
|
||||
}
|
||||
|
||||
export function NotificationSettings() {
|
||||
const [configs, setConfigs] = useState<NotificationConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [addingChannel, setAddingChannel] = useState<ChannelType | null>(null)
|
||||
const [testingId, setTestingId] = useState<string | null>(null)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Add form state
|
||||
const [newWebhookUrl, setNewWebhookUrl] = useState('')
|
||||
const [newEmails, setNewEmails] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs()
|
||||
}, [])
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showDropdown])
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const data = await notificationsApi.listConfigs()
|
||||
setConfigs(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load notification configs:', err)
|
||||
toast.error('Failed to load notification settings')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddChannel = (channel: ChannelType) => {
|
||||
setAddingChannel(channel)
|
||||
setShowDropdown(false)
|
||||
setNewWebhookUrl('')
|
||||
setNewEmails('')
|
||||
}
|
||||
|
||||
const handleSaveNew = async () => {
|
||||
if (!addingChannel) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const payload: NotificationConfigCreate = { channel: addingChannel }
|
||||
if (addingChannel === 'email') {
|
||||
const emails = newEmails.split(',').map(e => e.trim()).filter(Boolean)
|
||||
if (emails.length === 0) {
|
||||
toast.error('Please enter at least one email address')
|
||||
setIsSaving(false)
|
||||
return
|
||||
}
|
||||
payload.email_addresses = emails
|
||||
} else {
|
||||
if (!newWebhookUrl.trim()) {
|
||||
toast.error('Please enter a webhook URL')
|
||||
setIsSaving(false)
|
||||
return
|
||||
}
|
||||
payload.webhook_url = newWebhookUrl.trim()
|
||||
}
|
||||
await notificationsApi.createConfig(payload)
|
||||
await loadConfigs()
|
||||
setAddingChannel(null)
|
||||
setNewWebhookUrl('')
|
||||
setNewEmails('')
|
||||
toast.success(`${CHANNEL_LABELS[addingChannel]} channel added`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create notification config:', err)
|
||||
toast.error('Failed to add channel')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (config: NotificationConfig) => {
|
||||
try {
|
||||
const update: NotificationConfigUpdate = { is_active: !config.is_active }
|
||||
await notificationsApi.updateConfig(config.id, update)
|
||||
setConfigs(prev => prev.map(c => c.id === config.id ? { ...c, is_active: !c.is_active } : c))
|
||||
toast.success(config.is_active ? 'Channel disabled' : 'Channel enabled')
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle config:', err)
|
||||
toast.error('Failed to update channel')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEvent = async (config: NotificationConfig, eventKey: string) => {
|
||||
const updated = { ...config.events_enabled, [eventKey]: !config.events_enabled[eventKey] }
|
||||
try {
|
||||
await notificationsApi.updateConfig(config.id, { events_enabled: updated })
|
||||
setConfigs(prev => prev.map(c => c.id === config.id ? { ...c, events_enabled: updated } : c))
|
||||
} catch (err) {
|
||||
console.error('Failed to update events:', err)
|
||||
toast.error('Failed to update event settings')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async (configId: string) => {
|
||||
setTestingId(configId)
|
||||
try {
|
||||
const result = await notificationsApi.testConfig(configId)
|
||||
if (result.success) {
|
||||
toast.success(result.message || 'Test notification sent')
|
||||
} else {
|
||||
toast.error(result.message || 'Test failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to test config:', err)
|
||||
toast.error('Test notification failed')
|
||||
} finally {
|
||||
setTestingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (configId: string) => {
|
||||
try {
|
||||
await notificationsApi.deleteConfig(configId)
|
||||
setConfigs(prev => prev.filter(c => c.id !== configId))
|
||||
toast.success('Channel removed')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete config:', err)
|
||||
toast.error('Failed to remove channel')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Section header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="h-6 w-6 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold font-heading text-foreground">Notifications</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)] transition-all'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Channel
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-1 z-20 w-48 rounded-xl border border-border bg-card shadow-xl">
|
||||
{(Object.entries(CHANNEL_LABELS) as [ChannelType, string][]).map(([key, label]) => {
|
||||
const Icon = CHANNEL_ICONS[key]
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleAddChannel(key)}
|
||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-foreground hover:bg-[rgba(255,255,255,0.04)] first:rounded-t-xl last:rounded-b-xl transition-colors"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && configs.length === 0 && !addingChannel && (
|
||||
<div className="glass-card-static p-6 text-center">
|
||||
<Bell className="mx-auto h-8 w-8 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No notification channels configured. Add a channel to receive alerts for session events.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel list */}
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{configs.map(config => {
|
||||
const Icon = CHANNEL_ICONS[config.channel]
|
||||
return (
|
||||
<div key={config.id} className="glass-card-static p-5">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{CHANNEL_LABELS[config.channel]}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-2 w-2 rounded-full',
|
||||
config.is_active ? 'bg-emerald-400' : 'bg-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-xs font-label',
|
||||
config.is_active ? 'text-emerald-400' : 'text-muted-foreground'
|
||||
)}>
|
||||
{config.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Config details */}
|
||||
<div className="mb-4">
|
||||
{config.webhook_url && (
|
||||
<div>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Webhook URL
|
||||
</span>
|
||||
<p className="mt-0.5 text-sm text-foreground font-mono">
|
||||
{maskWebhookUrl(config.webhook_url)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{config.email_addresses && config.email_addresses.length > 0 && (
|
||||
<div>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Email Addresses
|
||||
</span>
|
||||
<p className="mt-0.5 text-sm text-foreground">
|
||||
{config.email_addresses.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event toggles */}
|
||||
<div className="mb-4">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Events
|
||||
</span>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
{Object.entries(NOTIFICATION_EVENTS).map(([eventKey, eventLabel]) => (
|
||||
<label
|
||||
key={eventKey}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.events_enabled[eventKey] ?? false}
|
||||
onChange={() => handleToggleEvent(config, eventKey)}
|
||||
className="h-3.5 w-3.5 rounded border-border bg-card text-primary focus:ring-primary/30 focus:ring-offset-0 cursor-pointer accent-[#06b6d4]"
|
||||
/>
|
||||
<span className="text-sm text-foreground">{eventLabel}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-border">
|
||||
<button
|
||||
onClick={() => handleToggleActive(config)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-[10px] px-3 py-1.5 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)] transition-all'
|
||||
)}
|
||||
>
|
||||
{config.is_active ? (
|
||||
<ToggleRight className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{config.is_active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleTest(config.id)}
|
||||
disabled={testingId === config.id}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-[10px] px-3 py-1.5 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)] transition-all',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{testingId === config.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
Test
|
||||
</button>
|
||||
|
||||
{confirmDeleteId === config.id ? (
|
||||
<button
|
||||
onClick={() => { handleDelete(config.id); setConfirmDeleteId(null) }}
|
||||
className="ml-auto inline-flex items-center gap-1.5 text-sm text-red-400 hover:text-red-300 transition-colors font-medium"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Confirm Remove
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(config.id)}
|
||||
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Inline add form */}
|
||||
{addingChannel && (
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{(() => {
|
||||
const Icon = CHANNEL_ICONS[addingChannel]
|
||||
return <Icon className="h-5 w-5 text-muted-foreground" />
|
||||
})()}
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Add {CHANNEL_LABELS[addingChannel]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{addingChannel === 'email' ? (
|
||||
<div>
|
||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Email Addresses
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={newEmails}
|
||||
onChange={(e) => setNewEmails(e.target.value)}
|
||||
placeholder="user@example.com, team@example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Separate multiple addresses with commas
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Webhook URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={newWebhookUrl}
|
||||
onChange={(e) => setNewWebhookUrl(e.target.value)}
|
||||
placeholder={
|
||||
addingChannel === 'slack_webhook'
|
||||
? 'https://hooks.slack.com/services/...'
|
||||
: 'https://outlook.office.com/webhook/...'
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
onClick={handleSaveNew}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-[10px] px-5 py-2.5 text-sm font-semibold',
|
||||
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 active:scale-[0.97] transition-all',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSaving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddingChannel(null)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationSettings
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Bell, CheckCircle, Clock } from 'lucide-react'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { Session } from '@/types/session'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import {
|
||||
Bell,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { notificationsApi } from '@/api/notifications'
|
||||
import type { AppNotification } from '@/types/notification'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000)
|
||||
@@ -12,23 +19,48 @@ function timeAgo(dateStr: string): string {
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
function EventIcon({ event }: { event: string }) {
|
||||
switch (event) {
|
||||
case 'session.escalated':
|
||||
return <AlertTriangle size={16} className="text-amber-400" />
|
||||
case 'session.high_priority':
|
||||
return <AlertCircle size={16} className="text-rose-500" />
|
||||
case 'proposal.pending':
|
||||
return <FileText size={16} className="text-primary" />
|
||||
case 'proposal.approved':
|
||||
return <CheckCircle size={16} className="text-emerald-400" />
|
||||
case 'knowledge_gap.detected':
|
||||
return <TrendingUp size={16} className="text-amber-400" />
|
||||
default:
|
||||
return <Bell size={16} className="text-muted-foreground" />
|
||||
}
|
||||
}
|
||||
|
||||
export function NotificationsPanel() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [hasNew, setHasNew] = useState(false)
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Poll unread count every 30 seconds
|
||||
useEffect(() => {
|
||||
sessionsApi.list({ size: 8 })
|
||||
.then(data => {
|
||||
setSessions(data)
|
||||
// Mark as "new" if any session was updated in the last hour
|
||||
const oneHourAgo = Date.now() - 3600000
|
||||
setHasNew(data.some(s => s.started_at && new Date(s.started_at).getTime() > oneHourAgo))
|
||||
})
|
||||
.catch(() => {})
|
||||
const fetchCount = () => {
|
||||
notificationsApi.unreadCount().then(setUnreadCount).catch(() => {})
|
||||
}
|
||||
fetchCount()
|
||||
const interval = setInterval(fetchCount, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Fetch full list when dropdown opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
notificationsApi.list({ limit: 20 }).then(setNotifications).catch(() => {})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
@@ -37,67 +69,112 @@ export function NotificationsPanel() {
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const handleMarkAllRead = useCallback(async () => {
|
||||
try {
|
||||
await notificationsApi.markAllRead()
|
||||
setUnreadCount(0)
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })))
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNotificationClick = useCallback(async (notification: AppNotification) => {
|
||||
try {
|
||||
if (!notification.is_read) {
|
||||
await notificationsApi.markRead(notification.id)
|
||||
setUnreadCount(prev => Math.max(0, prev - 1))
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === notification.id ? { ...n, is_read: true } : n))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
setOpen(false)
|
||||
if (notification.link) {
|
||||
navigate(notification.link)
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => { setOpen(!open); setHasNew(false) }}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="relative rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
|
||||
title="Notifications"
|
||||
aria-label={unreadCount > 0 ? `Notifications — ${unreadCount} unread` : 'Notifications'}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Bell size={18} />
|
||||
{hasNew && (
|
||||
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-primary" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex min-w-[1.125rem] h-[1.125rem] items-center justify-center rounded-full bg-rose-500 text-white text-[0.625rem] font-semibold leading-none px-1">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-50 mt-2 w-80 rounded-xl border border-border bg-card shadow-xl animate-scale-in">
|
||||
<div
|
||||
className="absolute right-0 z-50 mt-2 w-80 rounded-xl border border-border bg-card shadow-xl animate-scale-in"
|
||||
role="dialog"
|
||||
aria-label="Notifications"
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false) }}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-heading font-semibold text-foreground">Activity</h3>
|
||||
<Link
|
||||
to="/sessions"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[0.6875rem] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View All
|
||||
</Link>
|
||||
<h3 className="text-sm font-heading font-semibold text-foreground">Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No recent activity
|
||||
No notifications yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-72 overflow-y-auto divide-y divide-border">
|
||||
{sessions.map(session => (
|
||||
<Link
|
||||
key={session.id}
|
||||
to={`/sessions/${session.id}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-accent/50 transition-colors"
|
||||
{notifications.map(notification => (
|
||||
<button
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={`flex w-full items-start gap-3 px-4 py-3 text-left hover:bg-accent/50 transition-colors ${
|
||||
!notification.is_read ? 'bg-primary/5' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
{session.completed_at ? (
|
||||
<CheckCircle size={16} className="text-emerald-400" />
|
||||
) : (
|
||||
<Clock size={16} className="text-amber-400" />
|
||||
)}
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<EventIcon event={notification.event} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-foreground truncate">
|
||||
{session.tree_snapshot?.name || 'Session'}
|
||||
</p>
|
||||
<p className="text-[0.6875rem] text-muted-foreground">
|
||||
{session.completed_at
|
||||
? `Completed ${timeAgo(session.completed_at)}`
|
||||
: session.started_at ? `Started ${timeAgo(session.started_at)}` : 'Not started'}
|
||||
{session.client_name && ` · ${session.client_name}`}
|
||||
<p className="text-sm text-foreground truncate">{notification.title}</p>
|
||||
{notification.body && (
|
||||
<p className="text-[0.6875rem] text-muted-foreground truncate">
|
||||
{notification.body}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[0.6875rem] text-muted-foreground/60 mt-0.5">
|
||||
{timeAgo(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border px-4 py-2.5 text-center">
|
||||
<Link
|
||||
to="/account/integrations"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Notification settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plug, CheckCircle2, AlertCircle, Loader2, Pencil, Trash2, Shield, History, Ticket, Users, Zap, Save } from 'lucide-react'
|
||||
import { Plug, CheckCircle2, AlertCircle, Loader2, Pencil, Trash2, Shield, History, Ticket, Users, Zap, Save, Bell } from 'lucide-react'
|
||||
import { NotificationSettings } from '@/components/account/NotificationSettings'
|
||||
import { analytics } from '@/lib/analytics'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { IntegrationIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||
@@ -41,7 +42,7 @@ const emptyForm: ConnectionForm = {
|
||||
private_key: '',
|
||||
}
|
||||
|
||||
type Tab = 'connection' | 'member-mapping' | 'post-history' | 'flowpilot-settings'
|
||||
type Tab = 'connection' | 'member-mapping' | 'post-history' | 'flowpilot-settings' | 'notifications'
|
||||
|
||||
export function IntegrationsPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('connection')
|
||||
@@ -237,6 +238,7 @@ export function IntegrationsPage() {
|
||||
{ id: 'member-mapping' as Tab, label: 'Member Mapping', icon: Users },
|
||||
{ id: 'post-history' as Tab, label: 'Post History', icon: History },
|
||||
{ id: 'flowpilot-settings' as Tab, label: 'FlowPilot', icon: Zap },
|
||||
{ id: 'notifications' as Tab, label: 'Notifications', icon: Bell },
|
||||
]).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
@@ -555,6 +557,11 @@ export function IntegrationsPage() {
|
||||
{activeTab === 'flowpilot-settings' && (
|
||||
<FlowPilotSettingsTab connection={connection} />
|
||||
)}
|
||||
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && (
|
||||
<NotificationSettings />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -93,3 +93,4 @@ export type {
|
||||
|
||||
export * from './scripts'
|
||||
export * from './integrations'
|
||||
export * from './notification'
|
||||
|
||||
52
frontend/src/types/notification.ts
Normal file
52
frontend/src/types/notification.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface NotificationConfig {
|
||||
id: string
|
||||
channel: 'email' | 'slack_webhook' | 'teams_webhook'
|
||||
webhook_url: string | null
|
||||
email_addresses: string[] | null
|
||||
is_active: boolean
|
||||
events_enabled: Record<string, boolean>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface NotificationConfigCreate {
|
||||
channel: 'email' | 'slack_webhook' | 'teams_webhook'
|
||||
webhook_url?: string
|
||||
email_addresses?: string[]
|
||||
events_enabled?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface NotificationConfigUpdate {
|
||||
webhook_url?: string
|
||||
email_addresses?: string[]
|
||||
is_active?: boolean
|
||||
events_enabled?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface AppNotification {
|
||||
id: string
|
||||
event: string
|
||||
title: string
|
||||
body: string | null
|
||||
link: string | null
|
||||
is_read: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UnreadCount {
|
||||
count: number
|
||||
}
|
||||
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
'session.escalated': 'Session Escalated',
|
||||
'session.high_priority': 'High Priority Session',
|
||||
'proposal.pending': 'New Flow Proposal',
|
||||
'proposal.approved': 'Proposal Approved',
|
||||
'knowledge_gap.detected': 'Knowledge Gap Detected',
|
||||
} as const
|
||||
|
||||
export const CHANNEL_LABELS = {
|
||||
email: 'Email',
|
||||
slack_webhook: 'Slack',
|
||||
teams_webhook: 'Microsoft Teams',
|
||||
} as const
|
||||
Reference in New Issue
Block a user