mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
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
190 lines
5.6 KiB
Python
190 lines
5.6 KiB
Python
from typing import Annotated
|
|
from uuid import UUID
|
|
|
|
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
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import CurrentSuperuser, CurrentUser
|
|
from app.config import settings
|
|
from app.core.database import get_session
|
|
from app.debug.notifications import DEBUG_NOTIFICATION_TEMPLATES
|
|
from shared.models.notification import (
|
|
Notification,
|
|
NotificationCreate,
|
|
NotificationRead,
|
|
NotificationStats,
|
|
)
|
|
from shared.services.notification import NotificationManager
|
|
|
|
router = APIRouter(
|
|
prefix="/notifications",
|
|
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(
|
|
_in_scope(select(func.count(Notification.id)), project_id)
|
|
)
|
|
total = total_result.scalar_one()
|
|
|
|
unread_result = await session.execute(
|
|
_in_scope(
|
|
select(func.count(Notification.id)).where(Notification.is_read.is_(False)),
|
|
project_id,
|
|
)
|
|
)
|
|
|
|
unread = unread_result.scalar_one()
|
|
|
|
return NotificationStats(total=total, unread=unread)
|
|
|
|
|
|
@router.get("", response_model=Page[NotificationRead])
|
|
async def list_notifications(
|
|
_current_user: CurrentUser,
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
project_id: ProjectScope = None,
|
|
):
|
|
query = _in_scope(select(Notification), project_id).order_by(
|
|
Notification.created_at.desc()
|
|
)
|
|
return await paginate(session, query)
|
|
|
|
|
|
@router.get("/unread", response_model=Page[NotificationRead])
|
|
async def list_unread_notifications(
|
|
_current_user: CurrentUser,
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
project_id: ProjectScope = None,
|
|
):
|
|
query = _in_scope(
|
|
select(Notification).where(Notification.is_read.is_(False)), project_id
|
|
).order_by(Notification.created_at.desc())
|
|
return await paginate(session, query)
|
|
|
|
|
|
@router.post("", response_model=NotificationRead, status_code=status.HTTP_201_CREATED)
|
|
async def create_notification(
|
|
notification_in: NotificationCreate,
|
|
_current_user: CurrentSuperuser,
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
notification = await NotificationManager.publish(
|
|
session=session,
|
|
type=notification_in.type,
|
|
severity=notification_in.severity,
|
|
title=notification_in.title,
|
|
message=notification_in.message,
|
|
metadata=notification_in.notification_metadata,
|
|
)
|
|
|
|
return NotificationRead(**notification.model_dump())
|
|
|
|
|
|
@router.patch("/{notification_id}/read", response_model=dict)
|
|
async def mark_notification_as_read(
|
|
notification_id: int,
|
|
_current_user: CurrentUser,
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
updated = await NotificationManager.mark_as_read(
|
|
session=session,
|
|
notification_id=notification_id,
|
|
)
|
|
|
|
if not updated:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Notification not found",
|
|
)
|
|
|
|
return {"success": True, "message": "Notification marked as read"}
|
|
|
|
|
|
@router.post("/read-all", response_model=dict)
|
|
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, project_id=project_id
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Marked {count} notifications as read",
|
|
"count": count,
|
|
}
|
|
|
|
|
|
@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_notification(
|
|
notification_id: int,
|
|
_current_user: CurrentUser,
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
deleted = await NotificationManager.delete_notification(
|
|
session=session,
|
|
notification_id=notification_id,
|
|
)
|
|
|
|
if not deleted:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Notification not found",
|
|
)
|
|
|
|
|
|
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def clear_all_notifications(
|
|
_current_user: CurrentUser,
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
project_id: ProjectScope = None,
|
|
):
|
|
await NotificationManager.clear_all(session=session, project_id=project_id)
|
|
|
|
|
|
if settings.DEBUG:
|
|
import random
|
|
|
|
@router.post("/debug/publish-random", response_model=NotificationRead)
|
|
async def debug_publish_random_notification(
|
|
_current_user: CurrentUser,
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
template = random.choice(DEBUG_NOTIFICATION_TEMPLATES) # noqa: S311
|
|
|
|
notification = await NotificationManager.publish(
|
|
session=session,
|
|
type=template["type"],
|
|
severity=template["severity"],
|
|
title=template["title"],
|
|
message=template["message"],
|
|
metadata=template["metadata"],
|
|
)
|
|
|
|
return NotificationRead(**notification.model_dump())
|