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
This commit is contained in:
Yogesh Ojha
2026-09-06 18:13:08 +05:30
co-authored by Claude Opus 5
parent 1275d31458
commit ed5f8f3f16
20 changed files with 623 additions and 459 deletions
+37 -11
View File
@@ -1,6 +1,7 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy import func, select
@@ -23,17 +24,36 @@ router = APIRouter(
tags=["notifications"],
)
ProjectScope = Annotated[
UUID | None,
Query(description="Only this project's notifications, plus global ones"),
]
def _in_scope(query, project_id: UUID | None):
if project_id is None:
return query
return query.where(
(Notification.project_id == project_id) | Notification.project_id.is_(None)
)
@router.get("/stats", response_model=NotificationStats)
async def get_notification_stats(
_current_user: CurrentUser,
session: Annotated[AsyncSession, Depends(get_session)],
project_id: ProjectScope = None,
):
total_result = await session.execute(select(func.count(Notification.id)))
total_result = await session.execute(
_in_scope(select(func.count(Notification.id)), project_id)
)
total = total_result.scalar_one()
unread_result = await session.execute(
select(func.count(Notification.id)).where(Notification.is_read.is_(False))
_in_scope(
select(func.count(Notification.id)).where(Notification.is_read.is_(False)),
project_id,
)
)
unread = unread_result.scalar_one()
@@ -45,8 +65,11 @@ async def get_notification_stats(
async def list_notifications(
_current_user: CurrentUser,
session: Annotated[AsyncSession, Depends(get_session)],
project_id: ProjectScope = None,
):
query = select(Notification).order_by(Notification.created_at.desc())
query = _in_scope(select(Notification), project_id).order_by(
Notification.created_at.desc()
)
return await paginate(session, query)
@@ -54,12 +77,11 @@ async def list_notifications(
async def list_unread_notifications(
_current_user: CurrentUser,
session: Annotated[AsyncSession, Depends(get_session)],
project_id: ProjectScope = None,
):
query = (
select(Notification)
.where(Notification.is_read.is_(False))
.order_by(Notification.created_at.desc())
)
query = _in_scope(
select(Notification).where(Notification.is_read.is_(False)), project_id
).order_by(Notification.created_at.desc())
return await paginate(session, query)
@@ -105,8 +127,11 @@ async def mark_notification_as_read(
async def mark_all_notifications_as_read(
_current_user: CurrentUser,
session: Annotated[AsyncSession, Depends(get_session)],
project_id: ProjectScope = None,
):
count = await NotificationManager.mark_all_as_read(session=session)
count = await NotificationManager.mark_all_as_read(
session=session, project_id=project_id
)
return {
"success": True,
@@ -137,8 +162,9 @@ async def delete_notification(
async def clear_all_notifications(
_current_user: CurrentUser,
session: Annotated[AsyncSession, Depends(get_session)],
project_id: ProjectScope = None,
):
await NotificationManager.clear_all(session=session)
await NotificationManager.clear_all(session=session, project_id=project_id)
if settings.DEBUG:
-15
View File
@@ -17,7 +17,6 @@ from app.services.scan_context import ScanContextService
from app.services.scan_engine import ScanEngineService, stage_effects
from app.services.target import TargetService
from shared.config import BaseAppSettings
from shared.definitions.notifications import scan_cancelled
from shared.definitions.rescan import ASSET_SEED_STAGE, rescan_label
from shared.enums.api_key import APIProvider
from shared.enums.scan import SCAN_LIVE_STATUSES, ScanActivityStatus, ScanStatus
@@ -52,7 +51,6 @@ from shared.models.subdomain import Subdomain
from shared.models.target import Target
from shared.services.celery_dispatch import revoke_scan_tasks
from shared.services.launch_plan import AdHocEngine, plan_label
from shared.services.notification import NotificationManager
from shared.services.orchestrator.events import ScanEventPublisher
from shared.services.scan_factory import build_scan_row
from shared.services.scan_resolve import (
@@ -1122,19 +1120,6 @@ class ScanService:
return self._to_read(scan)
async def _announce_cancelled(self, scan: Scan) -> None:
target_value = (scan.execution_config or {}).get("target_value", "")
payload = scan_cancelled(str(scan.id), target_value, scan.engine_name)
try:
await NotificationManager.publish(
session=self.session,
type=payload["type"],
severity=payload["severity"],
title=payload["title"],
message=payload["message"],
metadata=payload.get("metadata"),
)
except Exception:
logger.warning("cancel notification dispatch failed", exc_info=True)
try:
ScanEventPublisher(
BaseAppSettings().redis_url,