From ed5f8f3f1663a09eb5fefa9918e13daf5b37ea14 Mon Sep 17 00:00:00 2001 From: Yogesh Ojha Date: Sun, 6 Sep 2026 18:13:08 +0530 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01BCPTXCr5qPESCt3LxR3329 --- .../2026_09_06_notification_project.py | 36 ++ api/app/api/v1/notifications.py | 48 +- api/app/services/scan.py | 15 - frontend/src/lib/api/notifications.ts | 36 +- .../layout/notifications-menu.svelte | 42 +- .../notifications/notification-toasts.svelte | 34 +- .../src/lib/stores/notifications.svelte.ts | 35 +- frontend/src/lib/types/notification.ts | 1 + frontend/src/routes/(app)/+layout.svelte | 14 +- shared/definitions/notifications.py | 416 +++++++++--------- shared/models/notification.py | 3 + shared/services/notification.py | 29 +- shared/services/notification_sync.py | 15 +- worker/app/celery.py | 6 + worker/app/orchestrator/finalize.py | 241 +++++----- worker/app/tasks/notifications.py | 24 + worker/app/tasks/reports.py | 1 + worker/app/tasks/ripestat.py | 31 +- worker/app/tasks/scan.py | 24 +- worker/app/tasks/whois.py | 31 +- 20 files changed, 623 insertions(+), 459 deletions(-) create mode 100644 alembic/versions/2026_09_06_notification_project.py create mode 100644 worker/app/tasks/notifications.py diff --git a/alembic/versions/2026_09_06_notification_project.py b/alembic/versions/2026_09_06_notification_project.py new file mode 100644 index 000000000..9ecc81667 --- /dev/null +++ b/alembic/versions/2026_09_06_notification_project.py @@ -0,0 +1,36 @@ +"""notifications: scope a notification to its project + +Revision ID: c4b8e1f70a92 +Revises: a91d47c30fb2 +Create Date: 2026-09-06 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "c4b8e1f70a92" +down_revision: str | None = "a91d47c30fb2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("notifications", sa.Column("project_id", sa.Uuid(), nullable=True)) + op.create_index("ix_notifications_project_id", "notifications", ["project_id"]) + op.execute( + """ + UPDATE notifications n + SET project_id = s.project_id + FROM scans s + WHERE n.notification_metadata->>'scan_id' IS NOT NULL + AND s.id = (n.notification_metadata->>'scan_id')::uuid + """ + ) + + +def downgrade() -> None: + op.drop_index("ix_notifications_project_id", table_name="notifications") + op.drop_column("notifications", "project_id") diff --git a/api/app/api/v1/notifications.py b/api/app/api/v1/notifications.py index 155a976f3..0fbf7b6ce 100644 --- a/api/app/api/v1/notifications.py +++ b/api/app/api/v1/notifications.py @@ -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: diff --git a/api/app/services/scan.py b/api/app/services/scan.py index bbdc26136..bf0a6abee 100644 --- a/api/app/services/scan.py +++ b/api/app/services/scan.py @@ -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, diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts index 32d4abfdd..1f625df35 100644 --- a/frontend/src/lib/api/notifications.ts +++ b/frontend/src/lib/api/notifications.ts @@ -2,28 +2,40 @@ import { api } from './client'; import type { Notification, NotificationStats } from '$lib/types/notification'; import type { PaginatedResponse } from '$lib/types/pagination'; -export const notificationsApi = { - list: (page = 1, size = 20): Promise> => { - return api.get>(`/notifications?page=${page}&size=${size}`); - }, +const scope = (projectId?: string) => (projectId ? `&project_id=${projectId}` : ''); - listUnread: (page = 1, size = 20): Promise> => { +export const notificationsApi = { + list: (page = 1, size = 20, projectId?: string): Promise> => { return api.get>( - `/notifications/unread?page=${page}&size=${size}` + `/notifications?page=${page}&size=${size}${scope(projectId)}` ); }, - stats: (): Promise => { - return api.get('/notifications/stats'); + listUnread: ( + page = 1, + size = 20, + projectId?: string + ): Promise> => { + return api.get>( + `/notifications/unread?page=${page}&size=${size}${scope(projectId)}` + ); + }, + + stats: (projectId?: string): Promise => { + return api.get( + `/notifications/stats${projectId ? `?project_id=${projectId}` : ''}` + ); }, markAsRead: (id: number): Promise<{ success: boolean; message: string }> => { return api.patch<{ success: boolean; message: string }>(`/notifications/${id}/read`, {}); }, - markAllAsRead: (): Promise<{ success: boolean; message: string; count: number }> => { + markAllAsRead: ( + projectId?: string + ): Promise<{ success: boolean; message: string; count: number }> => { return api.post<{ success: boolean; message: string; count: number }>( - '/notifications/read-all', + `/notifications/read-all${projectId ? `?project_id=${projectId}` : ''}`, {} ); }, @@ -32,7 +44,7 @@ export const notificationsApi = { return api.delete(`/notifications/${id}`); }, - clearAll: (): Promise => { - return api.delete('/notifications'); + clearAll: (projectId?: string): Promise => { + return api.delete(`/notifications${projectId ? `?project_id=${projectId}` : ''}`); } }; diff --git a/frontend/src/lib/components/layout/notifications-menu.svelte b/frontend/src/lib/components/layout/notifications-menu.svelte index 86649ed26..192086caa 100644 --- a/frontend/src/lib/components/layout/notifications-menu.svelte +++ b/frontend/src/lib/components/layout/notifications-menu.svelte @@ -65,6 +65,14 @@ return counts; }); + const presentTypes = $derived(NOTIFICATION_TYPES.filter((t) => typeCounts[t] > 0)); + + $effect(() => { + if (selectedFilter !== 'all' && !presentTypes.includes(selectedFilter)) { + selectedFilter = 'all'; + } + }); + function onPopoverChange(open: boolean) { popoverOpen = open; if (open) tab = notificationStore.unreadCount > 0 ? 'unread' : 'all'; @@ -116,7 +124,7 @@ try { await notificationStore.deleteNotification(id); } catch { - toast.error("Couldn't delete notification — try again"); + toast.error('Notification could not be deleted'); } }; @@ -124,7 +132,7 @@ try { await notificationStore.markAllAsRead(); } catch { - toast.error("Couldn't mark notifications as read — try again"); + toast.error('Notifications could not be marked read'); } }; @@ -134,7 +142,7 @@ await notificationStore.clearAll(); clearAllOpen = false; } catch { - toast.error("Couldn't clear notifications — try again"); + toast.error('Notifications could not be cleared'); } finally { clearing = false; } @@ -156,7 +164,7 @@ goto(ROUTES.settings('notifications')); }; - const retryLoad = () => notificationStore.loadNotifications(); + const retryLoad = () => notificationStore.loadNotifications(notificationStore.projectId); {#snippet skeletonRows(count: number)} @@ -271,7 +279,7 @@ - Couldn't load notifications + Notifications could not be loaded @@ -288,12 +296,12 @@ {/if} - {tab === 'unread' ? "You're all caught up" : 'No notifications yet'} + {tab === 'unread' ? 'Nothing unread' : 'No notifications yet'} {tab === 'unread' - ? 'New alerts and scan results land here.' - : 'Scan results, findings and system events will appear here.'} + ? 'Every notification has been read.' + : 'Scan results, findings and system events appear here.'} @@ -397,16 +405,14 @@ {typeCounts.all} - {#each NOTIFICATION_TYPES as type (type)} + {#each presentTypes as type (type)} {@const TypeIcon = getTypeIcon(type)} {NOTIFICATION_TYPE_LABELS[type]} - {#if typeCounts[type] > 0} - - {typeCounts[type]} - - {/if} + + {typeCounts[type]} + {/each} @@ -431,10 +437,8 @@ - Couldn't load notifications - Something went wrong fetching your notifications. + Notifications could not be loaded + The request did not complete. Retry below.