""" Unified Event Storage Models This module defines separate storage models for: - Alerts: Full enrichment, lifecycle tracking - Notifications: Lightweight, ephemeral (7-day TTL) - Recommendations: Medium weight, dismissible Replaces the old single Alert model with semantic clarity. """ from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, Float, CheckConstraint, Index, Boolean, Enum from sqlalchemy.dialects.postgresql import UUID, JSONB from datetime import datetime, timezone, timedelta from typing import Dict, Any, Optional import uuid import enum from shared.database.base import Base def utc_now(): """Return current UTC time as timezone-aware datetime""" return datetime.now(timezone.utc) # ============================================================ # ENUMS # ============================================================ class AlertStatus(enum.Enum): """Alert lifecycle status""" ACTIVE = "active" RESOLVED = "resolved" ACKNOWLEDGED = "acknowledged" IN_PROGRESS = "in_progress" DISMISSED = "dismissed" IGNORED = "ignored" class PriorityLevel(enum.Enum): """Priority levels based on multi-factor scoring""" CRITICAL = "critical" # 90-100 IMPORTANT = "important" # 70-89 STANDARD = "standard" # 50-69 INFO = "info" # 0-49 class AlertTypeClass(enum.Enum): """Alert type classification (for alerts only)""" ACTION_NEEDED = "action_needed" # Requires user action PREVENTED_ISSUE = "prevented_issue" # AI already handled TREND_WARNING = "trend_warning" # Pattern detected ESCALATION = "escalation" # Time-sensitive with countdown INFORMATION = "information" # FYI only class NotificationType(enum.Enum): """Notification type classification""" STATE_CHANGE = "state_change" COMPLETION = "completion" ARRIVAL = "arrival" DEPARTURE = "departure" UPDATE = "update" SYSTEM_EVENT = "system_event" class RecommendationType(enum.Enum): """Recommendation type classification""" OPTIMIZATION = "optimization" COST_REDUCTION = "cost_reduction" RISK_MITIGATION = "risk_mitigation" TREND_INSIGHT = "trend_insight" BEST_PRACTICE = "best_practice" # ============================================================ # ALERT MODEL (Full Enrichment) # ============================================================ class Alert(Base): """ Alert model with full enrichment capabilities. Used for EventClass.ALERT only. Full priority scoring, context enrichment, smart actions, lifecycle tracking. """ __tablename__ = "alerts" # Primary key id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Event classification item_type = Column(String(50), nullable=False) # 'alert' or 'recommendation' - from old schema event_domain = Column(String(50), nullable=True, index=True) # inventory, production, etc. - new field, make nullable for now alert_type = Column(String(100), nullable=False) # specific type of alert (e.g., 'low_stock', 'supplier_delay') - from old schema service = Column(String(100), nullable=False) # Content title = Column(String(500), nullable=False) message = Column(Text, nullable=False) # Alert-specific classification type_class = Column( Enum(AlertTypeClass, name='alerttypeclass', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]), nullable=False, index=True ) # Status status = Column( Enum(AlertStatus, name='alertstatus', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]), default=AlertStatus.ACTIVE, nullable=False, index=True ) # Priority (multi-factor scored) priority_score = Column(Integer, nullable=False) # 0-100 priority_level = Column( Enum(PriorityLevel, name='prioritylevel', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]), nullable=False, index=True ) # Enrichment context (JSONB) orchestrator_context = Column(JSONB, nullable=True) business_impact = Column(JSONB, nullable=True) urgency_context = Column(JSONB, nullable=True) user_agency = Column(JSONB, nullable=True) trend_context = Column(JSONB, nullable=True) # Smart actions smart_actions = Column(JSONB, nullable=False) # AI reasoning ai_reasoning_summary = Column(Text, nullable=True) confidence_score = Column(Float, nullable=False, default=0.8) # Timing intelligence timing_decision = Column(String(50), nullable=False, default='send_now') scheduled_send_time = Column(DateTime(timezone=True), nullable=True) # Placement hints placement = Column(JSONB, nullable=False) # Escalation & chaining action_created_at = Column(DateTime(timezone=True), nullable=True, index=True) superseded_by_action_id = Column(UUID(as_uuid=True), nullable=True, index=True) hidden_from_ui = Column(Boolean, default=False, nullable=False, index=True) # Metadata alert_metadata = Column(JSONB, nullable=True) # Timestamps created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True) updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) resolved_at = Column(DateTime(timezone=True), nullable=True) __table_args__ = ( Index('idx_alerts_tenant_status', 'tenant_id', 'status'), Index('idx_alerts_priority_score', 'tenant_id', 'priority_score', 'created_at'), Index('idx_alerts_type_class', 'tenant_id', 'type_class', 'status'), Index('idx_alerts_domain', 'tenant_id', 'event_domain', 'status'), Index('idx_alerts_timing', 'timing_decision', 'scheduled_send_time'), CheckConstraint('priority_score >= 0 AND priority_score <= 100', name='chk_alert_priority_range'), ) def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API/SSE""" return { 'id': str(self.id), 'tenant_id': str(self.tenant_id), 'event_class': 'alert', 'event_domain': self.event_domain, 'event_type': self.alert_type, 'alert_type': self.alert_type, # Frontend expects this field name 'service': self.service, 'title': self.title, 'message': self.message, 'type_class': self.type_class.value if isinstance(self.type_class, AlertTypeClass) else self.type_class, 'status': self.status.value if isinstance(self.status, AlertStatus) else self.status, 'priority_level': self.priority_level.value if isinstance(self.priority_level, PriorityLevel) else self.priority_level, 'priority_score': self.priority_score, 'orchestrator_context': self.orchestrator_context, 'business_impact': self.business_impact, 'urgency_context': self.urgency_context, 'user_agency': self.user_agency, 'trend_context': self.trend_context, 'actions': self.smart_actions, 'ai_reasoning_summary': self.ai_reasoning_summary, 'confidence_score': self.confidence_score, 'timing_decision': self.timing_decision, 'scheduled_send_time': self.scheduled_send_time.isoformat() if self.scheduled_send_time else None, 'placement': self.placement, 'action_created_at': self.action_created_at.isoformat() if self.action_created_at else None, 'superseded_by_action_id': str(self.superseded_by_action_id) if self.superseded_by_action_id else None, 'hidden_from_ui': self.hidden_from_ui, 'alert_metadata': self.alert_metadata, # Frontend expects alert_metadata 'metadata': self.alert_metadata, # Keep legacy field for backwards compat 'timestamp': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'resolved_at': self.resolved_at.isoformat() if self.resolved_at else None, } # ============================================================ # NOTIFICATION MODEL (Lightweight, Ephemeral) # ============================================================ class Notification(Base): """ Notification model for informational state changes. Used for EventClass.NOTIFICATION only. Lightweight schema, no priority scoring, no lifecycle, 7-day TTL. """ __tablename__ = "notifications" # Primary key id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Event classification event_domain = Column(String(50), nullable=False, index=True) event_type = Column(String(100), nullable=False) notification_type = Column(String(50), nullable=False) # NotificationType service = Column(String(100), nullable=False) # Content title = Column(String(500), nullable=False) message = Column(Text, nullable=False) # Entity context (optional) entity_type = Column(String(100), nullable=True) # 'batch', 'delivery', 'po', etc. entity_id = Column(String(100), nullable=True, index=True) old_state = Column(String(100), nullable=True) new_state = Column(String(100), nullable=True) # Display metadata notification_metadata = Column(JSONB, nullable=True) # Placement hints (lightweight) placement = Column(JSONB, nullable=False, default=['notification_panel']) # TTL tracking expires_at = Column(DateTime(timezone=True), nullable=False, index=True) # Timestamps created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True) __table_args__ = ( Index('idx_notifications_tenant_domain', 'tenant_id', 'event_domain', 'created_at'), Index('idx_notifications_entity', 'tenant_id', 'entity_type', 'entity_id'), Index('idx_notifications_expiry', 'expires_at'), ) def __init__(self, **kwargs): """Set default expiry to 7 days from now""" if 'expires_at' not in kwargs: kwargs['expires_at'] = utc_now() + timedelta(days=7) super().__init__(**kwargs) def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API/SSE""" return { 'id': str(self.id), 'tenant_id': str(self.tenant_id), 'event_class': 'notification', 'event_domain': self.event_domain, 'event_type': self.event_type, 'notification_type': self.notification_type, 'service': self.service, 'title': self.title, 'message': self.message, 'entity_type': self.entity_type, 'entity_id': self.entity_id, 'old_state': self.old_state, 'new_state': self.new_state, 'metadata': self.notification_metadata, 'placement': self.placement, 'timestamp': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None, 'expires_at': self.expires_at.isoformat() if self.expires_at else None, } # ============================================================ # RECOMMENDATION MODEL (Medium Weight, Dismissible) # ============================================================ class Recommendation(Base): """ Recommendation model for AI-generated suggestions. Used for EventClass.RECOMMENDATION only. Medium weight schema, light priority, no orchestrator queries, dismissible. """ __tablename__ = "recommendations" # Primary key id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Event classification event_domain = Column(String(50), nullable=False, index=True) event_type = Column(String(100), nullable=False) recommendation_type = Column(String(50), nullable=False) # RecommendationType service = Column(String(100), nullable=False) # Content title = Column(String(500), nullable=False) message = Column(Text, nullable=False) # Light priority (info by default) priority_level = Column(String(50), nullable=False, default='info') # Context (lighter than alerts) estimated_impact = Column(JSONB, nullable=True) suggested_actions = Column(JSONB, nullable=True) # AI reasoning ai_reasoning_summary = Column(Text, nullable=True) confidence_score = Column(Float, nullable=True) # Dismissal tracking dismissed_at = Column(DateTime(timezone=True), nullable=True, index=True) dismissed_by = Column(UUID(as_uuid=True), nullable=True) # Metadata recommendation_metadata = Column(JSONB, nullable=True) # Timestamps created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True) updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) __table_args__ = ( Index('idx_recommendations_tenant_domain', 'tenant_id', 'event_domain', 'created_at'), Index('idx_recommendations_dismissed', 'tenant_id', 'dismissed_at'), ) def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API/SSE""" return { 'id': str(self.id), 'tenant_id': str(self.tenant_id), 'event_class': 'recommendation', 'event_domain': self.event_domain, 'event_type': self.event_type, 'recommendation_type': self.recommendation_type, 'service': self.service, 'title': self.title, 'message': self.message, 'priority_level': self.priority_level, 'estimated_impact': self.estimated_impact, 'suggested_actions': self.suggested_actions, 'ai_reasoning_summary': self.ai_reasoning_summary, 'confidence_score': self.confidence_score, 'dismissed_at': self.dismissed_at.isoformat() if self.dismissed_at else None, 'dismissed_by': str(self.dismissed_by) if self.dismissed_by else None, 'metadata': self.recommendation_metadata, 'timestamp': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None, } # ============================================================ # INTERACTION TRACKING (Shared across all event types) # ============================================================ class EventInteraction(Base): """Event interaction tracking for analytics""" __tablename__ = "event_interactions" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Event reference (polymorphic) event_id = Column(UUID(as_uuid=True), nullable=False, index=True) event_class = Column(String(50), nullable=False, index=True) # 'alert', 'notification', 'recommendation' # User user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Interaction details interaction_type = Column(String(50), nullable=False, index=True) # acknowledged, resolved, dismissed, clicked, etc. interacted_at = Column(DateTime(timezone=True), nullable=False, default=utc_now, index=True) response_time_seconds = Column(Integer, nullable=True) # Context interaction_metadata = Column(JSONB, nullable=True) # Timestamps created_at = Column(DateTime(timezone=True), nullable=False, default=utc_now) __table_args__ = ( Index('idx_event_interactions_event', 'event_id', 'event_class'), Index('idx_event_interactions_user', 'tenant_id', 'user_id', 'interacted_at'), )