feat(phase-18): add in-app notification system (T-128, T-129)
This commit is contained in:
1431
AegisTestPlan_v2.md
Normal file
1431
AegisTestPlan_v2.md
Normal file
File diff suppressed because it is too large
Load Diff
1475
AegisTestPlan_v3.md
Normal file
1475
AegisTestPlan_v3.md
Normal file
File diff suppressed because it is too large
Load Diff
3989
aegiscompleteplan.md
Normal file
3989
aegiscompleteplan.md
Normal file
File diff suppressed because it is too large
Load Diff
46
backend/alembic/versions/b006_add_notifications_table.py
Normal file
46
backend/alembic/versions/b006_add_notifications_table.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""add_notifications_table
|
||||||
|
|
||||||
|
Revision ID: b006notifications
|
||||||
|
Revises: b005v2indexes
|
||||||
|
Create Date: 2026-02-09 11:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b006notifications'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'b005v2indexes'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create notifications table."""
|
||||||
|
op.create_table(
|
||||||
|
'notifications',
|
||||||
|
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||||
|
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('type', sa.String(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(), nullable=False),
|
||||||
|
sa.Column('message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('entity_type', sa.String(), nullable=True),
|
||||||
|
sa.Column('entity_id', UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column('read', sa.Boolean(), server_default='false'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index('ix_notifications_user_id', 'notifications', ['user_id'])
|
||||||
|
op.create_index('ix_notifications_read', 'notifications', ['read'])
|
||||||
|
op.create_index('ix_notifications_created_at', 'notifications', ['created_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop notifications table."""
|
||||||
|
op.drop_index('ix_notifications_created_at', table_name='notifications')
|
||||||
|
op.drop_index('ix_notifications_read', table_name='notifications')
|
||||||
|
op.drop_index('ix_notifications_user_id', table_name='notifications')
|
||||||
|
op.drop_table('notifications')
|
||||||
@@ -17,6 +17,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
|||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.services.mitre_sync_service import sync_mitre
|
from app.services.mitre_sync_service import sync_mitre
|
||||||
from app.services.intel_service import scan_intel
|
from app.services.intel_service import scan_intel
|
||||||
|
from app.services.notification_service import cleanup_old_notifications
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -45,6 +46,19 @@ def _run_mitre_sync() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_notification_cleanup() -> None:
|
||||||
|
"""Clean up old read notifications."""
|
||||||
|
logger.info("Scheduled notification cleanup job starting...")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
deleted = cleanup_old_notifications(db, days=90)
|
||||||
|
logger.info("Notification cleanup finished — deleted %d old notifications", deleted)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Notification cleanup job failed")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_intel_scan() -> None:
|
def _run_intel_scan() -> None:
|
||||||
"""Execute an intel scan inside its own DB session."""
|
"""Execute an intel scan inside its own DB session."""
|
||||||
logger.info("Scheduled intel scan job starting...")
|
logger.info("Scheduled intel scan job starting...")
|
||||||
@@ -89,5 +103,13 @@ def start_scheduler() -> None:
|
|||||||
name="Intel scan (every 7d)",
|
name="Intel scan (every 7d)",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_notification_cleanup,
|
||||||
|
trigger="interval",
|
||||||
|
hours=24,
|
||||||
|
id="notification_cleanup",
|
||||||
|
name="Notification cleanup (daily)",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Background scheduler started — mitre_sync (24h), intel_scan (7d)")
|
logger.info("Background scheduler started — mitre_sync (24h), intel_scan (7d), notification_cleanup (24h)")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.routers import system as system_router
|
|||||||
from app.routers import metrics as metrics_router
|
from app.routers import metrics as metrics_router
|
||||||
from app.routers import users as users_router
|
from app.routers import users as users_router
|
||||||
from app.routers import audit as audit_router
|
from app.routers import audit as audit_router
|
||||||
|
from app.routers import notifications as notifications_router
|
||||||
from app.storage import ensure_bucket_exists
|
from app.storage import ensure_bucket_exists
|
||||||
from app.jobs.mitre_sync_job import start_scheduler, scheduler
|
from app.jobs.mitre_sync_job import start_scheduler, scheduler
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ app.include_router(system_router.router, prefix="/api/v1")
|
|||||||
app.include_router(metrics_router.router, prefix="/api/v1")
|
app.include_router(metrics_router.router, prefix="/api/v1")
|
||||||
app.include_router(users_router.router, prefix="/api/v1")
|
app.include_router(users_router.router, prefix="/api/v1")
|
||||||
app.include_router(audit_router.router, prefix="/api/v1")
|
app.include_router(audit_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(notifications_router.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ from app.models.test_template import TestTemplate
|
|||||||
from app.models.evidence import Evidence
|
from app.models.evidence import Evidence
|
||||||
from app.models.intel import IntelItem
|
from app.models.intel import IntelItem
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.notification import Notification
|
||||||
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
|
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Technique", "Test", "TestTemplate", "Evidence",
|
"User", "Technique", "Test", "TestTemplate", "Evidence",
|
||||||
"IntelItem", "AuditLog",
|
"IntelItem", "AuditLog", "Notification",
|
||||||
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
||||||
]
|
]
|
||||||
|
|||||||
39
backend/app/models/notification.py
Normal file
39
backend/app/models/notification.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Notification model — in-app notifications for user actions."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Index
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
"""
|
||||||
|
In-app notification for alerting users when they need to act.
|
||||||
|
|
||||||
|
Types include: test_assigned, validation_needed, test_rejected,
|
||||||
|
test_validated, test_state_changed, etc.
|
||||||
|
"""
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
type = Column(String, nullable=False)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
message = Column(Text, nullable=True)
|
||||||
|
entity_type = Column(String, nullable=True)
|
||||||
|
entity_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
read = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_notifications_user_id", "user_id"),
|
||||||
|
Index("ix_notifications_read", "read"),
|
||||||
|
Index("ix_notifications_created_at", "created_at"),
|
||||||
|
)
|
||||||
103
backend/app/routers/notifications.py
Normal file
103
backend/app/routers/notifications.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Notification endpoints.
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
---------
|
||||||
|
GET /notifications — list user notifications (paginated)
|
||||||
|
GET /notifications/unread-count — count of unread notifications
|
||||||
|
PATCH /notifications/{id}/read — mark one notification as read
|
||||||
|
POST /notifications/read-all — mark all as read
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies.auth import get_current_user
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.notification import NotificationOut, UnreadCountOut
|
||||||
|
from app.services.notification_service import (
|
||||||
|
mark_as_read,
|
||||||
|
mark_all_as_read,
|
||||||
|
get_unread_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /notifications — list (paginated)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[NotificationOut])
|
||||||
|
def list_notifications(
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return paginated notifications for the current user, newest first."""
|
||||||
|
notifs = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(Notification.user_id == current_user.id)
|
||||||
|
.order_by(Notification.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return notifs
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /notifications/unread-count
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unread-count", response_model=UnreadCountOut)
|
||||||
|
def unread_count(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return the number of unread notifications for the current user."""
|
||||||
|
count = get_unread_count(db, current_user.id)
|
||||||
|
return UnreadCountOut(unread_count=count)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /notifications/{id}/read
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{notification_id}/read", response_model=NotificationOut)
|
||||||
|
def read_notification(
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Mark a single notification as read."""
|
||||||
|
success = mark_as_read(db, notification_id, current_user.id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Notification not found",
|
||||||
|
)
|
||||||
|
notif = db.query(Notification).filter(Notification.id == notification_id).first()
|
||||||
|
return notif
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /notifications/read-all
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/read-all")
|
||||||
|
def read_all_notifications(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Mark all notifications for the current user as read."""
|
||||||
|
count = mark_all_as_read(db, current_user.id)
|
||||||
|
return {"detail": f"Marked {count} notifications as read"}
|
||||||
28
backend/app/schemas/notification.py
Normal file
28
backend/app/schemas/notification.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Pydantic schemas for Notification endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationOut(BaseModel):
|
||||||
|
"""Notification returned by the API."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
message: str | None = None
|
||||||
|
entity_type: str | None = None
|
||||||
|
entity_id: uuid.UUID | None = None
|
||||||
|
read: bool = False
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadCountOut(BaseModel):
|
||||||
|
"""Simple counter response."""
|
||||||
|
|
||||||
|
unread_count: int
|
||||||
179
backend/app/services/notification_service.py
Normal file
179
backend/app/services/notification_service.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""Notification service — create, read, and manage in-app notifications.
|
||||||
|
|
||||||
|
Provides helpers for generating notifications automatically when test
|
||||||
|
state changes occur, plus CRUD for the notifications API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def create_notification(
|
||||||
|
db: Session,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
type: str,
|
||||||
|
title: str,
|
||||||
|
message: str | None = None,
|
||||||
|
entity_type: str | None = None,
|
||||||
|
entity_id: uuid.UUID | None = None,
|
||||||
|
) -> Notification:
|
||||||
|
"""Create a single notification for a user."""
|
||||||
|
notif = Notification(
|
||||||
|
user_id=user_id,
|
||||||
|
type=type,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
)
|
||||||
|
db.add(notif)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(notif)
|
||||||
|
return notif
|
||||||
|
|
||||||
|
|
||||||
|
def mark_as_read(db: Session, notification_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
||||||
|
"""Mark a single notification as read. Returns True if updated."""
|
||||||
|
notif = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(Notification.id == notification_id, Notification.user_id == user_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if notif is None:
|
||||||
|
return False
|
||||||
|
notif.read = True
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def mark_all_as_read(db: Session, user_id: uuid.UUID) -> int:
|
||||||
|
"""Mark all unread notifications for a user as read. Returns count updated."""
|
||||||
|
count = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(Notification.user_id == user_id, Notification.read == False) # noqa: E712
|
||||||
|
.update({"read": True})
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def get_unread_count(db: Session, user_id: uuid.UUID) -> int:
|
||||||
|
"""Return the number of unread notifications for a user."""
|
||||||
|
return (
|
||||||
|
db.query(func.count(Notification.id))
|
||||||
|
.filter(Notification.user_id == user_id, Notification.read == False) # noqa: E712
|
||||||
|
.scalar()
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_notifications(db: Session, days: int = 90) -> int:
|
||||||
|
"""Delete read notifications older than *days*. Returns count deleted."""
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||||
|
count = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(
|
||||||
|
Notification.read == True, # noqa: E712
|
||||||
|
Notification.created_at < cutoff,
|
||||||
|
)
|
||||||
|
.delete()
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Automatic notification dispatchers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def notify_test_state_change(db: Session, test, new_state: str) -> None:
|
||||||
|
"""Dispatch notifications based on a test's new state.
|
||||||
|
|
||||||
|
Called by the workflow service after each state transition.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- red_executing -> notify creator (confirmation)
|
||||||
|
- blue_evaluating -> notify all blue_tech users
|
||||||
|
- in_review -> notify red_lead and blue_lead users
|
||||||
|
- rejected -> notify creator
|
||||||
|
- validated -> notify creator
|
||||||
|
"""
|
||||||
|
test_name = test.name
|
||||||
|
test_id = test.id
|
||||||
|
creator_id = test.created_by
|
||||||
|
|
||||||
|
if new_state == "red_executing" and creator_id:
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id=creator_id,
|
||||||
|
type="test_state_changed",
|
||||||
|
title="Test execution started",
|
||||||
|
message=f'Your test "{test_name}" has moved to execution phase.',
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif new_state == "blue_evaluating":
|
||||||
|
# Notify all blue_tech users
|
||||||
|
blue_users = db.query(User).filter(User.role == "blue_tech", User.is_active == True).all() # noqa: E712
|
||||||
|
for user in blue_users:
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
type="test_assigned",
|
||||||
|
title="New test ready for blue evaluation",
|
||||||
|
message=f'Test "{test_name}" needs blue team evaluation.',
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif new_state == "in_review":
|
||||||
|
# Notify red_lead and blue_lead users
|
||||||
|
managers = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.role.in_(["red_lead", "blue_lead"]), User.is_active == True) # noqa: E712
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for user in managers:
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
type="validation_needed",
|
||||||
|
title="Test ready for validation",
|
||||||
|
message=f'Test "{test_name}" is awaiting your review.',
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif new_state == "rejected" and creator_id:
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id=creator_id,
|
||||||
|
type="test_rejected",
|
||||||
|
title="Test rejected",
|
||||||
|
message=f'Your test "{test_name}" has been rejected. Please review and resubmit.',
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif new_state == "validated" and creator_id:
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id=creator_id,
|
||||||
|
type="test_validated",
|
||||||
|
title="Test validated",
|
||||||
|
message=f'Your test "{test_name}" has been validated successfully.',
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test_id,
|
||||||
|
)
|
||||||
@@ -20,6 +20,7 @@ from app.models.enums import TestState
|
|||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
|
from app.services.notification_service import notify_test_state_change
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Valid transition map
|
# Valid transition map
|
||||||
@@ -91,6 +92,12 @@ def transition_state(
|
|||||||
details=details,
|
details=details,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Dispatch in-app notifications for the new state
|
||||||
|
try:
|
||||||
|
notify_test_state_change(db, test, target_state.value)
|
||||||
|
except Exception:
|
||||||
|
pass # Notifications are best-effort — don't block the workflow
|
||||||
|
|
||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
@@ -250,9 +257,17 @@ def check_dual_validation(db: Session, test: Test) -> Test:
|
|||||||
if red_status == "rejected" or blue_status == "rejected":
|
if red_status == "rejected" or blue_status == "rejected":
|
||||||
test.state = TestState.rejected
|
test.state = TestState.rejected
|
||||||
db.commit()
|
db.commit()
|
||||||
|
try:
|
||||||
|
notify_test_state_change(db, test, "rejected")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
elif red_status == "approved" and blue_status == "approved":
|
elif red_status == "approved" and blue_status == "approved":
|
||||||
test.state = TestState.validated
|
test.state = TestState.validated
|
||||||
db.commit()
|
db.commit()
|
||||||
|
try:
|
||||||
|
notify_test_state_change(db, test, "validated")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
# One side hasn't voted yet — stay in_review, just flush
|
# One side hasn't voted yet — stay in_review, just flush
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
51
frontend/src/api/notifications.ts
Normal file
51
frontend/src/api/notifications.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import client from "./client";
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string | null;
|
||||||
|
entity_type: string | null;
|
||||||
|
entity_id: string | null;
|
||||||
|
read: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnreadCount {
|
||||||
|
unread_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fetch notifications for the current user (paginated). */
|
||||||
|
export async function getNotifications(
|
||||||
|
offset = 0,
|
||||||
|
limit = 20,
|
||||||
|
): Promise<NotificationItem[]> {
|
||||||
|
const { data } = await client.get<NotificationItem[]>(
|
||||||
|
`/notifications?offset=${offset}&limit=${limit}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the unread notification count. */
|
||||||
|
export async function getUnreadCount(): Promise<UnreadCount> {
|
||||||
|
const { data } = await client.get<UnreadCount>("/notifications/unread-count");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a single notification as read. */
|
||||||
|
export async function markAsRead(id: string): Promise<NotificationItem> {
|
||||||
|
const { data } = await client.patch<NotificationItem>(
|
||||||
|
`/notifications/${id}/read`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark all notifications as read. */
|
||||||
|
export async function markAllAsRead(): Promise<void> {
|
||||||
|
await client.post("/notifications/read-all");
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Outlet } from "react-router-dom";
|
|||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
|
import NotificationBell from "./NotificationBell";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
@@ -13,6 +14,7 @@ export default function Layout() {
|
|||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6">
|
<header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6">
|
||||||
|
<NotificationBell />
|
||||||
<span className="text-sm text-gray-300">{user?.username}</span>
|
<span className="text-sm text-gray-300">{user?.username}</span>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
|
|||||||
53
frontend/src/components/NotificationBell.tsx
Normal file
53
frontend/src/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import { getUnreadCount } from "../api/notifications";
|
||||||
|
import NotificationDropdown from "./NotificationDropdown";
|
||||||
|
|
||||||
|
export default function NotificationBell() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["notifications", "unread-count"],
|
||||||
|
queryFn: getUnreadCount,
|
||||||
|
refetchInterval: 30000, // Poll every 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = data?.unread_count ?? 0;
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(!open);
|
||||||
|
if (!open) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
|
||||||
|
{count > 99 ? "99+" : count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && <NotificationDropdown onClose={() => setOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/src/components/NotificationDropdown.tsx
Normal file
139
frontend/src/components/NotificationDropdown.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
CheckCheck,
|
||||||
|
FlaskConical,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Bell,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
getNotifications,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
type NotificationItem,
|
||||||
|
} from "../api/notifications";
|
||||||
|
|
||||||
|
const typeIcons: Record<string, React.ReactNode> = {
|
||||||
|
test_assigned: <FlaskConical className="h-4 w-4 text-indigo-400" />,
|
||||||
|
validation_needed: <AlertTriangle className="h-4 w-4 text-yellow-400" />,
|
||||||
|
test_rejected: <XCircle className="h-4 w-4 text-red-400" />,
|
||||||
|
test_validated: <CheckCircle className="h-4 w-4 text-green-400" />,
|
||||||
|
test_state_changed: <Bell className="h-4 w-4 text-cyan-400" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationDropdown({ onClose }: { onClose: () => void }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: notifications, isLoading } = useQuery({
|
||||||
|
queryKey: ["notifications", "list"],
|
||||||
|
queryFn: () => getNotifications(0, 20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markReadMutation = useMutation({
|
||||||
|
mutationFn: markAsRead,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const markAllMutation = useMutation({
|
||||||
|
mutationFn: markAllAsRead,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = (notif: NotificationItem) => {
|
||||||
|
if (!notif.read) {
|
||||||
|
markReadMutation.mutate(notif.id);
|
||||||
|
}
|
||||||
|
if (notif.entity_type === "test" && notif.entity_id) {
|
||||||
|
navigate(`/tests/${notif.entity_id}`);
|
||||||
|
} else if (notif.entity_type === "technique" && notif.entity_id) {
|
||||||
|
navigate(`/techniques/${notif.entity_id}`);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMin < 1) return "just now";
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
const diffH = Math.floor(diffMin / 60);
|
||||||
|
if (diffH < 24) return `${diffH}h ago`;
|
||||||
|
const diffD = Math.floor(diffH / 24);
|
||||||
|
return `${diffD}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-gray-800 bg-gray-900 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Notifications</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => markAllMutation.mutate()}
|
||||||
|
disabled={markAllMutation.isPending}
|
||||||
|
className="flex items-center gap-1 text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-3.5 w-3.5" />
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
) : notifications && notifications.length > 0 ? (
|
||||||
|
notifications.map((notif) => (
|
||||||
|
<button
|
||||||
|
key={notif.id}
|
||||||
|
onClick={() => handleClick(notif)}
|
||||||
|
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-gray-800/50 ${
|
||||||
|
!notif.read ? "bg-cyan-500/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 flex-shrink-0">
|
||||||
|
{typeIcons[notif.type] || <Bell className="h-4 w-4 text-gray-400" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className={`text-sm ${
|
||||||
|
notif.read ? "text-gray-400" : "font-medium text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{notif.title}
|
||||||
|
</p>
|
||||||
|
{notif.message && (
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 truncate">
|
||||||
|
{notif.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-[10px] text-gray-600">
|
||||||
|
{formatTime(notif.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!notif.read && (
|
||||||
|
<div className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-cyan-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-sm text-gray-500">
|
||||||
|
No notifications yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user