Files
rengine/shared/services/notification_sync.py
T
Yogesh OjhaandClaude Opus 5 ed5f8f3f16 feat(notifications): a notification is an interrupt, the activity log is the ledger
The in-app feed was a second copy of the activity log with a red badge on it.
A scan published up to six notifications (started, completed, new subdomains,
new services, new vulnerabilities, coverage), every one of them already an
activity_logs row and every one of them toasted for five seconds. A nightly
twenty-target schedule left forty to a hundred and twenty unread rows.

If it does not want a human to do something, it is not a notification.

Deleted scan_started, scan_completed and scan_cancelled (both the API-side echo
of the user's own Cancel click and the finalize one). whois/ripestat
enrichment_complete became enrichment_incomplete and return None unless
something actually failed.

One digest replaces the five scan templates. scan_digest(...) -> dict | None
takes a ScanDeltas and derives its severity, title, type and deep-link tab from
the worst thing inside it, and returns None when nothing changed, so a quiet
scheduled run is silent. Severity now encodes risk rather than process:
scan_failed is warning, not error, because the bell takes its colour from the
highest unread severity and one flaky run should not read like a KEV.

Toasts are interrupts only: warning and error toast, success and info land
silently in the bell. A toast with a url finally gets a View action; it used to
require an action_label no template ever set.

notifications.project_id scopes the bell to the active project (nullable and
indexed, no FK so a transient row can never block a project delete). The list,
stats, read-all and clear-all endpoints take ?project_id=, and the store drops
SSE events from other projects.

app.tasks.notifications.cleanup runs on beat every six hours. expires_at had
been decorative since nothing ever called cleanup_expired, and the inbox modal
fetched stats.total rows in a single request.

Three bugs this uncovered:

_count_new_subdomains had no baseline gate while services and vulnerabilities
did, so a target's first scan announced its whole inventory as new: the first
gov.np run would have reported 24022 new subdomains. Baselines are now per
dimension, and a run with none reports the census instead.

The delta counts ignored census_only, so a focused rescan both counted as a
baseline and had its own rows announced. Every comparison now joins
scans bs ... scope = 'full' AND bs.started_at < :started.

Measuring the deltas took 16.8s on a 13,528-host scan: the scan-level baseline
was a correlated EXISTS re-evaluated once per row and could not use
ix_subdomains_target_name_discovered, having no name predicate. Hoisting it to
one scan-level check and rewriting the per-row test as a hash anti-join over a
WITH seen AS (SELECT DISTINCT ...) CTE gives the identical answer in 52ms,
verified against a plain EXCEPT query.

Not done, deliberately: is_read is still one global flag, so on a shared
instance one analyst's Mark all as read still applies to everyone; that needs a
notification_reads table. And notifier.wants() still governs external channels
only, the bell having no preference layer, because volume reduction was the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCPTXCr5qPESCt3LxR3329
2026-09-06 18:13:08 +05:30

92 lines
3.0 KiB
Python

import logging
import uuid
from datetime import timedelta
from sqlalchemy.orm import Session
from shared.enums.notification import NotificationSeverity, NotificationType
from shared.enums.sse import SSEChannel, SSEEventType
from shared.models.notification import Notification, NotificationMetadata
from shared.services.event_publisher import SyncEventPublisher
from shared.utils.datetime import utc_now
logger = logging.getLogger(__name__)
NOTIFICATION_EXPIRY_DAYS = 7
def single_project(rows) -> uuid.UUID | None:
ids = {r.project_id for r in rows if getattr(r, "project_id", None)}
return next(iter(ids)) if len(ids) == 1 else None
class SyncNotificationPublisher:
def __init__(self, redis_url: str) -> None:
self._event_publisher = SyncEventPublisher(redis_url)
def publish(
self,
session: Session,
type: NotificationType,
severity: NotificationSeverity,
title: str,
message: str,
metadata: NotificationMetadata | dict | None = None,
project_id: uuid.UUID | str | None = None,
) -> Notification:
if isinstance(metadata, NotificationMetadata):
metadata_dict = metadata.model_dump(exclude_none=True)
elif isinstance(metadata, dict):
validated = NotificationMetadata(**metadata)
metadata_dict = validated.model_dump(exclude_none=True)
else:
metadata_dict = {}
now = utc_now()
notification = Notification(
type=type,
severity=severity,
title=title[:200],
message=message,
notification_metadata=metadata_dict,
project_id=uuid.UUID(str(project_id)) if project_id else None,
created_at=now,
expires_at=now + timedelta(days=NOTIFICATION_EXPIRY_DAYS),
)
session.add(notification)
session.commit()
session.refresh(notification)
self._event_publisher.publish(
channel=SSEChannel.BROADCAST,
event_type=SSEEventType.NOTIFICATION,
data={
"id": notification.id,
"project_id": str(notification.project_id)
if notification.project_id
else None,
"type": notification.type.value,
"severity": notification.severity.value,
"title": notification.title,
"message": notification.message,
"notification_metadata": notification.notification_metadata,
"is_read": notification.is_read,
"created_at": notification.created_at.isoformat(),
},
)
logger.info(
"Published notification: %s/%s - %s", type.value, severity.value, title
)
try:
from shared.services.notifier import dispatch_sync # noqa: PLC0415
dispatch_sync(session, type, severity, title, message)
except Exception as exc:
logger.warning("External notification dispatch failed: %s", exc)
return notification