feat(notifications): add Phase 4 Slice 2 — multi-channel notification system

Full notification infrastructure with in-app, email, Slack, and Teams channels:

Backend:
- NotificationConfig, NotificationLog, Notification models + migration
- Notification service with event routing, channel delivery, retry logic
- 9 API endpoints (config CRUD + in-app notifications)
- APScheduler retry job with exponential backoff (30s, 2m, 10m)
- Wired into escalation, proposal approval, and knowledge flywheel
- Pydantic event key validation, cross-tenant protection on recipients

Frontend:
- TypeScript types + API client for all notification endpoints
- NotificationsPanel: bell icon with unread badge, dropdown, mark-read
- NotificationSettings: channel config, event toggles, test, delete confirm
- Notifications tab on IntegrationsPage
- ARIA attributes, Escape handler, settings link on panel

Review fixes (13 issues resolved):
- notify() no longer commits/rolls back caller's transaction (critical)
- retry_failed_notifications returns count instead of None (critical)
- NotificationSettings moved inside dedicated tab (critical)
- target_user_ids scoped by account_id (security)
- Email loop collects all failures before raising
- Slack webhook validates response body
- events_enabled rejects unknown event keys
- link column widened to String(500)
- Dead code removed from _auto_reinforce
- Delete confirmation, ARIA, Escape key support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:37:54 +00:00
parent a8999adef3
commit 0f750e63e0
22 changed files with 3402 additions and 53 deletions

View File

@@ -43,6 +43,9 @@ from .psa_post_log import PsaPostLog
from .psa_member_mapping import PsaMemberMapping
from .supporting_data import SessionSupportingData
from .flow_proposal import FlowProposal
from .notification_config import NotificationConfig
from .notification_log import NotificationLog
from .notification import Notification
__all__ = [
"User",
@@ -100,4 +103,7 @@ __all__ = [
"PsaMemberMapping",
"SessionSupportingData",
"FlowProposal",
"NotificationConfig",
"NotificationLog",
"Notification",
]

View File

@@ -0,0 +1,45 @@
"""In-app notification model."""
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class Notification(Base):
__tablename__ = "notifications"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
event: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
link: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
index=True,
)
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])

View File

@@ -0,0 +1,60 @@
"""Notification channel configuration per account.
Each account can have multiple notification configs (email, Slack webhook, Teams webhook).
Each config specifies which events it receives.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.account import Account
class NotificationConfig(Base):
__tablename__ = "notification_configs"
__table_args__ = (
CheckConstraint(
"channel IN ('email', 'slack_webhook', 'teams_webhook')",
name="ck_notification_configs_channel",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
channel: Mapped[str] = mapped_column(String(20), nullable=False)
webhook_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
email_addresses: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
events_enabled: Mapped[dict[str, Any]] = mapped_column(
JSONB, default=lambda: {
"session.escalated": True,
"session.high_priority": True,
"proposal.pending": True,
"proposal.approved": True,
"knowledge_gap.detected": True,
}
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id])

View File

@@ -0,0 +1,52 @@
"""Notification delivery log with retry tracking."""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Integer, DateTime, ForeignKey, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.notification_config import NotificationConfig
class NotificationLog(Base):
__tablename__ = "notification_logs"
__table_args__ = (
CheckConstraint(
"status IN ('sent', 'failed', 'retrying', 'exhausted')",
name="ck_notification_logs_status",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
notification_config_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_configs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
event: Mapped[str] = mapped_column(String(50), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="sent")
retry_count: Mapped[int] = mapped_column(Integer, default=0)
max_retries: Mapped[int] = mapped_column(Integer, default=3)
last_error: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
next_retry_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
delivered_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
config: Mapped[Optional["NotificationConfig"]] = relationship(
"NotificationConfig", foreign_keys=[notification_config_id]
)