From 0f750e63e0ecd8259b9f67409ecc8714dd31ccfe Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Mar 2026 12:37:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(notifications):=20add=20Phase=204=20Slice?= =?UTF-8?q?=202=20=E2=80=94=20multi-channel=20notification=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../b09c3789b7e6_add_notification_tables.py | 83 + backend/app/api/endpoints/flow_proposals.py | 8 + backend/app/api/endpoints/notifications.py | 255 +++ backend/app/api/router.py | 2 + backend/app/core/email.py | 85 + backend/app/main.py | 19 + backend/app/models/__init__.py | 6 + backend/app/models/notification.py | 45 + backend/app/models/notification_config.py | 60 + backend/app/models/notification_log.py | 52 + backend/app/schemas/notification.py | 85 + backend/app/services/flowpilot_engine.py | 10 + backend/app/services/knowledge_flywheel.py | 14 + backend/app/services/notification_service.py | 420 +++++ .../2026-03-19-phase4-slice2-notifications.md | 1570 +++++++++++++++++ frontend/src/api/index.ts | 1 + frontend/src/api/notifications.ts | 57 + .../account/NotificationSettings.tsx | 440 +++++ .../components/layout/NotificationsPanel.tsx | 179 +- .../src/pages/account/IntegrationsPage.tsx | 11 +- frontend/src/types/index.ts | 1 + frontend/src/types/notification.ts | 52 + 22 files changed, 3402 insertions(+), 53 deletions(-) create mode 100644 backend/alembic/versions/b09c3789b7e6_add_notification_tables.py create mode 100644 backend/app/api/endpoints/notifications.py create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/models/notification_config.py create mode 100644 backend/app/models/notification_log.py create mode 100644 backend/app/schemas/notification.py create mode 100644 backend/app/services/notification_service.py create mode 100644 docs/plans/2026-03-19-phase4-slice2-notifications.md create mode 100644 frontend/src/api/notifications.ts create mode 100644 frontend/src/components/account/NotificationSettings.tsx create mode 100644 frontend/src/types/notification.ts diff --git a/backend/alembic/versions/b09c3789b7e6_add_notification_tables.py b/backend/alembic/versions/b09c3789b7e6_add_notification_tables.py new file mode 100644 index 00000000..464545db --- /dev/null +++ b/backend/alembic/versions/b09c3789b7e6_add_notification_tables.py @@ -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') diff --git a/backend/app/api/endpoints/flow_proposals.py b/backend/app/api/endpoints/flow_proposals.py index 6a41b12f..226dae92 100644 --- a/backend/app/api/endpoints/flow_proposals.py +++ b/backend/app/api/endpoints/flow_proposals.py @@ -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) diff --git a/backend/app/api/endpoints/notifications.py b/backend/app/api/endpoints/notifications.py new file mode 100644 index 00000000..ba27f2b3 --- /dev/null +++ b/backend/app/api/endpoints/notifications.py @@ -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) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 38ff2b56..45403da7 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/core/email.py b/backend/app/core/email.py index 24c13dcb..313d5db0 100644 --- a/backend/app/core/email.py +++ b/backend/app/core/email.py @@ -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( """ + + +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""" + + + View in ResolutionFlow + + """ + + return f""" + + + + +
+ + + + + {link_section} + +
+

ResolutionFlow

+
+

{safe_title}

+
+

{safe_body}

+
+

+ — ResolutionFlow +

