feat: add admin notification system

- Add AdminNotificationService for notification operations
- Enhance notifications API with mark-read, mark-all-read endpoints
- Add notifications.html page template
- Add notifications.js Alpine component
- Add implementation documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-21 14:11:48 +01:00
parent 4dc08f87c5
commit 02edea7cb3
5 changed files with 1759 additions and 33 deletions

View File

@@ -15,12 +15,19 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.admin_notification_service import (
admin_notification_service,
platform_alert_service,
)
from models.database.user import User
from models.schema.admin import (
AdminNotificationCreate,
AdminNotificationListResponse,
AdminNotificationResponse,
PlatformAlertCreate,
PlatformAlertListResponse,
PlatformAlertResolve,
PlatformAlertResponse,
)
from models.schema.notification import (
AlertStatisticsResponse,
@@ -40,27 +47,115 @@ logger = logging.getLogger(__name__)
@router.get("", response_model=AdminNotificationListResponse)
def get_notifications(
priority: str | None = Query(None, description="Filter by priority"),
notification_type: str | None = Query(None, description="Filter by type"),
is_read: bool | None = Query(None, description="Filter by read status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
) -> AdminNotificationListResponse:
"""Get admin notifications with filtering."""
# TODO: Implement notification service
return AdminNotificationListResponse(
notifications=[], total=0, unread_count=0, skip=skip, limit=limit
notifications, total, unread_count = admin_notification_service.get_notifications(
db=db,
priority=priority,
is_read=is_read,
notification_type=notification_type,
skip=skip,
limit=limit,
)
return AdminNotificationListResponse(
notifications=[
AdminNotificationResponse(
id=n.id,
type=n.type,
priority=n.priority,
title=n.title,
message=n.message,
is_read=n.is_read,
read_at=n.read_at,
read_by_user_id=n.read_by_user_id,
action_required=n.action_required,
action_url=n.action_url,
metadata=n.notification_metadata,
created_at=n.created_at,
)
for n in notifications
],
total=total,
unread_count=unread_count,
skip=skip,
limit=limit,
)
@router.post("", response_model=AdminNotificationResponse)
def create_notification(
notification_data: AdminNotificationCreate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> AdminNotificationResponse:
"""Create a new admin notification (manual)."""
notification = admin_notification_service.create_from_schema(
db=db, data=notification_data
)
db.commit()
logger.info(f"Admin {current_admin.username} created notification: {notification.title}")
return AdminNotificationResponse(
id=notification.id,
type=notification.type,
priority=notification.priority,
title=notification.title,
message=notification.message,
is_read=notification.is_read,
read_at=notification.read_at,
read_by_user_id=notification.read_by_user_id,
action_required=notification.action_required,
action_url=notification.action_url,
metadata=notification.notification_metadata,
created_at=notification.created_at,
)
@router.get("/recent")
def get_recent_notifications(
limit: int = Query(5, ge=1, le=10),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> dict:
"""Get recent unread notifications for header dropdown."""
notifications = admin_notification_service.get_recent_notifications(
db=db, limit=limit
)
unread_count = admin_notification_service.get_unread_count(db)
return {
"notifications": [
{
"id": n.id,
"type": n.type,
"priority": n.priority,
"title": n.title,
"message": n.message[:100] + "..." if len(n.message) > 100 else n.message,
"action_url": n.action_url,
"created_at": n.created_at.isoformat(),
}
for n in notifications
],
"unread_count": unread_count,
}
@router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
) -> UnreadCountResponse:
"""Get count of unread notifications."""
# TODO: Implement
return UnreadCountResponse(unread_count=0)
count = admin_notification_service.get_unread_count(db)
return UnreadCountResponse(unread_count=count)
@router.put("/{notification_id}/read", response_model=MessageResponse)
@@ -68,20 +163,48 @@ def mark_as_read(
notification_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
) -> MessageResponse:
"""Mark notification as read."""
# TODO: Implement
return MessageResponse(message="Notification marked as read")
notification = admin_notification_service.mark_as_read(
db=db, notification_id=notification_id, user_id=current_admin.id
)
db.commit()
if notification:
return MessageResponse(message="Notification marked as read")
return MessageResponse(message="Notification not found")
@router.put("/mark-all-read", response_model=MessageResponse)
def mark_all_as_read(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
) -> MessageResponse:
"""Mark all notifications as read."""
# TODO: Implement
return MessageResponse(message="All notifications marked as read")
count = admin_notification_service.mark_all_as_read(
db=db, user_id=current_admin.id
)
db.commit()
return MessageResponse(message=f"Marked {count} notifications as read")
@router.delete("/{notification_id}", response_model=MessageResponse)
def delete_notification(
notification_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> MessageResponse:
"""Delete a notification."""
deleted = admin_notification_service.delete_notification(
db=db, notification_id=notification_id
)
db.commit()
if deleted:
return MessageResponse(message="Notification deleted")
return MessageResponse(message="Notification not found")
# ============================================================================
@@ -92,29 +215,83 @@ def mark_all_as_read(
@router.get("/alerts", response_model=PlatformAlertListResponse)
def get_platform_alerts(
severity: str | None = Query(None, description="Filter by severity"),
alert_type: str | None = Query(None, description="Filter by alert type"),
is_resolved: bool | None = Query(None, description="Filter by resolution status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
) -> PlatformAlertListResponse:
"""Get platform alerts with filtering."""
# TODO: Implement alert service
alerts, total, active_count, critical_count = platform_alert_service.get_alerts(
db=db,
severity=severity,
alert_type=alert_type,
is_resolved=is_resolved,
skip=skip,
limit=limit,
)
return PlatformAlertListResponse(
alerts=[], total=0, active_count=0, critical_count=0, skip=skip, limit=limit
alerts=[
PlatformAlertResponse(
id=a.id,
alert_type=a.alert_type,
severity=a.severity,
title=a.title,
description=a.description,
affected_vendors=a.affected_vendors,
affected_systems=a.affected_systems,
is_resolved=a.is_resolved,
resolved_at=a.resolved_at,
resolved_by_user_id=a.resolved_by_user_id,
resolution_notes=a.resolution_notes,
auto_generated=a.auto_generated,
occurrence_count=a.occurrence_count,
first_occurred_at=a.first_occurred_at,
last_occurred_at=a.last_occurred_at,
created_at=a.created_at,
)
for a in alerts
],
total=total,
active_count=active_count,
critical_count=critical_count,
skip=skip,
limit=limit,
)
@router.post("/alerts", response_model=MessageResponse)
@router.post("/alerts", response_model=PlatformAlertResponse)
def create_platform_alert(
alert_data: PlatformAlertCreate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
) -> PlatformAlertResponse:
"""Create new platform alert (manual)."""
# TODO: Implement - return PlatformAlertResponse when service is ready
logger.info(f"Admin {current_admin.username} created alert: {alert_data.title}")
return MessageResponse(message="Platform alert creation coming soon")
alert = platform_alert_service.create_from_schema(db=db, data=alert_data)
db.commit()
logger.info(f"Admin {current_admin.username} created alert: {alert.title}")
return PlatformAlertResponse(
id=alert.id,
alert_type=alert.alert_type,
severity=alert.severity,
title=alert.title,
description=alert.description,
affected_vendors=alert.affected_vendors,
affected_systems=alert.affected_systems,
is_resolved=alert.is_resolved,
resolved_at=alert.resolved_at,
resolved_by_user_id=alert.resolved_by_user_id,
resolution_notes=alert.resolution_notes,
auto_generated=alert.auto_generated,
occurrence_count=alert.occurrence_count,
first_occurred_at=alert.first_occurred_at,
last_occurred_at=alert.last_occurred_at,
created_at=alert.created_at,
)
@router.put("/alerts/{alert_id}/resolve", response_model=MessageResponse)
@@ -123,23 +300,28 @@ def resolve_platform_alert(
resolve_data: PlatformAlertResolve,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
) -> MessageResponse:
"""Resolve platform alert."""
# TODO: Implement
logger.info(f"Admin {current_admin.username} resolved alert {alert_id}")
return MessageResponse(message="Alert resolved successfully")
alert = platform_alert_service.resolve_alert(
db=db,
alert_id=alert_id,
user_id=current_admin.id,
resolution_notes=resolve_data.resolution_notes,
)
db.commit()
if alert:
logger.info(f"Admin {current_admin.username} resolved alert {alert_id}")
return MessageResponse(message="Alert resolved successfully")
return MessageResponse(message="Alert not found or already resolved")
@router.get("/alerts/stats", response_model=AlertStatisticsResponse)
def get_alert_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
) -> AlertStatisticsResponse:
"""Get alert statistics for dashboard."""
# TODO: Implement
return AlertStatisticsResponse(
total_alerts=0,
active_alerts=0,
critical_alerts=0,
resolved_today=0,
)
stats = platform_alert_service.get_statistics(db)
return AlertStatisticsResponse(**stats)

View File

@@ -0,0 +1,701 @@
# app/services/admin_notification_service.py
"""
Admin notification service.
Provides functionality for:
- Creating and managing admin notifications
- Managing platform alerts
- Notification statistics and queries
"""
import logging
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from models.database.admin import AdminNotification, PlatformAlert
from models.schema.admin import AdminNotificationCreate, PlatformAlertCreate
logger = logging.getLogger(__name__)
# ============================================================================
# NOTIFICATION TYPES
# ============================================================================
class NotificationType:
"""Notification type constants."""
SYSTEM_ALERT = "system_alert"
IMPORT_FAILURE = "import_failure"
EXPORT_FAILURE = "export_failure"
ORDER_SYNC_FAILURE = "order_sync_failure"
VENDOR_ISSUE = "vendor_issue"
CUSTOMER_MESSAGE = "customer_message"
VENDOR_MESSAGE = "vendor_message"
SECURITY_ALERT = "security_alert"
PERFORMANCE_ALERT = "performance_alert"
ORDER_EXCEPTION = "order_exception"
CRITICAL_ERROR = "critical_error"
class Priority:
"""Priority level constants."""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
CRITICAL = "critical"
class AlertType:
"""Platform alert type constants."""
SECURITY = "security"
PERFORMANCE = "performance"
CAPACITY = "capacity"
INTEGRATION = "integration"
DATABASE = "database"
SYSTEM = "system"
class Severity:
"""Alert severity constants."""
INFO = "info"
WARNING = "warning"
ERROR = "error"
CRITICAL = "critical"
# ============================================================================
# ADMIN NOTIFICATION SERVICE
# ============================================================================
class AdminNotificationService:
"""Service for managing admin notifications."""
def create_notification(
self,
db: Session,
notification_type: str,
title: str,
message: str,
priority: str = Priority.NORMAL,
action_required: bool = False,
action_url: str | None = None,
metadata: dict[str, Any] | None = None,
) -> AdminNotification:
"""
Create a new admin notification.
Args:
db: Database session
notification_type: Type of notification
title: Notification title
message: Notification message
priority: Priority level (low, normal, high, critical)
action_required: Whether action is required
action_url: URL to relevant admin page
metadata: Additional contextual data
Returns:
Created AdminNotification
"""
notification = AdminNotification(
type=notification_type,
title=title,
message=message,
priority=priority,
action_required=action_required,
action_url=action_url,
notification_metadata=metadata,
)
db.add(notification)
db.flush()
logger.info(
f"Created notification: {notification_type} - {title} (priority: {priority})"
)
return notification
def create_from_schema(
self,
db: Session,
data: AdminNotificationCreate,
) -> AdminNotification:
"""Create notification from Pydantic schema."""
return self.create_notification(
db=db,
notification_type=data.type,
title=data.title,
message=data.message,
priority=data.priority,
action_required=data.action_required,
action_url=data.action_url,
metadata=data.metadata,
)
def get_notifications(
self,
db: Session,
priority: str | None = None,
is_read: bool | None = None,
notification_type: str | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[list[AdminNotification], int, int]:
"""
Get paginated admin notifications.
Returns:
Tuple of (notifications, total_count, unread_count)
"""
query = db.query(AdminNotification)
# Apply filters
if priority:
query = query.filter(AdminNotification.priority == priority)
if is_read is not None:
query = query.filter(AdminNotification.is_read == is_read)
if notification_type:
query = query.filter(AdminNotification.type == notification_type)
# Get counts
total = query.count()
unread_count = (
db.query(AdminNotification)
.filter(AdminNotification.is_read == False) # noqa: E712
.count()
)
# Get paginated results ordered by priority and date
priority_order = func.case(
(AdminNotification.priority == "critical", 1),
(AdminNotification.priority == "high", 2),
(AdminNotification.priority == "normal", 3),
(AdminNotification.priority == "low", 4),
else_=5,
)
notifications = (
query.order_by(
AdminNotification.is_read, # Unread first
priority_order,
AdminNotification.created_at.desc(),
)
.offset(skip)
.limit(limit)
.all()
)
return notifications, total, unread_count
def get_unread_count(self, db: Session) -> int:
"""Get count of unread notifications."""
return (
db.query(AdminNotification)
.filter(AdminNotification.is_read == False) # noqa: E712
.count()
)
def get_recent_notifications(
self,
db: Session,
limit: int = 5,
) -> list[AdminNotification]:
"""Get recent unread notifications for header dropdown."""
priority_order = func.case(
(AdminNotification.priority == "critical", 1),
(AdminNotification.priority == "high", 2),
(AdminNotification.priority == "normal", 3),
(AdminNotification.priority == "low", 4),
else_=5,
)
return (
db.query(AdminNotification)
.filter(AdminNotification.is_read == False) # noqa: E712
.order_by(priority_order, AdminNotification.created_at.desc())
.limit(limit)
.all()
)
def mark_as_read(
self,
db: Session,
notification_id: int,
user_id: int,
) -> AdminNotification | None:
"""Mark a notification as read."""
notification = (
db.query(AdminNotification)
.filter(AdminNotification.id == notification_id)
.first()
)
if notification and not notification.is_read:
notification.is_read = True
notification.read_at = datetime.utcnow()
notification.read_by_user_id = user_id
db.flush()
return notification
def mark_all_as_read(
self,
db: Session,
user_id: int,
) -> int:
"""Mark all unread notifications as read. Returns count of updated."""
now = datetime.utcnow()
count = (
db.query(AdminNotification)
.filter(AdminNotification.is_read == False) # noqa: E712
.update(
{
AdminNotification.is_read: True,
AdminNotification.read_at: now,
AdminNotification.read_by_user_id: user_id,
}
)
)
db.flush()
return count
def delete_old_notifications(
self,
db: Session,
days: int = 30,
) -> int:
"""Delete notifications older than specified days."""
cutoff = datetime.utcnow() - timedelta(days=days)
count = (
db.query(AdminNotification)
.filter(
and_(
AdminNotification.is_read == True, # noqa: E712
AdminNotification.created_at < cutoff,
)
)
.delete()
)
db.flush()
return count
def delete_notification(
self,
db: Session,
notification_id: int,
) -> bool:
"""
Delete a notification by ID.
Returns:
True if notification was deleted, False if not found.
"""
notification = (
db.query(AdminNotification)
.filter(AdminNotification.id == notification_id)
.first()
)
if notification:
db.delete(notification)
db.flush()
logger.info(f"Deleted notification {notification_id}")
return True
return False
# =========================================================================
# CONVENIENCE METHODS FOR CREATING SPECIFIC NOTIFICATION TYPES
# =========================================================================
def notify_import_failure(
self,
db: Session,
vendor_name: str,
job_id: int,
error_message: str,
vendor_id: int | None = None,
) -> AdminNotification:
"""Create notification for import job failure."""
return self.create_notification(
db=db,
notification_type=NotificationType.IMPORT_FAILURE,
title=f"Import Failed: {vendor_name}",
message=error_message,
priority=Priority.HIGH,
action_required=True,
action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=jobs"
if vendor_id
else "/admin/marketplace",
metadata={"vendor_name": vendor_name, "job_id": job_id, "vendor_id": vendor_id},
)
def notify_order_sync_failure(
self,
db: Session,
vendor_name: str,
error_message: str,
vendor_id: int | None = None,
) -> AdminNotification:
"""Create notification for order sync failure."""
return self.create_notification(
db=db,
notification_type=NotificationType.ORDER_SYNC_FAILURE,
title=f"Order Sync Failed: {vendor_name}",
message=error_message,
priority=Priority.HIGH,
action_required=True,
action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=jobs"
if vendor_id
else "/admin/marketplace/letzshop",
metadata={"vendor_name": vendor_name, "vendor_id": vendor_id},
)
def notify_order_exception(
self,
db: Session,
vendor_name: str,
order_number: str,
exception_count: int,
vendor_id: int | None = None,
) -> AdminNotification:
"""Create notification for order item exceptions."""
return self.create_notification(
db=db,
notification_type=NotificationType.ORDER_EXCEPTION,
title=f"Order Exception: {order_number}",
message=f"{exception_count} item(s) need attention for order {order_number} ({vendor_name})",
priority=Priority.NORMAL,
action_required=True,
action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=exceptions"
if vendor_id
else "/admin/marketplace/letzshop",
metadata={
"vendor_name": vendor_name,
"order_number": order_number,
"exception_count": exception_count,
"vendor_id": vendor_id,
},
)
def notify_critical_error(
self,
db: Session,
error_type: str,
error_message: str,
details: dict[str, Any] | None = None,
) -> AdminNotification:
"""Create notification for critical application errors."""
return self.create_notification(
db=db,
notification_type=NotificationType.CRITICAL_ERROR,
title=f"Critical Error: {error_type}",
message=error_message,
priority=Priority.CRITICAL,
action_required=True,
action_url="/admin/logs",
metadata=details,
)
def notify_vendor_issue(
self,
db: Session,
vendor_name: str,
issue_type: str,
message: str,
vendor_id: int | None = None,
) -> AdminNotification:
"""Create notification for vendor-related issues."""
return self.create_notification(
db=db,
notification_type=NotificationType.VENDOR_ISSUE,
title=f"Vendor Issue: {vendor_name}",
message=message,
priority=Priority.HIGH,
action_required=True,
action_url=f"/admin/vendors/{vendor_id}" if vendor_id else "/admin/vendors",
metadata={
"vendor_name": vendor_name,
"issue_type": issue_type,
"vendor_id": vendor_id,
},
)
def notify_security_alert(
self,
db: Session,
title: str,
message: str,
details: dict[str, Any] | None = None,
) -> AdminNotification:
"""Create notification for security-related alerts."""
return self.create_notification(
db=db,
notification_type=NotificationType.SECURITY_ALERT,
title=title,
message=message,
priority=Priority.CRITICAL,
action_required=True,
action_url="/admin/audit",
metadata=details,
)
# ============================================================================
# PLATFORM ALERT SERVICE
# ============================================================================
class PlatformAlertService:
"""Service for managing platform-wide alerts."""
def create_alert(
self,
db: Session,
alert_type: str,
severity: str,
title: str,
description: str | None = None,
affected_vendors: list[int] | None = None,
affected_systems: list[str] | None = None,
auto_generated: bool = True,
) -> PlatformAlert:
"""Create a new platform alert."""
now = datetime.utcnow()
alert = PlatformAlert(
alert_type=alert_type,
severity=severity,
title=title,
description=description,
affected_vendors=affected_vendors,
affected_systems=affected_systems,
auto_generated=auto_generated,
first_occurred_at=now,
last_occurred_at=now,
)
db.add(alert)
db.flush()
logger.info(f"Created platform alert: {alert_type} - {title} ({severity})")
return alert
def create_from_schema(
self,
db: Session,
data: PlatformAlertCreate,
) -> PlatformAlert:
"""Create alert from Pydantic schema."""
return self.create_alert(
db=db,
alert_type=data.alert_type,
severity=data.severity,
title=data.title,
description=data.description,
affected_vendors=data.affected_vendors,
affected_systems=data.affected_systems,
auto_generated=data.auto_generated,
)
def get_alerts(
self,
db: Session,
severity: str | None = None,
alert_type: str | None = None,
is_resolved: bool | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[list[PlatformAlert], int, int, int]:
"""
Get paginated platform alerts.
Returns:
Tuple of (alerts, total_count, active_count, critical_count)
"""
query = db.query(PlatformAlert)
# Apply filters
if severity:
query = query.filter(PlatformAlert.severity == severity)
if alert_type:
query = query.filter(PlatformAlert.alert_type == alert_type)
if is_resolved is not None:
query = query.filter(PlatformAlert.is_resolved == is_resolved)
# Get counts
total = query.count()
active_count = (
db.query(PlatformAlert)
.filter(PlatformAlert.is_resolved == False) # noqa: E712
.count()
)
critical_count = (
db.query(PlatformAlert)
.filter(
and_(
PlatformAlert.is_resolved == False, # noqa: E712
PlatformAlert.severity == Severity.CRITICAL,
)
)
.count()
)
# Get paginated results
severity_order = func.case(
(PlatformAlert.severity == "critical", 1),
(PlatformAlert.severity == "error", 2),
(PlatformAlert.severity == "warning", 3),
(PlatformAlert.severity == "info", 4),
else_=5,
)
alerts = (
query.order_by(
PlatformAlert.is_resolved, # Unresolved first
severity_order,
PlatformAlert.last_occurred_at.desc(),
)
.offset(skip)
.limit(limit)
.all()
)
return alerts, total, active_count, critical_count
def resolve_alert(
self,
db: Session,
alert_id: int,
user_id: int,
resolution_notes: str | None = None,
) -> PlatformAlert | None:
"""Resolve a platform alert."""
alert = db.query(PlatformAlert).filter(PlatformAlert.id == alert_id).first()
if alert and not alert.is_resolved:
alert.is_resolved = True
alert.resolved_at = datetime.utcnow()
alert.resolved_by_user_id = user_id
alert.resolution_notes = resolution_notes
db.flush()
logger.info(f"Resolved platform alert {alert_id}")
return alert
def get_statistics(self, db: Session) -> dict[str, int]:
"""Get alert statistics."""
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
total = db.query(PlatformAlert).count()
active = (
db.query(PlatformAlert)
.filter(PlatformAlert.is_resolved == False) # noqa: E712
.count()
)
critical = (
db.query(PlatformAlert)
.filter(
and_(
PlatformAlert.is_resolved == False, # noqa: E712
PlatformAlert.severity == Severity.CRITICAL,
)
)
.count()
)
resolved_today = (
db.query(PlatformAlert)
.filter(
and_(
PlatformAlert.is_resolved == True, # noqa: E712
PlatformAlert.resolved_at >= today_start,
)
)
.count()
)
return {
"total_alerts": total,
"active_alerts": active,
"critical_alerts": critical,
"resolved_today": resolved_today,
}
def increment_occurrence(
self,
db: Session,
alert_id: int,
) -> PlatformAlert | None:
"""Increment occurrence count for repeated alert."""
alert = db.query(PlatformAlert).filter(PlatformAlert.id == alert_id).first()
if alert:
alert.occurrence_count += 1
alert.last_occurred_at = datetime.utcnow()
db.flush()
return alert
def find_similar_active_alert(
self,
db: Session,
alert_type: str,
title: str,
) -> PlatformAlert | None:
"""Find an active alert with same type and title."""
return (
db.query(PlatformAlert)
.filter(
and_(
PlatformAlert.alert_type == alert_type,
PlatformAlert.title == title,
PlatformAlert.is_resolved == False, # noqa: E712
)
)
.first()
)
def create_or_increment_alert(
self,
db: Session,
alert_type: str,
severity: str,
title: str,
description: str | None = None,
affected_vendors: list[int] | None = None,
affected_systems: list[str] | None = None,
) -> PlatformAlert:
"""Create alert or increment occurrence if similar exists."""
existing = self.find_similar_active_alert(db, alert_type, title)
if existing:
self.increment_occurrence(db, existing.id)
return existing
return self.create_alert(
db=db,
alert_type=alert_type,
severity=severity,
title=title,
description=description,
affected_vendors=affected_vendors,
affected_systems=affected_systems,
)
# Singleton instances
admin_notification_service = AdminNotificationService()
platform_alert_service = PlatformAlertService()

View File

@@ -0,0 +1,361 @@
{# app/templates/admin/notifications.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Notifications{% endblock %}
{% block alpine_data %}adminNotifications(){% endblock %}
{% block content %}
{{ page_header('Notifications & Alerts') }}
{{ loading_state('Loading notifications...') }}
{{ error_state('Error loading notifications') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Unread Notifications -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('bell', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Unread
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.unread_count || 0">
0
</p>
</div>
</div>
<!-- Card: Active Alerts -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active Alerts
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.active_alerts || 0">
0
</p>
</div>
</div>
<!-- Card: Critical -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Critical
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.critical_alerts || 0">
0
</p>
</div>
</div>
<!-- Card: Resolved Today -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Resolved Today
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.resolved_today || 0">
0
</p>
</div>
</div>
</div>
<!-- Tabs -->
<div x-show="!loading" class="mb-6">
<div class="flex border-b border-gray-200 dark:border-gray-700">
<button
@click="activeTab = 'notifications'"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'notifications'
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
>
Notifications
<span x-show="stats.unread_count > 0"
class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-600 rounded-full dark:bg-red-600 dark:text-white"
x-text="stats.unread_count"></span>
</button>
<button
@click="activeTab = 'alerts'; loadAlerts()"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'alerts'
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
>
Platform Alerts
<span x-show="alertStats.active_alerts > 0"
class="ml-2 px-2 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full dark:bg-orange-600 dark:text-white"
x-text="alertStats.active_alerts"></span>
</button>
</div>
</div>
<!-- Notifications Tab -->
<div x-show="!loading && activeTab === 'notifications'" class="space-y-4">
<!-- Filters -->
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center gap-4">
<select
x-model="filters.priority"
@change="page = 1; loadNotifications()"
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">All Priorities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="normal">Normal</option>
<option value="low">Low</option>
</select>
<select
x-model="filters.is_read"
@change="page = 1; loadNotifications()"
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">All Status</option>
<option value="false">Unread</option>
<option value="true">Read</option>
</select>
</div>
<button
x-show="stats.unread_count > 0"
@click="markAllAsRead()"
class="px-4 py-2 text-sm font-medium text-purple-600 hover:text-purple-800 dark:text-purple-400"
>
Mark all as read
</button>
</div>
<!-- Notifications List -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<template x-if="loadingNotifications && notifications.length === 0">
<div class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading notifications...</p>
</div>
</template>
<template x-if="!loadingNotifications && notifications.length === 0">
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('bell', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
<p class="font-medium">No notifications</p>
<p class="text-sm mt-1">You're all caught up!</p>
</div>
</template>
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="notif in notifications" :key="notif.id">
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="notif.is_read ? 'opacity-60' : ''">
<div class="flex items-start px-4 py-4">
<!-- Priority indicator -->
<div class="flex-shrink-0 mr-4">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full"
:class="{
'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300': notif.priority === 'critical',
'bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-300': notif.priority === 'high',
'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300': notif.priority === 'normal',
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300': notif.priority === 'low'
}">
<span x-html="getNotificationIcon(notif.type)"></span>
</span>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100" x-text="notif.title"></p>
<span class="text-xs text-gray-400" x-text="formatDate(notif.created_at)"></span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="notif.message"></p>
<div class="flex items-center gap-4 mt-2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded"
:class="{
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': notif.priority === 'critical',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': notif.priority === 'high',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': notif.priority === 'normal',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200': notif.priority === 'low'
}"
x-text="notif.priority"></span>
<span class="text-xs text-gray-500" x-text="notif.type.replace('_', ' ')"></span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<template x-if="notif.action_url">
<a :href="notif.action_url"
class="px-3 py-1 text-xs font-medium text-purple-600 bg-purple-100 rounded hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
View
</a>
</template>
<template x-if="!notif.is_read">
<button @click="markAsRead(notif)"
class="px-3 py-1 text-xs font-medium text-gray-600 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
Mark read
</button>
</template>
<button @click="deleteNotification(notif.id)"
class="p-1 text-gray-400 hover:text-red-500 transition-colors">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</div>
</div>
</li>
</template>
</ul>
<!-- Pagination -->
<div x-show="stats.total > limit" class="flex items-center justify-between px-4 py-3 border-t dark:border-gray-700">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing <span x-text="skip + 1"></span>-<span x-text="Math.min(skip + limit, stats.total)"></span> of <span x-text="stats.total"></span>
</span>
<div class="flex items-center gap-2">
<button
@click="page--; loadNotifications()"
:disabled="page <= 1"
class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
<button
@click="page++; loadNotifications()"
:disabled="page * limit >= stats.total"
class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</div>
</div>
</div>
</div>
<!-- Alerts Tab -->
<div x-show="!loading && activeTab === 'alerts'" class="space-y-4">
<!-- Filters -->
<div class="flex flex-wrap items-center gap-4 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<select
x-model="alertFilters.severity"
@change="alertPage = 1; loadAlerts()"
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">All Severities</option>
<option value="critical">Critical</option>
<option value="error">Error</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select
x-model="alertFilters.is_resolved"
@change="alertPage = 1; loadAlerts()"
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">All Status</option>
<option value="false">Active</option>
<option value="true">Resolved</option>
</select>
</div>
<!-- Alerts List -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<template x-if="loadingAlerts && alerts.length === 0">
<div class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading alerts...</p>
</div>
</template>
<template x-if="!loadingAlerts && alerts.length === 0">
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('shield-check', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
<p class="font-medium">No alerts</p>
<p class="text-sm mt-1">All systems are running smoothly</p>
</div>
</template>
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="alert in alerts" :key="alert.id">
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="alert.is_resolved ? 'opacity-60' : ''">
<div class="flex items-start px-4 py-4">
<!-- Severity indicator -->
<div class="flex-shrink-0 mr-4">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full"
:class="{
'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300': alert.severity === 'critical',
'bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-300': alert.severity === 'error',
'bg-yellow-100 text-yellow-600 dark:bg-yellow-900 dark:text-yellow-300': alert.severity === 'warning',
'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300': alert.severity === 'info'
}">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5')"></span>
</span>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100" x-text="alert.title"></p>
<template x-if="alert.occurrence_count > 1">
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded dark:bg-gray-700 dark:text-gray-300"
x-text="alert.occurrence_count + 'x'"></span>
</template>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="alert.description"></p>
<div class="flex items-center gap-4 mt-2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded uppercase"
:class="{
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': alert.severity === 'critical',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': alert.severity === 'error',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': alert.severity === 'warning',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': alert.severity === 'info'
}"
x-text="alert.severity"></span>
<span class="text-xs text-gray-500" x-text="alert.alert_type"></span>
<span class="text-xs text-gray-400" x-text="'Last: ' + formatDate(alert.last_occurred_at)"></span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<template x-if="!alert.is_resolved">
<button @click="resolveAlert(alert)"
class="px-3 py-1 text-xs font-medium text-green-600 bg-green-100 rounded hover:bg-green-200 dark:bg-green-900 dark:text-green-300">
Resolve
</button>
</template>
<template x-if="alert.is_resolved">
<span class="px-3 py-1 text-xs font-medium text-gray-500 bg-gray-100 rounded dark:bg-gray-700">
Resolved
</span>
</template>
</div>
</div>
</li>
</template>
</ul>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script src="{{ url_for('static', path='admin/js/notifications.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,187 @@
# Admin Notification System
## Overview
The admin notification system provides real-time alerts and notifications to platform administrators for important events, errors, and system status updates.
## Components
### Backend
#### Database Models
Located in `models/database/admin.py`:
- **AdminNotification**: Stores individual notifications
- `type`: Notification type (import_failure, order_sync_failure, etc.)
- `priority`: low, normal, high, critical
- `title`, `message`: Content
- `is_read`, `read_at`, `read_by_user_id`: Read tracking
- `action_required`, `action_url`: Optional action link
- `notification_metadata`: JSON for additional context
- **PlatformAlert**: Stores platform-wide alerts
- `alert_type`: security, performance, capacity, integration, etc.
- `severity`: info, warning, error, critical
- `affected_vendors`, `affected_systems`: Scope tracking
- `occurrence_count`, `first_occurred_at`, `last_occurred_at`: Deduplication
- `is_resolved`, `resolved_at`, `resolution_notes`: Resolution tracking
#### Service Layer
Located in `app/services/admin_notification_service.py`:
```python
from app.services.admin_notification_service import (
admin_notification_service,
platform_alert_service,
NotificationType,
Priority,
AlertType,
Severity,
)
```
**AdminNotificationService** methods:
| Method | Description |
|--------|-------------|
| `create_notification()` | Create a new notification |
| `get_notifications()` | List notifications with filters |
| `get_recent_notifications()` | Get recent unread for header dropdown |
| `get_unread_count()` | Count unread notifications |
| `mark_as_read()` | Mark single notification read |
| `mark_all_as_read()` | Mark all as read |
| `delete_notification()` | Delete a notification |
**Convenience methods** for common scenarios:
| Method | Use Case |
|--------|----------|
| `notify_import_failure()` | Product/order import failed |
| `notify_order_sync_failure()` | Letzshop sync failed |
| `notify_order_exception()` | Order has unmatched products |
| `notify_critical_error()` | System critical error |
| `notify_vendor_issue()` | Vendor-related problem |
| `notify_security_alert()` | Security event detected |
**PlatformAlertService** methods:
| Method | Description |
|--------|-------------|
| `create_alert()` | Create a new platform alert |
| `get_alerts()` | List alerts with filters |
| `resolve_alert()` | Mark alert as resolved |
| `get_statistics()` | Get alert counts and stats |
| `create_or_increment_alert()` | Deduplicate recurring alerts |
#### API Endpoints
Located in `app/api/v1/admin/notifications.py`:
**Notifications:**
- `GET /api/v1/admin/notifications` - List with filters
- `POST /api/v1/admin/notifications` - Create (manual)
- `GET /api/v1/admin/notifications/recent` - For header dropdown
- `GET /api/v1/admin/notifications/unread-count` - Badge count
- `PUT /api/v1/admin/notifications/{id}/read` - Mark read
- `PUT /api/v1/admin/notifications/mark-all-read` - Mark all read
- `DELETE /api/v1/admin/notifications/{id}` - Delete
**Platform Alerts:**
- `GET /api/v1/admin/notifications/alerts` - List with filters
- `POST /api/v1/admin/notifications/alerts` - Create (manual)
- `PUT /api/v1/admin/notifications/alerts/{id}/resolve` - Resolve
- `GET /api/v1/admin/notifications/alerts/stats` - Statistics
### Frontend
#### Header Dropdown
Located in `app/templates/admin/partials/header.html`:
- Real-time notification bell with unread count badge
- Polls for new notifications every 60 seconds
- Quick actions: mark as read, view all
- Priority-based color coding
#### Notifications Page
Located in `app/templates/admin/notifications.html` with `static/admin/js/notifications.js`:
- Full notifications management interface
- Two tabs: Notifications and Platform Alerts
- Statistics cards (unread, active alerts, critical, resolved today)
- Filtering by priority, type, read status
- Bulk operations (mark all read)
- Alert resolution workflow
## Automatic Triggers
Notifications are automatically created in these scenarios:
### Import Failures
**Product Import** (`app/tasks/background_tasks.py`):
- When a product import job fails completely
- When import completes with 5+ errors
**Historical Order Import** (`app/tasks/letzshop_tasks.py`):
- When Letzshop API returns an error
- When import fails with an unexpected exception
### Example Usage
```python
from app.services.admin_notification_service import admin_notification_service
# In a background task or service
admin_notification_service.notify_import_failure(
db=db,
vendor_name="Acme Corp",
job_id=123,
error_message="CSV parsing failed: invalid column format",
vendor_id=5,
)
db.commit()
```
## Priority Levels
| Priority | When to Use | Badge Color |
|----------|-------------|-------------|
| `critical` | System down, data loss risk | Red |
| `high` | Import/sync failures, action needed | Orange |
| `normal` | Informational alerts | Blue |
| `low` | Minor issues, suggestions | Gray |
## Architecture
```
┌─────────────────┐ ┌──────────────────────┐
│ Background │────▶│ Notification │
│ Tasks │ │ Service │
└─────────────────┘ └──────────┬───────────┘
┌─────────────────┐ ▼
│ API Endpoints │◀───────────────┤
└─────────────────┘ │
┌─────────────────┐ ┌──────────────────────┐
│ Header │◀────│ Database │
│ Dropdown │ │ (admin_notifications│
└─────────────────┘ │ platform_alerts) │
│ └──────────────────────┘
┌─────────────────┐
│ Notifications │
│ Page │
└─────────────────┘
```
## Future Enhancements
- Email notifications for critical alerts
- Webhook integration for external systems
- Customizable notification preferences per admin
- Scheduled notification digests

View File

@@ -0,0 +1,295 @@
/**
* Admin Notifications Page
*
* Handles the notifications management interface including:
* - Notifications list with filtering and pagination
* - Platform alerts management
* - Mark as read, delete, and bulk operations
*/
const notificationsLog = window.LogConfig?.createLogger('NOTIFICATIONS') || console;
/**
* Admin Notifications Component
*/
function adminNotifications() {
return {
// Loading states
loading: true,
loadingNotifications: false,
loadingAlerts: false,
// Tab state
activeTab: 'notifications',
// Notifications state
notifications: [],
page: 1,
skip: 0,
limit: 10,
stats: {
total: 0,
unread_count: 0
},
// Notifications filters
filters: {
priority: '',
is_read: ''
},
// Alerts state
alerts: [],
alertPage: 1,
alertSkip: 0,
alertLimit: 10,
alertStats: {
total: 0,
active_alerts: 0,
critical_alerts: 0,
resolved_today: 0,
by_type: {},
by_severity: {}
},
// Alerts filters
alertFilters: {
severity: '',
is_resolved: ''
},
// Resolve modal state
showResolveModal: false,
resolvingAlert: null,
resolutionNotes: '',
/**
* Initialize component
*/
async init() {
notificationsLog.debug('Initializing notifications page');
await Promise.all([
this.loadNotifications(),
this.loadAlertStats()
]);
this.loading = false;
},
// ============================================================================
// NOTIFICATIONS
// ============================================================================
/**
* Load notifications with current filters
*/
async loadNotifications() {
this.loadingNotifications = true;
try {
this.skip = (this.page - 1) * this.limit;
const params = new URLSearchParams();
params.append('skip', this.skip);
params.append('limit', this.limit);
if (this.filters.priority) params.append('priority', this.filters.priority);
if (this.filters.is_read !== '') params.append('is_read', this.filters.is_read);
const response = await apiClient.get(`/admin/notifications?${params}`);
this.notifications = response.notifications || [];
this.stats.total = response.total || 0;
this.stats.unread_count = response.unread_count || 0;
notificationsLog.debug(`Loaded ${this.notifications.length} notifications`);
} catch (error) {
notificationsLog.error('Failed to load notifications:', error);
window.showToast?.('Failed to load notifications', 'error');
} finally {
this.loadingNotifications = false;
}
},
/**
* Mark notification as read
*/
async markAsRead(notification) {
try {
await apiClient.put(`/admin/notifications/${notification.id}/read`);
// Update local state
notification.is_read = true;
this.stats.unread_count = Math.max(0, this.stats.unread_count - 1);
window.showToast?.('Notification marked as read', 'success');
} catch (error) {
notificationsLog.error('Failed to mark as read:', error);
window.showToast?.('Failed to mark notification as read', 'error');
}
},
/**
* Mark all notifications as read
*/
async markAllAsRead() {
try {
await apiClient.put('/admin/notifications/mark-all-read');
// Update local state
this.notifications.forEach(n => n.is_read = true);
this.stats.unread_count = 0;
window.showToast?.('All notifications marked as read', 'success');
} catch (error) {
notificationsLog.error('Failed to mark all as read:', error);
window.showToast?.('Failed to mark all as read', 'error');
}
},
/**
* Delete notification
*/
async deleteNotification(notificationId) {
if (!confirm('Are you sure you want to delete this notification?')) {
return;
}
try {
await apiClient.delete(`/admin/notifications/${notificationId}`);
// Remove from local state
const wasUnread = this.notifications.find(n => n.id === notificationId && !n.is_read);
this.notifications = this.notifications.filter(n => n.id !== notificationId);
this.stats.total = Math.max(0, this.stats.total - 1);
if (wasUnread) {
this.stats.unread_count = Math.max(0, this.stats.unread_count - 1);
}
window.showToast?.('Notification deleted', 'success');
} catch (error) {
notificationsLog.error('Failed to delete notification:', error);
window.showToast?.('Failed to delete notification', 'error');
}
},
/**
* Get notification icon based on type
*/
getNotificationIcon(type) {
const icons = {
'import_failure': window.$icon?.('x-circle', 'w-5 h-5') || '❌',
'sync_issue': window.$icon?.('refresh', 'w-5 h-5') || '🔄',
'vendor_alert': window.$icon?.('exclamation-triangle', 'w-5 h-5') || '⚠️',
'system_health': window.$icon?.('heart', 'w-5 h-5') || '💓',
'security': window.$icon?.('shield-exclamation', 'w-5 h-5') || '🛡️',
'performance': window.$icon?.('chart-bar', 'w-5 h-5') || '📊',
'info': window.$icon?.('information-circle', 'w-5 h-5') || ''
};
return icons[type] || window.$icon?.('bell', 'w-5 h-5') || '🔔';
},
// ============================================================================
// PLATFORM ALERTS
// ============================================================================
/**
* Load platform alerts
*/
async loadAlerts() {
this.loadingAlerts = true;
try {
this.alertSkip = (this.alertPage - 1) * this.alertLimit;
const params = new URLSearchParams();
params.append('skip', this.alertSkip);
params.append('limit', this.alertLimit);
if (this.alertFilters.severity) params.append('severity', this.alertFilters.severity);
if (this.alertFilters.is_resolved !== '') params.append('is_resolved', this.alertFilters.is_resolved);
const response = await apiClient.get(`/admin/notifications/alerts?${params}`);
this.alerts = response.alerts || [];
this.alertStats.total = response.total || 0;
this.alertStats.active_alerts = response.active_count || 0;
this.alertStats.critical_alerts = response.critical_count || 0;
notificationsLog.debug(`Loaded ${this.alerts.length} alerts`);
} catch (error) {
notificationsLog.error('Failed to load alerts:', error);
window.showToast?.('Failed to load alerts', 'error');
} finally {
this.loadingAlerts = false;
}
},
/**
* Load alert statistics
*/
async loadAlertStats() {
try {
const response = await apiClient.get('/admin/notifications/alerts/stats');
this.alertStats = {
...this.alertStats,
total: response.total || 0,
active_alerts: response.active || 0,
critical_alerts: response.critical || 0,
resolved_today: response.resolved_today || 0,
by_type: response.by_type || {},
by_severity: response.by_severity || {}
};
} catch (error) {
notificationsLog.error('Failed to load alert stats:', error);
}
},
/**
* Resolve alert
*/
async resolveAlert(alert) {
const notes = prompt('Resolution notes (optional):');
if (notes === null) return; // User cancelled
try {
await apiClient.put(`/admin/notifications/alerts/${alert.id}/resolve`, {
resolution_notes: notes
});
// Update local state
alert.is_resolved = true;
alert.resolution_notes = notes;
this.alertStats.active_alerts = Math.max(0, this.alertStats.active_alerts - 1);
if (alert.severity === 'critical') {
this.alertStats.critical_alerts = Math.max(0, this.alertStats.critical_alerts - 1);
}
this.alertStats.resolved_today++;
window.showToast?.('Alert resolved successfully', 'success');
} catch (error) {
notificationsLog.error('Failed to resolve alert:', error);
window.showToast?.('Failed to resolve alert', 'error');
}
},
// ============================================================================
// HELPERS
// ============================================================================
/**
* Format date for display
*/
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
// Show relative time for recent dates
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 172800) return 'Yesterday';
// Show full date for older dates
return date.toLocaleString();
}
};
}
// Make available globally
window.adminNotifications = adminNotifications;