feat(phase-18): add in-app notification system (T-128, T-129)
This commit is contained in:
+1431
File diff suppressed because it is too large
Load Diff
+1475
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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