+
+
+""" diff --git a/backend/app/main.py b/backend/app/main.py index 8df118b0..a9ade25e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5dde142a..36280507 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 00000000..334ee9a9 --- /dev/null +++ b/backend/app/models/notification.py @@ -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]) diff --git a/backend/app/models/notification_config.py b/backend/app/models/notification_config.py new file mode 100644 index 00000000..993147af --- /dev/null +++ b/backend/app/models/notification_config.py @@ -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]) diff --git a/backend/app/models/notification_log.py b/backend/app/models/notification_log.py new file mode 100644 index 00000000..5ee4e932 --- /dev/null +++ b/backend/app/models/notification_log.py @@ -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] + ) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 00000000..63b6bf9d --- /dev/null +++ b/backend/app/schemas/notification.py @@ -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 diff --git a/backend/app/services/flowpilot_engine.py b/backend/app/services/flowpilot_engine.py index a4a4ca6d..ddd047b5 100644 --- a/backend/app/services/flowpilot_engine.py +++ b/backend/app/services/flowpilot_engine.py @@ -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) diff --git a/backend/app/services/knowledge_flywheel.py b/backend/app/services/knowledge_flywheel.py index 8f0209c1..7aef8427 100644 --- a/backend/app/services/knowledge_flywheel.py +++ b/backend/app/services/knowledge_flywheel.py @@ -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, diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 00000000..23926b0f --- /dev/null +++ b/backend/app/services/notification_service.py @@ -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 diff --git a/docs/plans/2026-03-19-phase4-slice2-notifications.md b/docs/plans/2026-03-19-phase4-slice2-notifications.md new file mode 100644 index 00000000..164c1251 --- /dev/null +++ b/docs/plans/2026-03-19-phase4-slice2-notifications.md @@ -0,0 +1,1570 @@ +# Phase 4 Slice 2: Notification Integrations — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build an event-driven notification system that alerts engineers and team admins via email, Slack, Teams, and in-app notifications when key events occur (escalations, proposals, high-priority tickets). + +**Architecture:** Lightweight event-driven service. Events are fired from existing lifecycle points (escalation, proposal creation, approval). The notification service routes them to configured channels. Retry via APScheduler for failed webhooks. In-app notifications extend the existing `NotificationsPanel` bell icon. + +**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), APScheduler 3.x, Resend (email), Slack/Teams webhook POST, React, TypeScript, Tailwind CSS v4 + +**Prerequisites:** +- Phase 1-3 complete (AI sessions, escalations, Knowledge Flywheel) +- Existing: `EmailService` in `core/email.py`, APScheduler in `core/scheduler.py`, `NotificationsPanel.tsx` +- Existing models: `User`, `AISession`, `FlowProposal`, `Account` + +**Parent plan:** `docs/2026-03-18-flowpilot-first-pivot-phase4.md` (Tasks 4, 5, 6, 6.5) + +--- + +## Task 1: NotificationConfig and NotificationLog models + migration + +**Files:** +- Create: `backend/app/models/notification_config.py` +- Create: `backend/app/models/notification_log.py` +- Create: `backend/app/models/notification.py` +- Edit: `backend/app/models/__init__.py` +- Create: `backend/alembic/versions/XXX_add_notification_tables.py` (via autogenerate) + +### Step 1: Create NotificationConfig model + +Create `backend/app/models/notification_config.py`: + +```python +"""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): + """A notification channel configured by an account. + + channel: "email" | "slack_webhook" | "teams_webhook" + events_enabled: {"session.escalated": true, "proposal.pending": true, ...} + """ + __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]) +``` + +### Step 2: Create NotificationLog model + +Create `backend/app/models/notification_log.py`: + +```python +"""Notification delivery log with retry tracking. + +Tracks every notification delivery attempt. Failed deliveries are retried +via APScheduler with exponential backoff (30s, 2m, 10m — max 3 retries). +""" +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): + """A single notification delivery attempt.""" + __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] + ) +``` + +### Step 3: Create in-app Notification model + +Create `backend/app/models/notification.py`: + +```python +"""In-app notification model. + +Created alongside external notifications (email/Slack/Teams). +Powers the NotificationsPanel bell icon dropdown in the frontend. +""" +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): + """An in-app notification for a specific user.""" + __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(200), 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]) +``` + +### Step 4: Register models in `__init__.py` + +Edit `backend/app/models/__init__.py` — add: + +```python +from app.models.notification_config import NotificationConfig +from app.models.notification_log import NotificationLog +from app.models.notification import Notification +``` + +### Step 5: Generate migration + +```bash +cd /projects/patherly/backend +DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \ + venv/bin/alembic revision --autogenerate -m "add notification tables" +``` + +Review the generated migration. Verify it creates `notification_configs`, `notification_logs`, and `notifications` tables with correct FKs and constraints. + +### Step 6: Run migration + +```bash +DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \ + venv/bin/alembic upgrade head +``` + +### Step 7: Commit + +```bash +git add backend/app/models/notification_config.py backend/app/models/notification_log.py \ + backend/app/models/notification.py backend/app/models/__init__.py \ + backend/alembic/versions/*notification* +git commit -m "feat(notifications): add NotificationConfig, NotificationLog, and Notification models + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 2: Notification schemas + +**Files:** +- Create: `backend/app/schemas/notification.py` + +### Step 1: Create schemas + +Create `backend/app/schemas/notification.py`: + +```python +"""Pydantic schemas for notification system.""" +from datetime import datetime +from uuid import UUID +from typing import Optional, Any + +from pydantic import BaseModel, Field + + +# --- NotificationConfig schemas --- + +VALID_CHANNELS = {"email", "slack_webhook", "teams_webhook"} + +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} + ) + + +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 + + +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} + + +# --- In-app Notification schemas --- + +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 + + +# --- Notification test --- + +class NotificationTestRequest(BaseModel): + config_id: UUID + + +class NotificationTestResponse(BaseModel): + success: bool + message: str +``` + +### Step 2: Commit + +```bash +git add backend/app/schemas/notification.py +git commit -m "feat(notifications): add notification Pydantic schemas + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 3: Notification service (core event routing + channel delivery) + +**Files:** +- Create: `backend/app/services/notification_service.py` + +### Step 1: Create the notification service + +Create `backend/app/services/notification_service.py`: + +```python +"""Event-driven notification service. + +Fires notifications to configured channels (email, Slack, Teams) and +creates in-app notification records. Failed webhook deliveries are +logged for retry by the scheduler. + +Usage: + await notify("session.escalated", account_id, payload, db) +""" +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional, Any +from uuid import UUID + +import httpx +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.email import EmailService +from app.models.notification_config import NotificationConfig +from app.models.notification_log import NotificationLog +from app.models.notification import Notification +from app.models.user import User + +logger = logging.getLogger(__name__) + +# Exponential backoff delays for retries (seconds) +RETRY_DELAYS = [30, 120, 600] # 30s, 2m, 10m + + +async def notify( + event: str, + account_id: UUID, + payload: dict[str, Any], + db: AsyncSession, + *, + target_user_ids: list[UUID] | None = None, +) -> None: + """Fire a notification event. Routes to all active channels for this account. + + Also creates in-app Notification records for target_user_ids. + If target_user_ids is None, notifies all team admins + account owner. + + Args: + event: Event type (e.g., "session.escalated") + account_id: Account that owns the event + payload: Event data (session summary, proposal title, etc.) + db: Database session + target_user_ids: Specific users to notify. None = team admins + owner. + """ + # 1. Create in-app notifications + 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) + + for user_id in recipients: + db.add(Notification( + account_id=account_id, + user_id=user_id, + event=event, + title=title, + body=body, + link=link, + )) + + # 2. Route to external channels + configs = await _get_active_configs(account_id, event, db) + for config in configs: + await _deliver_to_channel(config, event, payload, db) + + await db.flush() + + +async def retry_failed_notifications(db: AsyncSession) -> int: + """Retry failed notification deliveries. Called by APScheduler. + + Returns the number of retries attempted. + """ + now = datetime.now(timezone.utc) + result = await db.execute( + select(NotificationLog) + .where( + NotificationLog.status == "retrying", + NotificationLog.next_retry_at <= now, + ) + .limit(50) + ) + logs = result.scalars().all() + + retried = 0 + for log in logs: + config = log.config + if not config: + log.status = "exhausted" + log.last_error = "Config deleted" + continue + + success = await _attempt_delivery(config, log.event, log.payload) + log.retry_count += 1 + + if success: + log.status = "sent" + log.delivered_at = now + logger.info("Notification retry succeeded: %s (attempt %d)", log.id, log.retry_count) + elif log.retry_count >= log.max_retries: + log.status = "exhausted" + log.last_error = f"Exhausted after {log.retry_count} retries" + logger.warning("Notification exhausted after %d retries: %s", log.retry_count, log.id) + else: + delay = RETRY_DELAYS[min(log.retry_count, len(RETRY_DELAYS) - 1)] + log.next_retry_at = now + timedelta(seconds=delay) + log.status = "retrying" + + retried += 1 + + if retried: + await db.commit() + + return retried + + +async def send_test_notification(config: NotificationConfig) -> tuple[bool, str]: + """Send a test notification to verify channel configuration. + + Returns (success, message). + """ + test_payload = { + "title": "Test Notification", + "body": "This is a test notification from ResolutionFlow.", + "session_id": None, + "engineer_name": "System", + } + + if config.channel == "email": + if not config.email_addresses: + return False, "No email addresses configured" + success = await EmailService.send_notification_email( + config.email_addresses[0], "Test Notification", test_payload + ) + return success, "Test email sent" if success else "Email delivery failed" + + elif config.channel == "slack_webhook": + if not config.webhook_url: + return False, "No webhook URL configured" + success = await _send_slack_message(config.webhook_url, "test", test_payload) + return success, "Test message sent to Slack" if success else "Slack webhook failed" + + elif config.channel == "teams_webhook": + if not config.webhook_url: + return False, "No webhook URL configured" + success = await _send_teams_message(config.webhook_url, "test", test_payload) + return success, "Test message sent to Teams" if success else "Teams webhook failed" + + return False, f"Unknown channel: {config.channel}" + + +# --- Internal helpers --- + +async def _get_active_configs( + account_id: UUID, event: str, db: AsyncSession +) -> list[NotificationConfig]: + """Get all active notification configs for this account + event.""" + result = await db.execute( + select(NotificationConfig).where( + NotificationConfig.account_id == account_id, + NotificationConfig.is_active.is_(True), + ) + ) + configs = result.scalars().all() + # Filter by event enabled + return [c for c in configs if c.events_enabled.get(event, False)] + + +async def _resolve_recipients( + account_id: UUID, + target_user_ids: list[UUID] | None, + db: AsyncSession, +) -> list[UUID]: + """Resolve notification recipients. Defaults to team admins + account owner.""" + if target_user_ids: + return target_user_ids + + result = await db.execute( + select(User.id).where( + User.account_id == account_id, + User.is_active.is_(True), + (User.is_team_admin.is_(True)) | (User.account_role == "owner"), + ) + ) + return list(result.scalars().all()) + + +async def _deliver_to_channel( + config: NotificationConfig, + event: str, + payload: dict[str, Any], + db: AsyncSession, +) -> None: + """Attempt delivery to a channel. Log result.""" + success = await _attempt_delivery(config, event, payload) + + log = NotificationLog( + notification_config_id=config.id, + event=event, + payload=payload, + ) + + if success: + log.status = "sent" + log.delivered_at = datetime.now(timezone.utc) + else: + log.status = "retrying" + log.last_error = f"Initial delivery failed for {config.channel}" + log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=RETRY_DELAYS[0]) + + db.add(log) + + +async def _attempt_delivery( + config: NotificationConfig, event: str, payload: dict[str, Any] +) -> bool: + """Attempt to deliver a notification via the configured channel.""" + try: + if config.channel == "email": + return await _send_email(config, event, payload) + elif config.channel == "slack_webhook": + return await _send_slack_message( + config.webhook_url, event, payload + ) + elif config.channel == "teams_webhook": + return await _send_teams_message( + config.webhook_url, event, payload + ) + return False + except Exception: + logger.exception("Notification delivery failed for config %s", config.id) + return False + + +async def _send_email( + config: NotificationConfig, event: str, payload: dict[str, Any] +) -> bool: + """Send notification email to all configured addresses.""" + if not config.email_addresses: + return False + + title = _build_notification_title(event, payload) + success = True + for addr in config.email_addresses: + result = await EmailService.send_notification_email(addr, title, payload) + if not result: + success = False + return success + + +async def _send_slack_message( + webhook_url: str, event: str, payload: dict[str, Any] +) -> bool: + """Send a Slack notification via incoming webhook.""" + title = _build_notification_title(event, payload) + body = _build_notification_body(event, payload) + link = payload.get("link", "") + + slack_payload = { + "blocks": [ + { + "type": "header", + "text": {"type": "plain_text", "text": f"🔔 {title}", "emoji": True}, + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": body}, + }, + ] + } + + if link: + slack_payload["blocks"].append({ + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Open in ResolutionFlow"}, + "url": f"{settings.FRONTEND_URL}{link}", + } + ], + }) + + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(webhook_url, json=slack_payload) + if resp.status_code == 200: + logger.info("Slack notification sent: %s", event) + return True + logger.warning("Slack webhook returned %d: %s", resp.status_code, resp.text) + return False + + +async def _send_teams_message( + webhook_url: str, event: str, payload: dict[str, Any] +) -> bool: + """Send a Teams notification via incoming webhook (Adaptive Card).""" + title = _build_notification_title(event, payload) + body = _build_notification_body(event, payload) + link = payload.get("link", "") + + card = { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + {"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Medium"}, + {"type": "TextBlock", "text": body, "wrap": True}, + ], + }, + } + ], + } + + if link: + card["attachments"][0]["content"]["actions"] = [ + { + "type": "Action.OpenUrl", + "title": "Open in ResolutionFlow", + "url": f"{settings.FRONTEND_URL}{link}", + } + ] + + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(webhook_url, json=card) + if resp.status_code in (200, 202): + logger.info("Teams notification sent: %s", event) + return True + logger.warning("Teams webhook returned %d: %s", resp.status_code, resp.text) + return False + + +def _build_notification_title(event: str, payload: dict[str, Any]) -> str: + """Build a human-readable notification title.""" + titles = { + "session.escalated": f"Session escalated by {payload.get('engineer_name', 'an engineer')}", + "session.high_priority": f"High-priority session started: {payload.get('ticket_number', 'N/A')}", + "proposal.pending": f"New flow proposal: {payload.get('title', 'Untitled')}", + "proposal.approved": f"Flow proposal approved: {payload.get('title', 'Untitled')}", + "knowledge_gap.detected": f"Knowledge gap detected: {payload.get('gap_type', 'Unknown')}", + "test": "Test Notification from ResolutionFlow", + } + return titles.get(event, f"Notification: {event}") + + +def _build_notification_body(event: str, payload: dict[str, Any]) -> str: + """Build notification body text.""" + if event == "session.escalated": + reason = payload.get("escalation_reason", "No reason provided") + return f"Reason: {reason}\nProblem: {payload.get('problem_summary', 'N/A')}" + elif event == "session.high_priority": + return f"Ticket: {payload.get('ticket_number', 'N/A')}\nClient: {payload.get('client_name', 'N/A')}" + elif event == "proposal.pending": + return f"Type: {payload.get('proposal_type', 'N/A')}\nDomain: {payload.get('problem_domain', 'N/A')}" + elif event == "proposal.approved": + return f"Approved by {payload.get('reviewer_name', 'a reviewer')}" + elif event == "knowledge_gap.detected": + return f"Severity: {payload.get('severity', 'N/A')}\nArea: {payload.get('problem_domain', 'N/A')}" + elif event == "test": + return "This is a test notification from ResolutionFlow. Your integration is working!" + return "" + + +def _build_notification_link(event: str, payload: dict[str, Any]) -> str | None: + """Build in-app link for the notification.""" + links = { + "session.escalated": f"/pilot/{payload.get('session_id', '')}", + "session.high_priority": f"/pilot/{payload.get('session_id', '')}", + "proposal.pending": "/review-queue", + "proposal.approved": "/review-queue", + "knowledge_gap.detected": "/analytics/flowpilot", + } + return links.get(event) +``` + +### Step 2: Add notification email method to EmailService + +Edit `backend/app/core/email.py` — add this static method to the `EmailService` class: + +```python +@staticmethod +async def send_notification_email(to_email: str, subject: str, data: dict) -> bool: + """Send a notification email.""" + if not settings.email_enabled: + logger.warning("Notification email not sent — RESEND_API_KEY not configured") + return False + try: + import resend + resend.api_key = settings.RESEND_API_KEY + html = _render_notification_html(subject=subject, data=data) + resend.Emails.send({ + "from": settings.FROM_EMAIL, + "to": [to_email], + "subject": f"[ResolutionFlow] {subject}", + "html": html, + }) + logger.info("Notification email sent to %s: %s", to_email, subject) + return True + except Exception: + logger.exception("Failed to send notification email to %s", to_email) + return False +``` + +Add this HTML renderer at module level in `email.py`: + +```python +def _render_notification_html(subject: str, data: dict) -> str: + body_text = data.get("body", "") + link = data.get("link", "") + link_html = "" + if link: + full_url = f"{settings.FRONTEND_URL}{link}" + link_html = f'

Open in ResolutionFlow →

' + + return f""" +
+

{subject}

+
+

{body_text}

+ {link_html} +
+

— ResolutionFlow

+
+ """ +``` + +### Step 3: Verify imports + +```bash +cd /projects/patherly/backend +DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \ + venv/bin/python -c "from app.services.notification_service import notify, retry_failed_notifications, send_test_notification; print('Service OK')" +``` + +### Step 4: Commit + +```bash +git add backend/app/services/notification_service.py backend/app/core/email.py +git commit -m "feat(notifications): add notification service with email/Slack/Teams delivery + retry + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 4: Notification API endpoints + +**Files:** +- Create: `backend/app/api/endpoints/notifications.py` +- Edit: `backend/app/api/router.py` + +### Step 1: Create notification endpoints + +Create `backend/app/api/endpoints/notifications.py`: + +```python +"""Notification endpoints. + +- NotificationConfig CRUD (account-level, requires team admin) +- In-app notification list/read/mark-all-read (per user) +- Test notification delivery +""" +import logging +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select, func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.rate_limit import limiter +from app.api.deps import get_current_active_user, require_team_admin +from app.models.user import User +from app.models.notification_config import NotificationConfig +from app.models.notification import Notification as NotificationModel +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"]) + + +# --- NotificationConfig CRUD (team admin) --- + +@router.get("/configs", response_model=list[NotificationConfigResponse]) +async def list_configs( + request, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_team_admin), +): + """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) + ) + return result.scalars().all() + + +@router.post("/configs", response_model=NotificationConfigResponse, status_code=201) +async def create_config( + data: NotificationConfigCreate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_team_admin), +): + """Create a new notification channel config.""" + # Validate channel-specific requirements + if data.channel in ("slack_webhook", "teams_webhook") and not data.webhook_url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"webhook_url is required for {data.channel}", + ) + if data.channel == "email" and not data.email_addresses: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="email_addresses required for email channel", + ) + + config = NotificationConfig( + account_id=current_user.account_id, + channel=data.channel, + webhook_url=data.webhook_url, + email_addresses=data.email_addresses, + events_enabled=data.events_enabled, + ) + db.add(config) + await db.commit() + await db.refresh(config) + return config + + +@router.patch("/configs/{config_id}", response_model=NotificationConfigResponse) +async def update_config( + config_id: UUID, + data: NotificationConfigUpdate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_team_admin), +): + """Update a notification channel config.""" + config = await db.get(NotificationConfig, config_id) + if not config or config.account_id != current_user.account_id: + raise HTTPException(status_code=404, detail="Config not found") + + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(config, field, value) + + await db.commit() + await db.refresh(config) + return config + + +@router.delete("/configs/{config_id}", status_code=204) +async def delete_config( + config_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_team_admin), +): + """Delete a notification channel config.""" + config = await db.get(NotificationConfig, config_id) + if not config or config.account_id != current_user.account_id: + raise HTTPException(status_code=404, detail="Config not found") + + await db.delete(config) + await db.commit() + + +@router.post("/configs/test", response_model=NotificationTestResponse) +async def test_config( + data: NotificationTestRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_team_admin), +): + """Send a test notification to verify channel configuration.""" + config = await db.get(NotificationConfig, data.config_id) + if not config or config.account_id != current_user.account_id: + raise HTTPException(status_code=404, detail="Config not found") + + success, message = await send_test_notification(config) + return NotificationTestResponse(success=success, message=message) + + +# --- In-app notifications (per user) --- + +@router.get("", response_model=list[NotificationResponse]) +async def list_notifications( + 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(20, ge=1, le=100), +): + """List in-app notifications for the current user (unread first).""" + result = await db.execute( + select(NotificationModel) + .where(NotificationModel.user_id == current_user.id) + .order_by(NotificationModel.is_read, NotificationModel.created_at.desc()) + .offset(skip) + .limit(limit) + ) + return result.scalars().all() + + +@router.get("/unread-count", response_model=UnreadCountResponse) +async def unread_count( + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get unread notification count for badge display.""" + result = await db.execute( + select(func.count(NotificationModel.id)).where( + NotificationModel.user_id == current_user.id, + NotificationModel.is_read.is_(False), + ) + ) + return UnreadCountResponse(count=result.scalar_one()) + + +@router.patch("/{notification_id}/read") +async def mark_read( + notification_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Mark a single notification as read.""" + notif = await db.get(NotificationModel, notification_id) + if not notif or notif.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Notification not found") + + notif.is_read = True + await db.commit() + return {"ok": True} + + +@router.post("/mark-all-read") +async def mark_all_read( + 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(NotificationModel) + .where( + NotificationModel.user_id == current_user.id, + NotificationModel.is_read.is_(False), + ) + .values(is_read=True) + ) + await db.commit() + return {"ok": True} +``` + +### Step 2: Register router + +Edit `backend/app/api/router.py` — add: + +```python +from app.api.endpoints import notifications + +api_router.include_router(notifications.router) +``` + +### Step 3: Verify + +```bash +cd /projects/patherly/backend +DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \ + venv/bin/python -c "from app.api.endpoints.notifications import router; print(f'Notifications: {len(router.routes)} routes')" +``` + +### Step 4: Commit + +```bash +git add backend/app/api/endpoints/notifications.py backend/app/api/router.py +git commit -m "feat(notifications): add notification config CRUD + in-app notification endpoints + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 5: Notification retry scheduler + +**Files:** +- Edit: `backend/app/main.py` + +### Step 1: Add retry job to scheduler + +Edit `backend/app/main.py` — in the lifespan function, after the existing scheduler jobs, add: + +```python +from app.services.notification_service import retry_failed_notifications +from app.core.database import AsyncSessionLocal + +async def _process_notification_retries(): + """Retry failed notification deliveries.""" + async with AsyncSessionLocal() as db: + retried = await retry_failed_notifications(db) + if retried: + logger.info("Retried %d failed notifications", retried) + +scheduler.add_job( + _process_notification_retries, + trigger="interval", + minutes=1, + id="notification_retry", + replace_existing=True, + max_instances=1, +) +``` + +### Step 2: Verify scheduler starts + +```bash +cd /projects/patherly/backend +DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \ + venv/bin/python -c "from app.main import app; print('App created OK')" +``` + +### Step 3: Commit + +```bash +git add backend/app/main.py +git commit -m "feat(notifications): add APScheduler job for notification retry (1m interval, max_instances=1) + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 6: Wire notifications into session lifecycle + +**Files:** +- Edit: `backend/app/services/flowpilot_engine.py` +- Edit: `backend/app/services/knowledge_flywheel.py` +- Edit: `backend/app/api/endpoints/flow_proposals.py` + +### Step 1: Wire into escalation + +Edit `backend/app/services/flowpilot_engine.py` — in `escalate_session()`, after `await db.flush()` (line ~498), add: + +```python +import asyncio +from app.services.notification_service import notify + +# Fire escalation notification (non-blocking) +escalation_payload = { + "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}", +} +target_users = [request.escalated_to_id] if request.escalated_to_id else None +asyncio.create_task( + notify("session.escalated", session.account_id, escalation_payload, db, target_user_ids=target_users) +) +``` + +**Note:** `asyncio.create_task` won't work directly here because the db session may close before the task runs. Instead, use a synchronous call within the existing transaction: + +```python +await notify("session.escalated", session.account_id, escalation_payload, db, target_user_ids=target_users) +``` + +### Step 2: Wire into proposal creation + +Edit `backend/app/services/knowledge_flywheel.py` — after each `db.add(proposal)` call (lines ~297, ~357, ~433), add: + +```python +from app.services.notification_service import notify + +# Only notify for proposals that need review (not auto_reinforced) +if proposal.status == "pending": + 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) +``` + +### Step 3: Wire into proposal approval + +Edit `backend/app/api/endpoints/flow_proposals.py` — in `review_proposal()`, after the status is set to "approved" (lines ~228, ~240), add: + +```python +from app.services.notification_service import notify + +if data.action == "approve": + # ... existing approval logic ... + + # Notify the engineer who created the source session + 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) +``` + +### Step 4: Verify imports work + +```bash +cd /projects/patherly/backend +DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \ + venv/bin/python -c " +from app.services.flowpilot_engine import escalate_session +from app.services.knowledge_flywheel import analyze_session +from app.api.endpoints.flow_proposals import router +print('All wiring OK') +" +``` + +### Step 5: Commit + +```bash +git add backend/app/services/flowpilot_engine.py backend/app/services/knowledge_flywheel.py \ + backend/app/api/endpoints/flow_proposals.py +git commit -m "feat(notifications): wire notify() into escalation, proposal creation, and approval + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 7: Frontend — notification types + API client + +**Files:** +- Create: `frontend/src/types/notification.ts` +- Create: `frontend/src/api/notifications.ts` +- Edit: `frontend/src/types/index.ts` +- Edit: `frontend/src/api/index.ts` + +### Step 1: Create types + +Create `frontend/src/types/notification.ts`: + +```typescript +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 + created_at: string + updated_at: string +} + +export interface NotificationConfigCreate { + channel: 'email' | 'slack_webhook' | 'teams_webhook' + webhook_url?: string + email_addresses?: string[] + events_enabled?: Record +} + +export interface NotificationConfigUpdate { + webhook_url?: string + email_addresses?: string[] + is_active?: boolean + events_enabled?: Record +} + +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 +``` + +### Step 2: Create API client + +Create `frontend/src/api/notifications.ts`: + +```typescript +import apiClient from './client' +import type { + NotificationConfig, + NotificationConfigCreate, + NotificationConfigUpdate, + AppNotification, + UnreadCount, +} from '@/types/notification' + +export const notificationsApi = { + // --- Config CRUD --- + async listConfigs(): Promise { + const response = await apiClient.get('/notifications/configs') + return response.data + }, + + async createConfig(data: NotificationConfigCreate): Promise { + const response = await apiClient.post('/notifications/configs', data) + return response.data + }, + + async updateConfig(id: string, data: NotificationConfigUpdate): Promise { + const response = await apiClient.patch(`/notifications/configs/${id}`, data) + return response.data + }, + + async deleteConfig(id: string): Promise { + 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 + }, + + // --- In-app notifications --- + async list(params?: { skip?: number; limit?: number }): Promise { + const response = await apiClient.get('/notifications', { params }) + return response.data + }, + + async unreadCount(): Promise { + const response = await apiClient.get('/notifications/unread-count') + return response.data.count + }, + + async markRead(id: string): Promise { + await apiClient.patch(`/notifications/${id}/read`) + }, + + async markAllRead(): Promise { + await apiClient.post('/notifications/mark-all-read') + }, +} + +export default notificationsApi +``` + +### Step 3: Export from index files + +Edit `frontend/src/types/index.ts` — add: + +```typescript +export type * from './notification' +``` + +Edit `frontend/src/api/index.ts` — add: + +```typescript +export { notificationsApi } from './notifications' +``` + +### Step 4: Verify build + +```bash +cd /projects/patherly/frontend && npm run build +``` + +### Step 5: Commit + +```bash +git add frontend/src/types/notification.ts frontend/src/api/notifications.ts \ + frontend/src/types/index.ts frontend/src/api/index.ts +git commit -m "feat(notifications): add notification types and API client + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 8: Frontend — NotificationsPanel upgrade (in-app notification center) + +**Files:** +- Edit: `frontend/src/components/layout/NotificationsPanel.tsx` + +### Step 1: Rewrite NotificationsPanel + +Replace the contents of `frontend/src/components/layout/NotificationsPanel.tsx` with a component that: + +- Fetches from `notificationsApi.list()` instead of `sessionsApi.list()` +- Shows unread count badge (number, not just a dot) via `notificationsApi.unreadCount()` +- Each item: event icon, title, body, time ago, click → mark read + navigate +- "Mark all as read" button in header +- Polls unread count every 30 seconds +- Keep existing glass-card dropdown styling + +**Event icons mapping:** +- `session.escalated` → `AlertTriangle` (amber) +- `session.high_priority` → `AlertCircle` (rose) +- `proposal.pending` → `FileText` (primary/cyan) +- `proposal.approved` → `CheckCircle` (emerald) +- `knowledge_gap.detected` → `TrendingUp` (amber) + +**Key implementation details:** +- Use `useEffect` with `setInterval` for polling unread count (30s) +- On dropdown open, fetch full notification list +- On notification click: `notificationsApi.markRead(id)` then `navigate(link)` if link exists +- Badge: show count number if > 0 (e.g., "3"), red background `bg-rose-500` +- Keep the existing click-outside-to-close pattern + +### Step 2: Verify build + +```bash +cd /projects/patherly/frontend && npm run build +``` + +### Step 3: Commit + +```bash +git add frontend/src/components/layout/NotificationsPanel.tsx +git commit -m "feat(notifications): upgrade NotificationsPanel to real notification center + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 9: Frontend — Notification settings UI + +**Files:** +- Create: `frontend/src/components/account/NotificationSettings.tsx` +- Edit: `frontend/src/pages/account/IntegrationsPage.tsx` + +### Step 1: Create NotificationSettings component + +Create `frontend/src/components/account/NotificationSettings.tsx`: + +A section component that renders within the Integrations page. Shows: + +- Section header: "Notifications" with Bell icon +- List of configured channels as glass-card items +- Each card: channel icon (Mail/Slack/Teams), name, status toggle, event checkboxes, test button, delete button +- "Add Channel" button → dropdown with Email, Slack, Teams options +- For Slack/Teams: webhook URL input field +- For Email: email addresses input (comma-separated) +- Event toggles: checkboxes for each event from `NOTIFICATION_EVENTS` +- Test button: calls `notificationsApi.testConfig()`, shows toast result + +**Design rules:** +- Cards use `.glass-card-static` +- Buttons follow CLAUDE.md patterns (primary: `bg-gradient-brand`, secondary: `bg-[rgba(255,255,255,0.04)]`) +- Status toggle: simple on/off with emerald/muted colors +- Section label: `font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground` + +### Step 2: Add NotificationSettings to IntegrationsPage + +Edit `frontend/src/pages/account/IntegrationsPage.tsx` — import and render `` below the existing PSA connections section. + +### Step 3: Verify build + +```bash +cd /projects/patherly/frontend && npm run build +``` + +### Step 4: Commit + +```bash +git add frontend/src/components/account/NotificationSettings.tsx \ + frontend/src/pages/account/IntegrationsPage.tsx +git commit -m "feat(notifications): add notification settings UI in integrations page + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 10: Final review + integration test + +### Step 1: Run backend import check + +```bash +cd /projects/patherly/backend +DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \ + venv/bin/python -c " +from app.models.notification_config import NotificationConfig +from app.models.notification_log import NotificationLog +from app.models.notification import Notification +from app.schemas.notification import NotificationConfigCreate, NotificationResponse +from app.services.notification_service import notify, retry_failed_notifications +from app.api.endpoints.notifications import router +print(f'Models: OK') +print(f'Schemas: OK') +print(f'Service: OK') +print(f'Endpoints: {len(router.routes)} routes') +print('All imports clean') +" +``` + +### Step 2: Run frontend build + +```bash +cd /projects/patherly/frontend && npm run build +``` + +### Step 3: Verify migration + +```bash +cd /projects/patherly/backend +DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \ + venv/bin/alembic current +``` + +### Step 4: Manual smoke test checklist + +- [ ] Start backend + frontend dev servers +- [ ] Open Integrations page → see Notifications section +- [ ] Add an email notification config → verify it saves +- [ ] Toggle events on/off → verify PATCH works +- [ ] Click Test → verify toast shows result +- [ ] Bell icon in top bar → see notification dropdown +- [ ] Escalate a FlowPilot session → verify notification appears in bell dropdown +- [ ] Click notification → navigates to session +- [ ] Mark all as read → badge clears + +### Step 5: Final commit (if any cleanup needed) + +```bash +git commit -m "fix(notifications): address review feedback + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Summary of All Files + +### New Files +``` +backend/app/models/notification_config.py # Channel config model +backend/app/models/notification_log.py # Delivery log + retry model +backend/app/models/notification.py # In-app notification model +backend/app/schemas/notification.py # All notification schemas +backend/app/services/notification_service.py # Core service (routing, delivery, retry) +backend/app/api/endpoints/notifications.py # Config CRUD + in-app notification endpoints +backend/alembic/versions/XXX_add_notification_tables.py +frontend/src/types/notification.ts # TypeScript interfaces +frontend/src/api/notifications.ts # API client +frontend/src/components/account/NotificationSettings.tsx # Settings UI +``` + +### Modified Files +``` +backend/app/models/__init__.py # Register 3 new models +backend/app/api/router.py # Register notifications router +backend/app/main.py # Add retry scheduler job +backend/app/core/email.py # Add send_notification_email method +backend/app/services/flowpilot_engine.py # Wire escalation notification +backend/app/services/knowledge_flywheel.py # Wire proposal creation notification +backend/app/api/endpoints/flow_proposals.py # Wire proposal approval notification +frontend/src/types/index.ts # Export notification types +frontend/src/api/index.ts # Export notifications API +frontend/src/components/layout/NotificationsPanel.tsx # Upgrade to real notification center +frontend/src/pages/account/IntegrationsPage.tsx # Add notification settings section +``` diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 9cd86d21..2754ae14 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -27,3 +27,4 @@ export { sessionToFlowApi } from './sessionToFlow' export { aiSessionsApi } from './aiSessions' export { flowProposalsApi } from './flowProposals' export { flowpilotAnalyticsApi } from './flowpilotAnalytics' +export { notificationsApi } from './notifications' diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 00000000..bcc10c74 --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -0,0 +1,57 @@ +import apiClient from './client' +import type { + NotificationConfig, + NotificationConfigCreate, + NotificationConfigUpdate, + AppNotification, + UnreadCount, +} from '@/types/notification' + +export const notificationsApi = { + async listConfigs(): Promise { + const response = await apiClient.get('/notifications/configs') + return response.data + }, + + async createConfig(data: NotificationConfigCreate): Promise { + const response = await apiClient.post('/notifications/configs', data) + return response.data + }, + + async updateConfig(id: string, data: NotificationConfigUpdate): Promise { + const response = await apiClient.patch(`/notifications/configs/${id}`, data) + return response.data + }, + + async deleteConfig(id: string): Promise { + 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 { + const response = await apiClient.get('/notifications', { params }) + return response.data + }, + + async unreadCount(): Promise { + const response = await apiClient.get('/notifications/unread-count') + return response.data.count + }, + + async markRead(id: string): Promise { + await apiClient.patch(`/notifications/${id}/read`) + }, + + async markAllRead(): Promise { + await apiClient.post('/notifications/mark-all-read') + }, +} + +export default notificationsApi diff --git a/frontend/src/components/account/NotificationSettings.tsx b/frontend/src/components/account/NotificationSettings.tsx new file mode 100644 index 00000000..9d1b9c2a --- /dev/null +++ b/frontend/src/components/account/NotificationSettings.tsx @@ -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 = { + 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([]) + const [loading, setLoading] = useState(true) + const [addingChannel, setAddingChannel] = useState(null) + const [testingId, setTestingId] = useState(null) + const [showDropdown, setShowDropdown] = useState(false) + const [confirmDeleteId, setConfirmDeleteId] = useState(null) + const dropdownRef = useRef(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 ( +
+ {/* Section header */} +
+
+ +

