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:
2026-03-19 12:37:54 +00:00
parent a8999adef3
commit 0f750e63e0
22 changed files with 3402 additions and 53 deletions

View File

@@ -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')

View File

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

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

View File

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

View File

@@ -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;">
&mdash; ResolutionFlow
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""

View File

@@ -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:

View File

@@ -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",
]

View 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])

View 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])

View 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]
)

View 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

View File

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

View File

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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -27,3 +27,4 @@ export { sessionToFlowApi } from './sessionToFlow'
export { aiSessionsApi } from './aiSessions'
export { flowProposalsApi } from './flowProposals'
export { flowpilotAnalyticsApi } from './flowpilotAnalytics'
export { notificationsApi } from './notifications'

View 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

View 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

View File

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

View File

@@ -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>
</>
)

View File

@@ -93,3 +93,4 @@ export type {
export * from './scripts'
export * from './integrations'
export * from './notification'

View 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