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.services.mitre_sync_service import sync_mitre
|
||||
from app.services.intel_service import scan_intel
|
||||
from app.services.notification_service import cleanup_old_notifications
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +46,19 @@ def _run_mitre_sync() -> None:
|
||||
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:
|
||||
"""Execute an intel scan inside its own DB session."""
|
||||
logger.info("Scheduled intel scan job starting...")
|
||||
@@ -89,5 +103,13 @@ def start_scheduler() -> None:
|
||||
name="Intel scan (every 7d)",
|
||||
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()
|
||||
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 users as users_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.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(users_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")
|
||||
|
||||
@@ -6,10 +6,11 @@ from app.models.test_template import TestTemplate
|
||||
from app.models.evidence import Evidence
|
||||
from app.models.intel import IntelItem
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
|
||||
|
||||
__all__ = [
|
||||
"User", "Technique", "Test", "TestTemplate", "Evidence",
|
||||
"IntelItem", "AuditLog",
|
||||
"IntelItem", "AuditLog", "Notification",
|
||||
"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.user import User
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.notification_service import notify_test_state_change
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Valid transition map
|
||||
@@ -91,6 +92,12 @@ def transition_state(
|
||||
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
|
||||
|
||||
|
||||
@@ -250,9 +257,17 @@ def check_dual_validation(db: Session, test: Test) -> Test:
|
||||
if red_status == "rejected" or blue_status == "rejected":
|
||||
test.state = TestState.rejected
|
||||
db.commit()
|
||||
try:
|
||||
notify_test_state_change(db, test, "rejected")
|
||||
except Exception:
|
||||
pass
|
||||
elif red_status == "approved" and blue_status == "approved":
|
||||
test.state = TestState.validated
|
||||
db.commit()
|
||||
try:
|
||||
notify_test_state_change(db, test, "validated")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# One side hasn't voted yet — stay in_review, just flush
|
||||
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 { useAuth } from "../context/AuthContext";
|
||||
import Sidebar from "./Sidebar";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -13,6 +14,7 @@ export default function Layout() {
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<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>
|
||||
<button
|
||||
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