Notifications

+
+ +
+ + + {showDropdown && ( +
+ {(Object.entries(CHANNEL_LABELS) as [ChannelType, string][]).map(([key, label]) => { + const Icon = CHANNEL_ICONS[key] + return ( + + ) + })} +
+ )} +
+
+ + {/* Loading */} + {loading && ( +
+ +
+ )} + + {/* Empty state */} + {!loading && configs.length === 0 && !addingChannel && ( +
+ +

+ No notification channels configured. Add a channel to receive alerts for session events. +

+
+ )} + + {/* Channel list */} + {!loading && ( +
+ {configs.map(config => { + const Icon = CHANNEL_ICONS[config.channel] + return ( +
+ {/* Header row */} +
+ + + {CHANNEL_LABELS[config.channel]} + + + + {config.is_active ? 'Active' : 'Inactive'} + +
+ + {/* Config details */} +
+ {config.webhook_url && ( +
+ + Webhook URL + +

+ {maskWebhookUrl(config.webhook_url)} +

+
+ )} + {config.email_addresses && config.email_addresses.length > 0 && ( +
+ + Email Addresses + +

+ {config.email_addresses.join(', ')} +

+
+ )} +
+ + {/* Event toggles */} +
+ + Events + +
+ {Object.entries(NOTIFICATION_EVENTS).map(([eventKey, eventLabel]) => ( + + ))} +
+
+ + {/* Action buttons */} +
+ + + + + {confirmDeleteId === config.id ? ( + + ) : ( + + )} +
+
+ ) + })} + + {/* Inline add form */} + {addingChannel && ( +
+
+ {(() => { + const Icon = CHANNEL_ICONS[addingChannel] + return + })()} + + Add {CHANNEL_LABELS[addingChannel]} + +
+ + {addingChannel === 'email' ? ( +
+ + setNewEmails(e.target.value)} + placeholder="user@example.com, team@example.com" + className="mt-1" + /> +

+ Separate multiple addresses with commas +

+
+ ) : ( +
+ + setNewWebhookUrl(e.target.value)} + placeholder={ + addingChannel === 'slack_webhook' + ? 'https://hooks.slack.com/services/...' + : 'https://outlook.office.com/webhook/...' + } + className="mt-1" + /> +
+ )} + +
+ + +
+
+ )} +
+ )} +
+ ) +} + +export default NotificationSettings diff --git a/frontend/src/components/layout/NotificationsPanel.tsx b/frontend/src/components/layout/NotificationsPanel.tsx index 38718943..8f634173 100644 --- a/frontend/src/components/layout/NotificationsPanel.tsx +++ b/frontend/src/components/layout/NotificationsPanel.tsx @@ -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 + case 'session.high_priority': + return + case 'proposal.pending': + return + case 'proposal.approved': + return + case 'knowledge_gap.detected': + return + default: + return + } +} + export function NotificationsPanel() { const [open, setOpen] = useState(false) - const [sessions, setSessions] = useState([]) - const [hasNew, setHasNew] = useState(false) + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) const ref = useRef(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 (
{open && ( -
+
{ if (e.key === 'Escape') setOpen(false) }} + >
-

Activity

- setOpen(false)} - className="text-[0.6875rem] text-muted-foreground hover:text-foreground" - > - View All - +

Notifications

+ {unreadCount > 0 && ( + + )}
- {sessions.length === 0 ? ( + {notifications.length === 0 ? (
- No recent activity + No notifications yet
) : (
- {sessions.map(session => ( - setOpen(false)} - className="flex items-start gap-3 px-4 py-3 hover:bg-accent/50 transition-colors" + {notifications.map(notification => ( + ))}
)} + +
+ setOpen(false)} + className="text-xs text-muted-foreground hover:text-foreground transition-colors" + > + Notification settings + +
)}
diff --git a/frontend/src/pages/account/IntegrationsPage.tsx b/frontend/src/pages/account/IntegrationsPage.tsx index bcb8a6a4..48788569 100644 --- a/frontend/src/pages/account/IntegrationsPage.tsx +++ b/frontend/src/pages/account/IntegrationsPage.tsx @@ -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('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 }) => (
) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f8fb643a..0f0f8da3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -93,3 +93,4 @@ export type { export * from './scripts' export * from './integrations' +export * from './notification' diff --git a/frontend/src/types/notification.ts b/frontend/src/types/notification.ts new file mode 100644 index 00000000..b82fb2a5 --- /dev/null +++ b/frontend/src/types/notification.ts @@ -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 + created_at: string + updated_at: string +} + +export interface NotificationConfigCreate { + channel: 'email' | 'slack_webhook' | 'teams_webhook' + webhook_url?: string + email_addresses?: string[] + events_enabled?: Record +} + +export interface NotificationConfigUpdate { + webhook_url?: string + email_addresses?: string[] + is_active?: boolean + events_enabled?: Record +} + +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