feat(B-NOTIF): Notification→Message Konsolidierung — System-Channel, post_system_message(), Migration
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
B-NOTIF-SYS: CommConversation um is_system Feld erweitert, get_or_create_system_channel() B-NOTIF-EVT: post_system_message() in kommunikation/services.py — erstellt CommMessage im System-Channel - create_notification() als deprecated Wrapper delegiert auf post_system_message() - Text-Block + action_card Block mit Deep-Link, Metadata: notification_type/severity/entity_ref B-NOTIF-PREF: NotificationPreference als Routing-Konfiguration, stumm/disabled respektiert B-NOTIF-MIG: Migration 0120 — Notifications → CommMessages migriert, notifications_legacy View B-NOTIF-DEPREC: Notification-Routes als deprecated markiert, keine Routes entfernt B-NOTIF-TEST: 19 Tests in test_notification_migration.py — alle grün - System-Channel, post_system_message, create_notification Wrapper, Preferences, Unread-Badge, Migration Mapping, Sensitive Fields
This commit is contained in:
+55
-35
@@ -1,7 +1,13 @@
|
||||
"""Notification service — create and manage user notifications."""
|
||||
"""Notification service — create and manage user notifications.
|
||||
|
||||
As of B-NOTIF-EVT, the primary entry point is post_system_message() which posts
|
||||
to the Communication system channel. create_notification() is retained as a
|
||||
deprecated backward-compat wrapper that delegates to post_system_message().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
@@ -15,6 +21,31 @@ from app.models.notification import (
|
||||
NotificationType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Re-export post_system_message from kommunikation services for convenience
|
||||
async def post_system_message(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
message_type: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
entity_type: str | None = None,
|
||||
entity_id: uuid.UUID | None = None,
|
||||
severity: str = "info",
|
||||
):
|
||||
"""Post a typed system message to the tenant system channel.
|
||||
|
||||
Delegates to kommunikation.services.post_system_message.
|
||||
Returns the created CommMessage, or None if the user has muted this type.
|
||||
"""
|
||||
from app.plugins.builtins.kommunikation.services import post_system_message as _post
|
||||
return await _post(
|
||||
db, tenant_id, user_id, message_type, title, body,
|
||||
entity_type, entity_id, severity,
|
||||
)
|
||||
|
||||
|
||||
async def create_notification(
|
||||
db: AsyncSession,
|
||||
@@ -28,34 +59,24 @@ async def create_notification(
|
||||
) -> Notification | None:
|
||||
"""Create a new notification for a user if they have not disabled this type.
|
||||
|
||||
.. deprecated:: B-NOTIF-EVT
|
||||
Use post_system_message() instead. This wrapper delegates to
|
||||
post_system_message() and also creates a legacy Notification record
|
||||
for backward compatibility with existing routes and frontend.
|
||||
|
||||
Returns None if the user has opted out of this notification type.
|
||||
"""
|
||||
# Check user preference
|
||||
pref = await db.execute(
|
||||
select(NotificationPreference).where(
|
||||
and_(
|
||||
NotificationPreference.user_id == user_id,
|
||||
NotificationPreference.type_key == type,
|
||||
NotificationPreference.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
# Delegate to post_system_message for the comm channel
|
||||
comm_msg = await post_system_message(
|
||||
db, tenant_id, user_id, type, title, body,
|
||||
entity_type, entity_id, severity="info",
|
||||
)
|
||||
pref_row = pref.scalar_one_or_none()
|
||||
|
||||
# If preference exists and is disabled, skip
|
||||
if pref_row and not pref_row.is_enabled:
|
||||
if comm_msg is None:
|
||||
# User has muted this type — don't create legacy record either
|
||||
return None
|
||||
|
||||
# If no preference, check if type is enabled by default
|
||||
if not pref_row:
|
||||
type_def = await db.execute(
|
||||
select(NotificationType).where(NotificationType.type_key == type)
|
||||
)
|
||||
type_row = type_def.scalar_one_or_none()
|
||||
if type_row and not type_row.is_enabled_by_default:
|
||||
return None
|
||||
|
||||
# Create notification
|
||||
# Also create legacy Notification record for backward compat
|
||||
notif = Notification(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@@ -68,17 +89,18 @@ async def create_notification(
|
||||
db.add(notif)
|
||||
await db.flush()
|
||||
|
||||
# Publish notification.created event
|
||||
# Publish notification.created event (backward compat)
|
||||
from app.core.event_bus import get_event_bus
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish('notification.created', {
|
||||
'notification_id': str(notif.id),
|
||||
'tenant_id': str(tenant_id),
|
||||
'user_id': str(user_id),
|
||||
'type': type,
|
||||
'title': title,
|
||||
'entity_type': entity_type,
|
||||
'entity_id': str(entity_id) if entity_id else None,
|
||||
await event_bus.publish("notification.created", {
|
||||
"notification_id": str(notif.id),
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"type": type,
|
||||
"title": title,
|
||||
"entity_type": entity_type,
|
||||
"entity_id": str(entity_id) if entity_id else None,
|
||||
"comm_message_id": str(comm_msg.id),
|
||||
})
|
||||
|
||||
return notif
|
||||
@@ -93,7 +115,6 @@ async def list_notifications(
|
||||
) -> dict[str, Any]:
|
||||
"""List notifications for a user, unread first, then by created_at desc."""
|
||||
offset = (page - 1) * page_size
|
||||
# Count total
|
||||
count_q = (
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
@@ -104,7 +125,6 @@ async def list_notifications(
|
||||
)
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
# Query — unread first (read_at IS NULL), then newest
|
||||
q = (
|
||||
select(Notification)
|
||||
.where(
|
||||
@@ -112,7 +132,7 @@ async def list_notifications(
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
.order_by(
|
||||
Notification.read_at.isnot(None), # False (unread) sorts first
|
||||
Notification.read_at.isnot(None),
|
||||
Notification.created_at.desc(),
|
||||
)
|
||||
.offset(offset)
|
||||
|
||||
@@ -44,6 +44,7 @@ class CommConversation(Base, TenantMixin, OwnedMixin):
|
||||
locked_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
created_by_type: Mapped[str] = mapped_column(String(20), nullable=False, default="user")
|
||||
last_msg_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -1135,3 +1135,205 @@ async def create_plugin_room(
|
||||
|
||||
parts = [plugin_p, user_p]
|
||||
return conversation_to_response(conv, parts, is_pinned_by_user=True)
|
||||
|
||||
|
||||
|
||||
# ─── System Channel ───
|
||||
|
||||
|
||||
async def get_or_create_system_channel(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> CommConversation:
|
||||
"""Get or create the tenant-wide system channel.
|
||||
|
||||
The system channel is a locked, is_system=True conversation that serves as
|
||||
the central destination for system notifications, user alerts, and agent messages.
|
||||
All users of the tenant are automatically added as participants.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(CommConversation).where(
|
||||
CommConversation.tenant_id == tenant_id,
|
||||
CommConversation.is_system.is_(True),
|
||||
CommConversation.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if conv is not None:
|
||||
return conv
|
||||
|
||||
# Create the system channel
|
||||
conv = CommConversation(
|
||||
tenant_id=tenant_id,
|
||||
title="System Channel",
|
||||
is_pinned=False,
|
||||
is_locked=True,
|
||||
is_direct=False,
|
||||
is_archived=False,
|
||||
is_system=True,
|
||||
created_by=None,
|
||||
created_by_type="system",
|
||||
metadata_={},
|
||||
)
|
||||
db.add(conv)
|
||||
await db.flush()
|
||||
|
||||
# Add all tenant users as participants
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
users_result = await db.execute(
|
||||
select(User.id)
|
||||
.join(UserTenant, UserTenant.user_id == User.id)
|
||||
.where(UserTenant.tenant_id == tenant_id)
|
||||
)
|
||||
user_ids = [row[0] for row in users_result.all()]
|
||||
|
||||
for uid in user_ids:
|
||||
p = CommParticipant(
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conv.id,
|
||||
participant_id=uid,
|
||||
participant_type="user",
|
||||
role="member",
|
||||
)
|
||||
db.add(p)
|
||||
|
||||
await db.flush()
|
||||
return conv
|
||||
|
||||
|
||||
async def post_system_message(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
message_type: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
entity_type: str | None = None,
|
||||
entity_id: uuid.UUID | None = None,
|
||||
severity: str = "info",
|
||||
) -> CommMessage | None:
|
||||
"""Post a typed system message to the tenant system channel.
|
||||
|
||||
Creates a CommMessage in the system channel with:
|
||||
- A text block containing title + body
|
||||
- An action_card block with deep-link if entity_type/entity_id is set
|
||||
- Block/message metadata: notification_type, severity, entity_ref
|
||||
|
||||
Returns the created CommMessage, or None if the user has muted this type.
|
||||
"""
|
||||
# Check user preferences — reuse the notification preference system
|
||||
from app.models.notification import NotificationPreference, NotificationType
|
||||
|
||||
pref = await db.execute(
|
||||
select(NotificationPreference).where(
|
||||
and_(
|
||||
NotificationPreference.user_id == user_id,
|
||||
NotificationPreference.type_key == message_type,
|
||||
NotificationPreference.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
pref_row = pref.scalar_one_or_none()
|
||||
|
||||
if pref_row and not pref_row.is_enabled:
|
||||
return None
|
||||
|
||||
if not pref_row:
|
||||
type_def = await db.execute(
|
||||
select(NotificationType).where(NotificationType.type_key == message_type)
|
||||
)
|
||||
type_row = type_def.scalar_one_or_none()
|
||||
if type_row and not type_row.is_enabled_by_default:
|
||||
return None
|
||||
|
||||
# Get or create system channel
|
||||
conv = await get_or_create_system_channel(db, tenant_id)
|
||||
|
||||
# Build message content
|
||||
content = title
|
||||
if body:
|
||||
content = f"{title}\n{body}"
|
||||
|
||||
# Build metadata
|
||||
msg_metadata: dict[str, Any] = {
|
||||
"notification_type": message_type,
|
||||
"severity": severity,
|
||||
"target_user_id": str(user_id),
|
||||
}
|
||||
if entity_type and entity_id:
|
||||
msg_metadata["entity_ref"] = {
|
||||
"entity_type": entity_type,
|
||||
"entity_id": str(entity_id),
|
||||
}
|
||||
|
||||
# Build blocks
|
||||
blocks: list[dict[str, Any]] = [
|
||||
{
|
||||
"block_type": "text",
|
||||
"block_data": {"text": content, "title": title, "body": body or ""},
|
||||
}
|
||||
]
|
||||
if entity_type and entity_id:
|
||||
blocks.append(
|
||||
{
|
||||
"block_type": "action_card",
|
||||
"block_data": {
|
||||
"label": "Open",
|
||||
"entity_type": entity_type,
|
||||
"entity_id": str(entity_id),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Create message directly (not via send_message to avoid trigger_depth issues)
|
||||
msg = CommMessage(
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conv.id,
|
||||
sender_id=None,
|
||||
sender_type="system",
|
||||
content=content,
|
||||
content_format="text",
|
||||
metadata_=msg_metadata,
|
||||
)
|
||||
db.add(msg)
|
||||
await db.flush()
|
||||
|
||||
# Create blocks
|
||||
for i, block in enumerate(blocks):
|
||||
b = CommMessageBlock(
|
||||
tenant_id=tenant_id,
|
||||
message_id=msg.id,
|
||||
block_type=block["block_type"],
|
||||
block_data=block["block_data"],
|
||||
sort_order=i,
|
||||
)
|
||||
db.add(b)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Update conversation last_msg
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
|
||||
await db.execute(
|
||||
update(CommConversation)
|
||||
.where(CommConversation.id == conv.id)
|
||||
.values(
|
||||
last_msg_at=datetime.now(dt_timezone.utc),
|
||||
last_msg_preview=content[:200],
|
||||
last_msg_sender_type="system",
|
||||
)
|
||||
)
|
||||
|
||||
# Publish event
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish("system.message.posted", {
|
||||
"conversation_id": str(conv.id),
|
||||
"message_id": str(msg.id),
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"message_type": message_type,
|
||||
"severity": severity,
|
||||
})
|
||||
|
||||
return msg
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Notification routes."""
|
||||
"""Notification routes (deprecated — delegates to Communication system channel).
|
||||
|
||||
All notification endpoints are deprecated as of B-NOTIF-DEPREC. New code should use
|
||||
the Communication system channel via post_system_message() instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
Reference in New Issue
Block a user