diff --git a/backend/igny8_core/business/notifications/__init__.py b/backend/igny8_core/business/notifications/__init__.py new file mode 100644 index 00000000..999ea976 --- /dev/null +++ b/backend/igny8_core/business/notifications/__init__.py @@ -0,0 +1 @@ +# Notifications module diff --git a/backend/igny8_core/business/notifications/admin.py b/backend/igny8_core/business/notifications/admin.py new file mode 100644 index 00000000..37838957 --- /dev/null +++ b/backend/igny8_core/business/notifications/admin.py @@ -0,0 +1,40 @@ +""" +Notification Admin Configuration +""" + +from django.contrib import admin +from unfold.admin import ModelAdmin + +from .models import Notification + + +@admin.register(Notification) +class NotificationAdmin(ModelAdmin): + list_display = ['title', 'notification_type', 'severity', 'account', 'user', 'is_read', 'created_at'] + list_filter = ['notification_type', 'severity', 'is_read', 'created_at'] + search_fields = ['title', 'message', 'account__name', 'user__email'] + readonly_fields = ['created_at', 'updated_at', 'read_at'] + ordering = ['-created_at'] + + fieldsets = ( + ('Notification', { + 'fields': ('account', 'user', 'notification_type', 'severity') + }), + ('Content', { + 'fields': ('title', 'message', 'site') + }), + ('Action', { + 'fields': ('action_url', 'action_label') + }), + ('Status', { + 'fields': ('is_read', 'read_at') + }), + ('Metadata', { + 'fields': ('metadata',), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) diff --git a/backend/igny8_core/business/notifications/apps.py b/backend/igny8_core/business/notifications/apps.py new file mode 100644 index 00000000..ef02b2ac --- /dev/null +++ b/backend/igny8_core/business/notifications/apps.py @@ -0,0 +1,13 @@ +""" +Notifications App Configuration +""" +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + """Configuration for the notifications app.""" + + default_auto_field = 'django.db.models.BigAutoField' + name = 'igny8_core.business.notifications' + label = 'notifications' + verbose_name = 'Notifications' diff --git a/backend/igny8_core/business/notifications/migrations/0001_initial.py b/backend/igny8_core/business/notifications/migrations/0001_initial.py new file mode 100644 index 00000000..706f9ac2 --- /dev/null +++ b/backend/igny8_core/business/notifications/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.9 on 2025-12-27 22:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('igny8_core_auth', '0018_add_country_remove_intent_seedkeyword'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('notification_type', models.CharField(choices=[('ai_cluster_complete', 'Clustering Complete'), ('ai_cluster_failed', 'Clustering Failed'), ('ai_ideas_complete', 'Ideas Generated'), ('ai_ideas_failed', 'Idea Generation Failed'), ('ai_content_complete', 'Content Generated'), ('ai_content_failed', 'Content Generation Failed'), ('ai_images_complete', 'Images Generated'), ('ai_images_failed', 'Image Generation Failed'), ('ai_prompts_complete', 'Image Prompts Created'), ('ai_prompts_failed', 'Image Prompts Failed'), ('content_ready_review', 'Content Ready for Review'), ('content_published', 'Content Published'), ('content_publish_failed', 'Publishing Failed'), ('wordpress_sync_success', 'WordPress Sync Complete'), ('wordpress_sync_failed', 'WordPress Sync Failed'), ('credits_low', 'Credits Running Low'), ('credits_depleted', 'Credits Depleted'), ('site_setup_complete', 'Site Setup Complete'), ('keywords_imported', 'Keywords Imported'), ('system_info', 'System Information')], default='system_info', max_length=50)), + ('title', models.CharField(max_length=200)), + ('message', models.TextField()), + ('severity', models.CharField(choices=[('info', 'Info'), ('success', 'Success'), ('warning', 'Warning'), ('error', 'Error')], default='info', max_length=20)), + ('object_id', models.PositiveIntegerField(blank=True, null=True)), + ('action_url', models.CharField(blank=True, max_length=500, null=True)), + ('action_label', models.CharField(blank=True, max_length=50, null=True)), + ('is_read', models.BooleanField(default=False)), + ('read_at', models.DateTimeField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='igny8_core_auth.site')), + ('user', models.ForeignKey(blank=True, help_text='If null, notification is visible to all account users', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['account', '-created_at'], name='notificatio_tenant__3b20a7_idx'), models.Index(fields=['account', 'is_read', '-created_at'], name='notificatio_tenant__9a5521_idx'), models.Index(fields=['user', '-created_at'], name='notificatio_user_id_05b4bc_idx')], + }, + ), + ] diff --git a/backend/igny8_core/business/notifications/migrations/__init__.py b/backend/igny8_core/business/notifications/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/igny8_core/business/notifications/models.py b/backend/igny8_core/business/notifications/models.py new file mode 100644 index 00000000..fb13c8b2 --- /dev/null +++ b/backend/igny8_core/business/notifications/models.py @@ -0,0 +1,191 @@ +""" +Notification Models for IGNY8 + +This module provides a notification system for tracking AI operations, +workflow events, and system alerts. +""" + +from django.db import models +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType + +from igny8_core.auth.models import AccountBaseModel + + +class NotificationType(models.TextChoices): + """Notification type choices""" + # AI Operations + AI_CLUSTER_COMPLETE = 'ai_cluster_complete', 'Clustering Complete' + AI_CLUSTER_FAILED = 'ai_cluster_failed', 'Clustering Failed' + AI_IDEAS_COMPLETE = 'ai_ideas_complete', 'Ideas Generated' + AI_IDEAS_FAILED = 'ai_ideas_failed', 'Idea Generation Failed' + AI_CONTENT_COMPLETE = 'ai_content_complete', 'Content Generated' + AI_CONTENT_FAILED = 'ai_content_failed', 'Content Generation Failed' + AI_IMAGES_COMPLETE = 'ai_images_complete', 'Images Generated' + AI_IMAGES_FAILED = 'ai_images_failed', 'Image Generation Failed' + AI_PROMPTS_COMPLETE = 'ai_prompts_complete', 'Image Prompts Created' + AI_PROMPTS_FAILED = 'ai_prompts_failed', 'Image Prompts Failed' + + # Workflow + CONTENT_READY_REVIEW = 'content_ready_review', 'Content Ready for Review' + CONTENT_PUBLISHED = 'content_published', 'Content Published' + CONTENT_PUBLISH_FAILED = 'content_publish_failed', 'Publishing Failed' + + # WordPress Sync + WORDPRESS_SYNC_SUCCESS = 'wordpress_sync_success', 'WordPress Sync Complete' + WORDPRESS_SYNC_FAILED = 'wordpress_sync_failed', 'WordPress Sync Failed' + + # Credits/Billing + CREDITS_LOW = 'credits_low', 'Credits Running Low' + CREDITS_DEPLETED = 'credits_depleted', 'Credits Depleted' + + # Setup + SITE_SETUP_COMPLETE = 'site_setup_complete', 'Site Setup Complete' + KEYWORDS_IMPORTED = 'keywords_imported', 'Keywords Imported' + + # System + SYSTEM_INFO = 'system_info', 'System Information' + + +class NotificationSeverity(models.TextChoices): + """Notification severity choices""" + INFO = 'info', 'Info' + SUCCESS = 'success', 'Success' + WARNING = 'warning', 'Warning' + ERROR = 'error', 'Error' + + +class Notification(AccountBaseModel): + """ + Notification model for tracking events and alerts + + Notifications are account-scoped (via AccountBaseModel) and can optionally target specific users. + They support generic relations to link to any related object. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='notifications', + help_text='If null, notification is visible to all account users' + ) + + # Notification content + notification_type = models.CharField( + max_length=50, + choices=NotificationType.choices, + default=NotificationType.SYSTEM_INFO + ) + title = models.CharField(max_length=200) + message = models.TextField() + severity = models.CharField( + max_length=20, + choices=NotificationSeverity.choices, + default=NotificationSeverity.INFO + ) + + # Related site (optional) + site = models.ForeignKey( + 'igny8_core_auth.Site', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='notifications' + ) + + # Generic relation to any object + content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + null=True, + blank=True + ) + object_id = models.PositiveIntegerField(null=True, blank=True) + content_object = GenericForeignKey('content_type', 'object_id') + + # Action + action_url = models.CharField(max_length=500, null=True, blank=True) + action_label = models.CharField(max_length=50, null=True, blank=True) + + # Status + is_read = models.BooleanField(default=False) + read_at = models.DateTimeField(null=True, blank=True) + + # Metadata for counts/details + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['account', '-created_at']), + models.Index(fields=['account', 'is_read', '-created_at']), + models.Index(fields=['user', '-created_at']), + ] + + def __str__(self): + return f"{self.title} ({self.notification_type})" + + def mark_as_read(self): + """Mark notification as read""" + if not self.is_read: + from django.utils import timezone + self.is_read = True + self.read_at = timezone.now() + self.save(update_fields=['is_read', 'read_at', 'updated_at']) + + @classmethod + def create_notification( + cls, + account, + notification_type: str, + title: str, + message: str, + severity: str = NotificationSeverity.INFO, + user=None, + site=None, + content_object=None, + action_url: str = None, + action_label: str = None, + metadata: dict = None + ): + """ + Factory method to create notifications + + Args: + account: The account this notification belongs to + notification_type: Type from NotificationType choices + title: Notification title + message: Notification message body + severity: Severity level from NotificationSeverity choices + user: Optional specific user (if None, visible to all account users) + site: Optional related site + content_object: Optional related object (using GenericForeignKey) + action_url: Optional URL for action button + action_label: Optional label for action button + metadata: Optional dict with additional data (counts, etc.) + + Returns: + Created Notification instance + """ + notification = cls( + account=account, + user=user, + notification_type=notification_type, + title=title, + message=message, + severity=severity, + site=site, + action_url=action_url, + action_label=action_label, + metadata=metadata or {} + ) + + if content_object: + notification.content_type = ContentType.objects.get_for_model(content_object) + notification.object_id = content_object.pk + + notification.save() + return notification diff --git a/backend/igny8_core/business/notifications/serializers.py b/backend/igny8_core/business/notifications/serializers.py new file mode 100644 index 00000000..7b0a9573 --- /dev/null +++ b/backend/igny8_core/business/notifications/serializers.py @@ -0,0 +1,90 @@ +""" +Notification Serializers +""" + +from rest_framework import serializers + +from .models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + """Serializer for Notification model""" + + site_name = serializers.CharField(source='site.name', read_only=True, default=None) + + class Meta: + model = Notification + fields = [ + 'id', + 'notification_type', + 'title', + 'message', + 'severity', + 'site', + 'site_name', + 'action_url', + 'action_label', + 'is_read', + 'read_at', + 'metadata', + 'created_at', + ] + read_only_fields = ['id', 'created_at', 'read_at'] + + +class NotificationListSerializer(serializers.ModelSerializer): + """Lightweight serializer for notification lists""" + + site_name = serializers.CharField(source='site.name', read_only=True, default=None) + time_ago = serializers.SerializerMethodField() + + class Meta: + model = Notification + fields = [ + 'id', + 'notification_type', + 'title', + 'message', + 'severity', + 'site_name', + 'action_url', + 'action_label', + 'is_read', + 'created_at', + 'time_ago', + 'metadata', + ] + + def get_time_ago(self, obj): + """Return human-readable time since notification""" + from django.utils import timezone + from datetime import timedelta + + now = timezone.now() + diff = now - obj.created_at + + if diff < timedelta(minutes=1): + return 'Just now' + elif diff < timedelta(hours=1): + minutes = int(diff.total_seconds() / 60) + return f'{minutes} minute{"s" if minutes != 1 else ""} ago' + elif diff < timedelta(days=1): + hours = int(diff.total_seconds() / 3600) + return f'{hours} hour{"s" if hours != 1 else ""} ago' + elif diff < timedelta(days=7): + days = diff.days + if days == 1: + return 'Yesterday' + return f'{days} days ago' + else: + return obj.created_at.strftime('%b %d, %Y') + + +class MarkReadSerializer(serializers.Serializer): + """Serializer for marking notifications as read""" + + notification_ids = serializers.ListField( + child=serializers.IntegerField(), + required=False, + help_text='List of notification IDs to mark as read. If empty, marks all as read.' + ) diff --git a/backend/igny8_core/business/notifications/services.py b/backend/igny8_core/business/notifications/services.py new file mode 100644 index 00000000..1df1b69f --- /dev/null +++ b/backend/igny8_core/business/notifications/services.py @@ -0,0 +1,306 @@ +""" +Notification Service + +Provides methods to create notifications for various events in the system. +""" + +from .models import Notification, NotificationType, NotificationSeverity + + +class NotificationService: + """Service for creating notifications""" + + @staticmethod + def notify_clustering_complete(account, site=None, cluster_count=0, keyword_count=0, user=None): + """Create notification when keyword clustering completes""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.AI_CLUSTER_COMPLETE, + title='Clustering Complete', + message=f'Created {cluster_count} clusters from {keyword_count} keywords', + severity=NotificationSeverity.SUCCESS, + user=user, + site=site, + action_url='/planner/clusters', + action_label='View Clusters', + metadata={'cluster_count': cluster_count, 'keyword_count': keyword_count} + ) + + @staticmethod + def notify_clustering_failed(account, site=None, error=None, user=None): + """Create notification when keyword clustering fails""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.AI_CLUSTER_FAILED, + title='Clustering Failed', + message=f'Failed to cluster keywords: {error}' if error else 'Failed to cluster keywords', + severity=NotificationSeverity.ERROR, + user=user, + site=site, + action_url='/planner/keywords', + action_label='View Keywords', + metadata={'error': str(error) if error else None} + ) + + @staticmethod + def notify_ideas_complete(account, site=None, idea_count=0, cluster_count=0, user=None): + """Create notification when idea generation completes""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.AI_IDEAS_COMPLETE, + title='Ideas Generated', + message=f'Generated {idea_count} content ideas from {cluster_count} clusters', + severity=NotificationSeverity.SUCCESS, + user=user, + site=site, + action_url='/planner/ideas', + action_label='View Ideas', + metadata={'idea_count': idea_count, 'cluster_count': cluster_count} + ) + + @staticmethod + def notify_ideas_failed(account, site=None, error=None, user=None): + """Create notification when idea generation fails""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.AI_IDEAS_FAILED, + title='Idea Generation Failed', + message=f'Failed to generate ideas: {error}' if error else 'Failed to generate ideas', + severity=NotificationSeverity.ERROR, + user=user, + site=site, + action_url='/planner/clusters', + action_label='View Clusters', + metadata={'error': str(error) if error else None} + ) + + @staticmethod + def notify_content_complete(account, site=None, article_count=0, word_count=0, user=None): + """Create notification when content generation completes""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.AI_CONTENT_COMPLETE, + title='Content Generated', + message=f'Generated {article_count} article{"s" if article_count != 1 else ""} ({word_count:,} words)', + severity=NotificationSeverity.SUCCESS, + user=user, + site=site, + action_url='/writer/content', + action_label='View Content', + metadata={'article_count': article_count, 'word_count': word_count} + ) + + @staticmethod + def notify_content_failed(account, site=None, error=None, user=None): + """Create notification when content generation fails""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.AI_CONTENT_FAILED, + title='Content Generation Failed', + message=f'Failed to generate content: {error}' if error else 'Failed to generate content', + severity=NotificationSeverity.ERROR, + user=user, + site=site, + action_url='/writer/tasks', + action_label='View Tasks', + metadata={'error': str(error) if error else None} + ) + + @staticmethod + def notify_images_complete(account, site=None, image_count=0, user=None): + """Create notification when image generation completes""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.AI_IMAGES_COMPLETE, + title='Images Generated', + message=f'Generated {image_count} image{"s" if image_count != 1 else ""}', + severity=NotificationSeverity.SUCCESS, + user=user, + site=site, + action_url='/writer/images', + action_label='View Images', + metadata={'image_count': image_count} + ) + + @staticmethod + def notify_images_failed(account, site=None, error=None, image_count=0, user=None): + """Create notification when image generation fails""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.AI_IMAGES_FAILED, + title='Image Generation Failed', + message=f'Failed to generate {image_count} image{"s" if image_count != 1 else ""}: {error}' if error else f'Failed to generate images', + severity=NotificationSeverity.ERROR, + user=user, + site=site, + action_url='/writer/images', + action_label='View Images', + metadata={'error': str(error) if error else None, 'image_count': image_count} + ) + + @staticmethod + def notify_prompts_complete(account, site=None, prompt_count=0, user=None): + """Create notification when image prompt generation completes""" + in_article_count = prompt_count - 1 if prompt_count > 1 else 0 + message = f'{prompt_count} image prompts ready (1 featured + {in_article_count} in-article)' if in_article_count > 0 else '1 image prompt ready' + + return Notification.create_notification( + account=account, + notification_type=NotificationType.AI_PROMPTS_COMPLETE, + title='Image Prompts Created', + message=message, + severity=NotificationSeverity.SUCCESS, + user=user, + site=site, + action_url='/writer/images', + action_label='Generate Images', + metadata={'prompt_count': prompt_count, 'in_article_count': in_article_count} + ) + + @staticmethod + def notify_prompts_failed(account, site=None, error=None, user=None): + """Create notification when image prompt generation fails""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.AI_PROMPTS_FAILED, + title='Image Prompts Failed', + message=f'Failed to create image prompts: {error}' if error else 'Failed to create image prompts', + severity=NotificationSeverity.ERROR, + user=user, + site=site, + action_url='/writer/content', + action_label='View Content', + metadata={'error': str(error) if error else None} + ) + + @staticmethod + def notify_content_published(account, site=None, title='', content_object=None, user=None): + """Create notification when content is published""" + site_name = site.name if site else 'site' + return Notification.create_notification( + account=account, + notification_type=NotificationType.CONTENT_PUBLISHED, + title='Content Published', + message=f'"{title}" published to {site_name}', + severity=NotificationSeverity.SUCCESS, + user=user, + site=site, + content_object=content_object, + action_url='/writer/published', + action_label='View Published', + metadata={'content_title': title} + ) + + @staticmethod + def notify_publish_failed(account, site=None, title='', error=None, user=None): + """Create notification when publishing fails""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.CONTENT_PUBLISH_FAILED, + title='Publishing Failed', + message=f'Failed to publish "{title}": {error}' if error else f'Failed to publish "{title}"', + severity=NotificationSeverity.ERROR, + user=user, + site=site, + action_url='/writer/review', + action_label='View Review', + metadata={'content_title': title, 'error': str(error) if error else None} + ) + + @staticmethod + def notify_wordpress_sync_success(account, site=None, count=0, user=None): + """Create notification when WordPress sync succeeds""" + site_name = site.name if site else 'site' + return Notification.create_notification( + account=account, + notification_type=NotificationType.WORDPRESS_SYNC_SUCCESS, + title='WordPress Synced', + message=f'Synced {count} item{"s" if count != 1 else ""} with {site_name}', + severity=NotificationSeverity.SUCCESS, + user=user, + site=site, + action_url='/writer/published', + action_label='View Published', + metadata={'sync_count': count} + ) + + @staticmethod + def notify_wordpress_sync_failed(account, site=None, error=None, user=None): + """Create notification when WordPress sync fails""" + site_name = site.name if site else 'site' + return Notification.create_notification( + account=account, + notification_type=NotificationType.WORDPRESS_SYNC_FAILED, + title='Sync Failed', + message=f'WordPress sync failed for {site_name}: {error}' if error else f'WordPress sync failed for {site_name}', + severity=NotificationSeverity.ERROR, + user=user, + site=site, + action_url=f'/sites/{site.id}/integrations' if site else '/sites', + action_label='Check Integration', + metadata={'error': str(error) if error else None} + ) + + @staticmethod + def notify_credits_low(account, percentage_used=80, credits_remaining=0, user=None): + """Create notification when credits are running low""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.CREDITS_LOW, + title='Credits Running Low', + message=f"You've used {percentage_used}% of your credits. {credits_remaining} credits remaining.", + severity=NotificationSeverity.WARNING, + user=user, + action_url='/account/billing', + action_label='Upgrade Plan', + metadata={'percentage_used': percentage_used, 'credits_remaining': credits_remaining} + ) + + @staticmethod + def notify_credits_depleted(account, user=None): + """Create notification when credits are depleted""" + return Notification.create_notification( + account=account, + notification_type=NotificationType.CREDITS_DEPLETED, + title='Credits Depleted', + message='Your credits are exhausted. Upgrade to continue using AI features.', + severity=NotificationSeverity.ERROR, + user=user, + action_url='/account/billing', + action_label='Upgrade Now', + metadata={} + ) + + @staticmethod + def notify_site_setup_complete(account, site=None, user=None): + """Create notification when site setup is complete""" + site_name = site.name if site else 'Site' + return Notification.create_notification( + account=account, + notification_type=NotificationType.SITE_SETUP_COMPLETE, + title='Site Ready', + message=f'{site_name} is fully configured and ready!', + severity=NotificationSeverity.SUCCESS, + user=user, + site=site, + action_url=f'/sites/{site.id}' if site else '/sites', + action_label='View Site', + metadata={} + ) + + @staticmethod + def notify_keywords_imported(account, site=None, count=0, user=None): + """Create notification when keywords are imported""" + site_name = site.name if site else 'site' + return Notification.create_notification( + account=account, + notification_type=NotificationType.KEYWORDS_IMPORTED, + title='Keywords Imported', + message=f'Added {count} keyword{"s" if count != 1 else ""} to {site_name}', + severity=NotificationSeverity.INFO, + user=user, + site=site, + action_url='/planner/keywords', + action_label='View Keywords', + metadata={'keyword_count': count} + ) diff --git a/backend/igny8_core/business/notifications/urls.py b/backend/igny8_core/business/notifications/urls.py new file mode 100644 index 00000000..abd05216 --- /dev/null +++ b/backend/igny8_core/business/notifications/urls.py @@ -0,0 +1,15 @@ +""" +Notification URL Configuration +""" + +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import NotificationViewSet + +router = DefaultRouter() +router.register(r'notifications', NotificationViewSet, basename='notification') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/backend/igny8_core/business/notifications/views.py b/backend/igny8_core/business/notifications/views.py new file mode 100644 index 00000000..bf3258c0 --- /dev/null +++ b/backend/igny8_core/business/notifications/views.py @@ -0,0 +1,132 @@ +""" +Notification Views +""" + +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from django.utils import timezone + +from igny8_core.api.pagination import CustomPageNumberPagination +from igny8_core.api.base import AccountModelViewSet + +from .models import Notification +from .serializers import NotificationSerializer, NotificationListSerializer, MarkReadSerializer + + +class NotificationViewSet(AccountModelViewSet): + """ + ViewSet for managing notifications + + Endpoints: + - GET /api/v1/notifications/ - List notifications + - GET /api/v1/notifications/{id}/ - Get notification detail + - DELETE /api/v1/notifications/{id}/ - Delete notification + - POST /api/v1/notifications/{id}/read/ - Mark single notification as read + - POST /api/v1/notifications/read-all/ - Mark all notifications as read + - GET /api/v1/notifications/unread-count/ - Get unread notification count + """ + + serializer_class = NotificationSerializer + pagination_class = CustomPageNumberPagination + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Filter notifications for current account and user""" + from django.db.models import Q + + user = self.request.user + account = getattr(user, 'account', None) + + if not account: + return Notification.objects.none() + + # Get notifications for this account that are either: + # - For all users (user=None) + # - For this specific user + queryset = Notification.objects.filter( + Q(account=account, user__isnull=True) | + Q(account=account, user=user) + ).select_related('site').order_by('-created_at') + + # Optional filters + is_read = self.request.query_params.get('is_read') + if is_read is not None: + queryset = queryset.filter(is_read=is_read.lower() == 'true') + + notification_type = self.request.query_params.get('type') + if notification_type: + queryset = queryset.filter(notification_type=notification_type) + + severity = self.request.query_params.get('severity') + if severity: + queryset = queryset.filter(severity=severity) + + return queryset + + def get_serializer_class(self): + """Use list serializer for list action""" + if self.action == 'list': + return NotificationListSerializer + return NotificationSerializer + + def list(self, request, *args, **kwargs): + """List notifications with unread count""" + queryset = self.filter_queryset(self.get_queryset()) + + # Get unread count + unread_count = queryset.filter(is_read=False).count() + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + response = self.get_paginated_response(serializer.data) + response.data['unread_count'] = unread_count + return response + + serializer = self.get_serializer(queryset, many=True) + return Response({ + 'results': serializer.data, + 'unread_count': unread_count + }) + + @action(detail=True, methods=['post']) + def read(self, request, pk=None): + """Mark a single notification as read""" + notification = self.get_object() + notification.mark_as_read() + serializer = self.get_serializer(notification) + return Response(serializer.data) + + @action(detail=False, methods=['post'], url_path='read-all') + def read_all(self, request): + """Mark all notifications as read""" + serializer = MarkReadSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + notification_ids = serializer.validated_data.get('notification_ids', []) + + queryset = self.get_queryset().filter(is_read=False) + + if notification_ids: + queryset = queryset.filter(id__in=notification_ids) + + count = queryset.update(is_read=True, read_at=timezone.now()) + + return Response({ + 'status': 'success', + 'marked_read': count + }) + + @action(detail=False, methods=['get'], url_path='unread-count') + def unread_count(self, request): + """Get count of unread notifications""" + count = self.get_queryset().filter(is_read=False).count() + return Response({'unread_count': count}) + + def destroy(self, request, *args, **kwargs): + """Delete a notification""" + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index f278b125..4b9474e4 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -66,6 +66,7 @@ INSTALLED_APPS = [ 'igny8_core.modules.system.apps.SystemConfig', 'igny8_core.modules.billing.apps.BillingConfig', 'igny8_core.business.automation', # AI Automation Pipeline + 'igny8_core.business.notifications.apps.NotificationsConfig', # User Notifications 'igny8_core.business.optimization.apps.OptimizationConfig', 'igny8_core.business.publishing.apps.PublishingConfig', 'igny8_core.business.integration.apps.IntegrationConfig', diff --git a/backend/igny8_core/urls.py b/backend/igny8_core/urls.py index bc5b2b0f..ec8c9125 100644 --- a/backend/igny8_core/urls.py +++ b/backend/igny8_core/urls.py @@ -44,6 +44,7 @@ urlpatterns = [ path('api/v1/billing/', include('igny8_core.business.billing.urls')), # Billing (credits, invoices, payments, packages) path('api/v1/admin/', include('igny8_core.modules.billing.admin_urls')), # Admin billing path('api/v1/automation/', include('igny8_core.business.automation.urls')), # Automation endpoints + path('api/v1/', include('igny8_core.business.notifications.urls')), # Notifications endpoints path('api/v1/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints diff --git a/frontend/src/components/common/ProgressModal.tsx b/frontend/src/components/common/ProgressModal.tsx index 1e9ed1dc..a6a8b1aa 100644 --- a/frontend/src/components/common/ProgressModal.tsx +++ b/frontend/src/components/common/ProgressModal.tsx @@ -46,41 +46,47 @@ const getSuccessMessage = (functionId?: string, title?: string, stepLogs?: any[] const clusterCount = extractCount(/(\d+)\s+cluster/i, stepLogs || []); if (keywordCount && clusterCount) { - return `Clustering complete\n${keywordCount} keyword${keywordCount !== '1' ? 's' : ''} mapped and grouped into ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`; + return `✓ Created ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''} from ${keywordCount} keyword${keywordCount !== '1' ? 's' : ''}`; } else if (clusterCount) { - return `Clustering complete\n${clusterCount} cluster${clusterCount !== '1' ? 's' : ''} created`; + return `✓ Created ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`; } else if (keywordCount) { - return `Clustering complete\n${keywordCount} keyword${keywordCount !== '1' ? 's' : ''} mapped and grouped into clusters`; + return `✓ Created clusters from ${keywordCount} keyword${keywordCount !== '1' ? 's' : ''}`; } - return 'Clustering complete\nKeywords mapped and grouped into clusters'; + return '✓ Keywords clustered successfully'; } if (funcName.includes('idea')) { const ideaCount = extractCount(/(\d+)\s+idea/i, stepLogs || []); + const clusterCount = extractCount(/(\d+)\s+cluster/i, stepLogs || []); - if (ideaCount) { - return `Content ideas & outlines created successfully`; + if (ideaCount && clusterCount) { + return `✓ Generated ${ideaCount} content idea${ideaCount !== '1' ? 's' : ''} from ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`; + } else if (ideaCount) { + return `✓ Generated ${ideaCount} content idea${ideaCount !== '1' ? 's' : ''} with outlines`; } - return 'Content ideas & outlines created successfully'; + return '✓ Content ideas & outlines created successfully'; } if (funcName.includes('content')) { const taskCount = extractCount(/(\d+)\s+task/i, stepLogs || []); const articleCount = extractCount(/(\d+)\s+article/i, stepLogs || []); + const wordCount = extractCount(/(\d+[,\d]*)\s+word/i, stepLogs || []); - if (articleCount) { - return `Article${articleCount !== '1' ? 's' : ''} drafted successfully — ${articleCount} article${articleCount !== '1' ? 's' : ''} generated.`; + if (articleCount && wordCount) { + return `✓ ${articleCount} article${articleCount !== '1' ? 's' : ''} generated (${wordCount} words total)`; + } else if (articleCount) { + return `✓ ${articleCount} article${articleCount !== '1' ? 's' : ''} generated`; } else if (taskCount) { - return `Article${taskCount !== '1' ? 's' : ''} drafted successfully — ${taskCount} task${taskCount !== '1' ? 's' : ''} completed.`; + return `✓ ${taskCount} article${taskCount !== '1' ? 's' : ''} generated`; } - return 'Article drafted successfully.'; + return '✓ Article generated successfully'; } // Check for image generation from prompts FIRST (more specific) if (funcName.includes('image') && funcName.includes('from')) { // Image generation from prompts const imageCount = extractCount(/(\d+)\s+image/i, stepLogs || []); if (imageCount) { - return `${imageCount} image${imageCount !== '1' ? 's' : ''} generated successfully`; + return `✓ ${imageCount} image${imageCount !== '1' ? 's' : ''} generated and saved`; } - return 'Images generated successfully'; + return '✓ Images generated and saved'; } else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) { // Image prompt generation // Try to extract from SAVE step message first (most reliable) @@ -92,9 +98,9 @@ const getSuccessMessage = (functionId?: string, title?: string, stepLogs?: any[] const totalPrompts = parseInt(countMatch[1], 10); const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0; if (inArticleCount > 0) { - return `Featured Image and ${inArticleCount} In‑article Image Prompts ready for image generation`; + return `✓ ${totalPrompts} image prompts ready (1 featured + ${inArticleCount} in-article)`; } else { - return `Featured Image Prompt ready for image generation`; + return `✓ 1 image prompt ready`; } } } @@ -107,9 +113,9 @@ const getSuccessMessage = (functionId?: string, title?: string, stepLogs?: any[] const totalPrompts = parseInt(match[1], 10); const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0; if (inArticleCount > 0) { - return `Featured Image and ${inArticleCount} In‑article Image Prompts ready for image generation`; + return `✓ ${totalPrompts} image prompts ready (1 featured + ${inArticleCount} in-article)`; } else { - return `Featured Image Prompt ready for image generation`; + return `✓ 1 image prompt ready`; } } } @@ -120,49 +126,49 @@ const getSuccessMessage = (functionId?: string, title?: string, stepLogs?: any[] const totalPrompts = parseInt(promptCount, 10); const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0; if (inArticleCount > 0) { - return `Featured Image and ${inArticleCount} In‑article Image Prompts ready for image generation`; + return `✓ ${totalPrompts} image prompts ready (1 featured + ${inArticleCount} in-article)`; } else { - return `Featured Image Prompt ready for image generation`; + return `✓ 1 image prompt ready`; } } // Default message - return 'Image prompts ready for generation'; + return '✓ Image prompts ready'; } - return 'Task completed successfully.'; + return '✓ Task completed successfully'; }; -// Get step definitions per function +// Get step definitions per function - these are default labels that get replaced with dynamic counts const getStepsForFunction = (functionId?: string, title?: string): Array<{phase: string, label: string}> => { const funcName = functionId?.toLowerCase() || title?.toLowerCase() || ''; if (funcName.includes('cluster')) { return [ - { phase: 'INIT', label: 'Validating keywords' }, - { phase: 'PREP', label: 'Loading keyword data' }, - { phase: 'AI_CALL', label: 'Generating clusters with Igny8 Semantic SEO Model' }, - { phase: 'PARSE', label: 'Organizing clusters' }, + { phase: 'INIT', label: 'Validating keywords for clustering' }, + { phase: 'PREP', label: 'Analyzing keyword relationships' }, + { phase: 'AI_CALL', label: 'Grouping keywords by search intent' }, + { phase: 'PARSE', label: 'Organizing semantic clusters' }, { phase: 'SAVE', label: 'Saving clusters' }, ]; } if (funcName.includes('idea')) { return [ - { phase: 'INIT', label: 'Verifying cluster integrity' }, - { phase: 'PREP', label: 'Loading cluster keywords' }, - { phase: 'AI_CALL', label: 'Generating ideas with Igny8 Semantic AI' }, - { phase: 'PARSE', label: 'High-opportunity ideas generated' }, - { phase: 'SAVE', label: 'Content Outline for Ideas generated' }, + { phase: 'INIT', label: 'Analyzing clusters for content opportunities' }, + { phase: 'PREP', label: 'Mapping keywords to topic briefs' }, + { phase: 'AI_CALL', label: 'Generating content ideas' }, + { phase: 'PARSE', label: 'Structuring article outlines' }, + { phase: 'SAVE', label: 'Saving content ideas with outlines' }, ]; } if (funcName.includes('content')) { return [ - { phase: 'INIT', label: 'Validating task' }, - { phase: 'PREP', label: 'Preparing content idea' }, - { phase: 'AI_CALL', label: 'Writing article with Igny8 Semantic AI' }, - { phase: 'PARSE', label: 'Formatting content' }, - { phase: 'SAVE', label: 'Saving article' }, + { phase: 'INIT', label: 'Preparing articles for generation' }, + { phase: 'PREP', label: 'Building content brief with target keywords' }, + { phase: 'AI_CALL', label: 'Writing articles with Igny8 Semantic AI' }, + { phase: 'PARSE', label: 'Formatting HTML content and metadata' }, + { phase: 'SAVE', label: 'Saving articles' }, ]; } @@ -170,20 +176,20 @@ const getStepsForFunction = (functionId?: string, title?: string): Array<{phase: if (funcName.includes('image') && funcName.includes('from')) { // Image generation from prompts return [ - { phase: 'INIT', label: 'Validating image prompts' }, - { phase: 'PREP', label: 'Preparing image generation queue' }, + { phase: 'INIT', label: 'Queuing images for generation' }, + { phase: 'PREP', label: 'Preparing AI image generation' }, { phase: 'AI_CALL', label: 'Generating images with AI' }, - { phase: 'PARSE', label: 'Processing image URLs' }, - { phase: 'SAVE', label: 'Saving image URLs' }, + { phase: 'PARSE', label: 'Processing generated images' }, + { phase: 'SAVE', label: 'Uploading images to media library' }, ]; } else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) { // Image prompt generation return [ - { phase: 'INIT', label: 'Checking content and image slots' }, - { phase: 'PREP', label: 'Mapping content for image prompts' }, - { phase: 'AI_CALL', label: 'Writing Featured Image Prompts' }, - { phase: 'PARSE', label: 'Writing In‑article Image Prompts' }, - { phase: 'SAVE', label: 'Assigning Prompts to Dedicated Slots' }, + { phase: 'INIT', label: 'Analyzing content for image opportunities' }, + { phase: 'PREP', label: 'Identifying image slots' }, + { phase: 'AI_CALL', label: 'Creating optimized prompts' }, + { phase: 'PARSE', label: 'Refining contextual image descriptions' }, + { phase: 'SAVE', label: 'Assigning prompts to image slots' }, ]; } @@ -286,210 +292,248 @@ export default function ProgressModal({ return match && match[1] ? match[1] : ''; }; + // Helper to extract count from all step logs + const extractCountFromLogs = (pattern: RegExp): string => { + for (const log of allStepLogs) { + const match = log.message?.match(pattern); + if (match && match[1]) return match[1]; + } + return ''; + }; + if (funcName.includes('cluster')) { if (stepPhase === 'INIT') { - // For INIT: Try to extract keyword count from message or stepLogs - // Backend message might include keyword names (e.g., "Validating keyword1, keyword2, keyword3 and 5 more keywords") - // Or we need to extract the total count - if (message && message !== defaultLabel && message.includes('Validating')) { - // Try to extract total count from message - const countMatch = message.match(/(\d+)\s+more keyword/i); - if (countMatch) { - const moreCount = parseInt(countMatch[1], 10); - // Count keywords before "and X more" - typically 3 - const shownCount = 3; - const totalCount = shownCount + moreCount; - return `Validating ${totalCount} keyword${totalCount !== 1 ? 's' : ''}`; - } - // Try to find total keyword count in any step log - for (const log of allStepLogs) { - const keywordCountMatch = log.message?.match(/(\d+)\s+keyword/i); - if (keywordCountMatch) { - const totalCount = parseInt(keywordCountMatch[1], 10); - return `Validating ${totalCount} keyword${totalCount !== 1 ? 's' : ''}`; - } - } - // If message has keyword names but no count, return as-is - return message; - } - // Fallback: try to extract count from stepLogs - for (const log of allStepLogs) { - const keywordCountMatch = log.message?.match(/(\d+)\s+keyword/i); - if (keywordCountMatch) { - const totalCount = parseInt(keywordCountMatch[1], 10); - return `Validating ${totalCount} keyword${totalCount !== 1 ? 's' : ''}`; - } - } - // Final fallback: use default label - return defaultLabel; - } else if (stepPhase === 'PREP') { - // For PREP: Show count of keywords being loaded - const keywordCount = extractCount(/(\d+)\s+keyword/i); + // For INIT: Try to extract keyword count + const keywordCount = extractCount(/(\d+)\s+keyword/i) || extractCountFromLogs(/(\d+)\s+keyword/i); if (keywordCount) { - return `Loading ${keywordCount} keyword${keywordCount !== '1' ? 's' : ''} for clustering`; + return `Validating ${keywordCount} keyword${keywordCount !== '1' ? 's' : ''} for clustering`; } - return message; + // Try to extract from "and X more keywords" format + const moreMatch = message.match(/(\d+)\s+more keyword/i); + if (moreMatch) { + const totalCount = parseInt(moreMatch[1], 10) + 3; // 3 shown + more + return `Validating ${totalCount} keywords for clustering`; + } + return 'Validating keywords for clustering'; + } else if (stepPhase === 'PREP') { + // For PREP: Show "Analyzing keyword relationships" + return 'Analyzing keyword relationships'; } else if (stepPhase === 'AI_CALL') { - // For AI_CALL: Show "Generating clusters with Igny8 Semantic SEO Model" - return 'Generating clusters with Igny8 Semantic SEO Model'; + // For AI_CALL: Try to get keyword count + const keywordCount = extractCount(/(\d+)\s+keyword/i) || extractCountFromLogs(/(\d+)\s+keyword/i); + if (keywordCount) { + return `Grouping keywords by search intent (${keywordCount} keywords)`; + } + return 'Grouping keywords by search intent'; } else if (stepPhase === 'PARSE') { - // For PARSE: Show count of clusters created + // For PARSE: Show "Organizing X semantic clusters" + const clusterCount = extractCount(/(\d+)\s+cluster/i) || extractCountFromLogs(/(\d+)\s+cluster/i); + if (clusterCount) { + return `Organizing ${clusterCount} semantic cluster${clusterCount !== '1' ? 's' : ''}`; + } + return 'Organizing semantic clusters'; + } else if (stepPhase === 'SAVE') { + // For SAVE: Show "Saving X clusters with Y keywords" + const clusterCount = extractCount(/(\d+)\s+cluster/i) || extractCountFromLogs(/(\d+)\s+cluster/i); + const keywordCount = extractCountFromLogs(/(\d+)\s+keyword/i); + if (clusterCount && keywordCount) { + return `Saving ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''} with ${keywordCount} keywords`; + } else if (clusterCount) { + return `Saving ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`; + } + return 'Saving clusters'; + } + } else if (funcName.includes('idea')) { + if (stepPhase === 'INIT') { + // For INIT: Try to extract cluster count const clusterCount = extractCount(/(\d+)\s+cluster/i); if (clusterCount) { - return `${clusterCount} cluster${clusterCount !== '1' ? 's' : ''} created`; + return `Analyzing ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''} for content opportunities`; } // Try to find cluster count in any step log for (const log of allStepLogs) { const count = log.message?.match(/(\d+)\s+cluster/i); if (count && count[1]) { - return `${count[1]} cluster${count[1] !== '1' ? 's' : ''} created`; + return `Analyzing ${count[1]} cluster${count[1] !== '1' ? 's' : ''} for content opportunities`; } } - return message; - } else if (stepPhase === 'SAVE') { - // For SAVE: Show count of clusters being saved + return 'Analyzing clusters for content opportunities'; + } else if (stepPhase === 'PREP') { + // For PREP: Try to extract keyword count + const keywordCount = extractCount(/(\d+)\s+keyword/i); + if (keywordCount) { + return `Mapping ${keywordCount} keyword${keywordCount !== '1' ? 's' : ''} to topic briefs`; + } + return 'Mapping keywords to topic briefs'; + } else if (stepPhase === 'AI_CALL') { + // For AI_CALL: Try to extract cluster count const clusterCount = extractCount(/(\d+)\s+cluster/i); if (clusterCount) { - return `Saving ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`; + return `Generating content ideas for ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`; } - return message; - } - } else if (funcName.includes('idea')) { - if (stepPhase === 'INIT') { - // For INIT: Show "Verifying cluster integrity" - return 'Verifying cluster integrity'; - } else if (stepPhase === 'PREP') { - // For PREP: Show "Loading cluster keywords" - return 'Loading cluster keywords'; - } else if (stepPhase === 'AI_CALL') { - // For AI_CALL: Show "Generating ideas with Igny8 Semantic AI" - return 'Generating ideas with Igny8 Semantic AI'; + // Try to find cluster count in any step log + for (const log of allStepLogs) { + const count = log.message?.match(/(\d+)\s+cluster/i); + if (count && count[1]) { + return `Generating content ideas for ${count[1]} cluster${count[1] !== '1' ? 's' : ''}`; + } + } + return 'Generating content ideas'; } else if (stepPhase === 'PARSE') { - // For PARSE: Show "X high-opportunity ideas generated" + // For PARSE: Show "Structuring X article outlines" const ideaCount = extractCount(/(\d+)\s+idea/i); if (ideaCount) { - return `${ideaCount} high-opportunity idea${ideaCount !== '1' ? 's' : ''} generated`; + return `Structuring ${ideaCount} article outline${ideaCount !== '1' ? 's' : ''}`; } // Try to find idea count in any step log for (const log of allStepLogs) { const count = log.message?.match(/(\d+)\s+idea/i); if (count && count[1]) { - return `${count[1]} high-opportunity idea${count[1] !== '1' ? 's' : ''} generated`; + return `Structuring ${count[1]} article outline${count[1] !== '1' ? 's' : ''}`; } } - return message; + return 'Structuring article outlines'; } else if (stepPhase === 'SAVE') { - // For SAVE: Show "Content Outline for Ideas generated" - return 'Content Outline for Ideas generated'; + // For SAVE: Show "Saving X content ideas with outlines" + const ideaCount = extractCount(/(\d+)\s+idea/i); + if (ideaCount) { + return `Saving ${ideaCount} content idea${ideaCount !== '1' ? 's' : ''} with outlines`; + } + // Try to find idea count in any step log + for (const log of allStepLogs) { + const count = log.message?.match(/(\d+)\s+idea/i); + if (count && count[1]) { + return `Saving ${count[1]} content idea${count[1] !== '1' ? 's' : ''} with outlines`; + } + } + return 'Saving content ideas with outlines'; } } else if (funcName.includes('content')) { - if (stepPhase === 'AI_CALL') { - // For AI_CALL: Show "Writing article with Igny8 Semantic AI" - return 'Writing article with Igny8 Semantic AI'; - } else if (stepPhase === 'PARSE') { - const articleCount = extractCount(/(\d+)\s+article/i); - if (articleCount) { - return `${articleCount} article${articleCount !== '1' ? 's' : ''} created`; + if (stepPhase === 'INIT') { + // Try to extract task/article count + const taskCount = extractCount(/(\d+)\s+task/i) || extractCount(/(\d+)\s+article/i); + if (taskCount) { + return `Preparing ${taskCount} article${taskCount !== '1' ? 's' : ''} for generation`; } + return 'Preparing articles for generation'; + } else if (stepPhase === 'PREP') { + // Try to extract keyword count + const keywordCount = extractCount(/(\d+)\s+keyword/i); + if (keywordCount) { + return `Building content brief with ${keywordCount} target keyword${keywordCount !== '1' ? 's' : ''}`; + } + return 'Building content brief with target keywords'; + } else if (stepPhase === 'AI_CALL') { + // Try to extract count + const taskCount = extractCount(/(\d+)\s+task/i) || extractCount(/(\d+)\s+article/i); + if (taskCount) { + return `Writing ${taskCount} article${taskCount !== '1' ? 's' : ''} with Igny8 Semantic AI`; + } + return 'Writing articles with Igny8 Semantic AI'; + } else if (stepPhase === 'PARSE') { + return 'Formatting HTML content and metadata'; + } else if (stepPhase === 'SAVE') { + const articleCount = extractCount(/(\d+)\s+article/i); + const wordCount = extractCount(/(\d+[,\d]*)\s+word/i); + if (articleCount && wordCount) { + return `Saving ${articleCount} article${articleCount !== '1' ? 's' : ''} (${wordCount} words)`; + } else if (articleCount) { + return `Saving ${articleCount} article${articleCount !== '1' ? 's' : ''}`; + } + return 'Saving articles'; } } else if (funcName.includes('image') && funcName.includes('from')) { // Image generation from prompts - if (stepPhase === 'PREP') { - // Extract image count from PREP step message - const imageCount = extractCount(/(\d+)\s+image/i); + if (stepPhase === 'INIT') { + // Try to get image count + const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i); if (imageCount) { - return `Preparing to generate ${imageCount} image${imageCount !== '1' ? 's' : ''}`; + return `Queuing ${imageCount} image${imageCount !== '1' ? 's' : ''} for generation`; } - if (stepLog?.message) { - const match = stepLog.message.match(/Preparing to generate (\d+)\s+image/i); - if (match && match[1]) { - return `Preparing to generate ${match[1]} image${match[1] !== '1' ? 's' : ''}`; - } + return 'Queuing images for generation'; + } else if (stepPhase === 'PREP') { + // Extract image count from PREP step message + const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i); + if (imageCount) { + return `Preparing AI image generation (${imageCount} images)`; } - return 'Preparing image generation queue'; + return 'Preparing AI image generation'; } else if (stepPhase === 'AI_CALL') { - // Extract current image number from message - const match = stepLog?.message?.match(/Generating.*image (\d+)/i); - if (match && match[1]) { - return `Generating image ${match[1]} with AI`; + // Extract current image number from message for "Generating image X/Y..." + const currentMatch = stepLog?.message?.match(/image (\d+)/i); + const totalCount = extractCountFromLogs(/(\d+)\s+image/i); + if (currentMatch && totalCount) { + return `Generating image ${currentMatch[1]}/${totalCount}...`; + } else if (currentMatch) { + return `Generating image ${currentMatch[1]}...`; } return 'Generating images with AI'; } else if (stepPhase === 'PARSE') { // Extract image count from PARSE step - const imageCount = extractCount(/(\d+)\s+image/i); + const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i); if (imageCount) { - return `${imageCount} image${imageCount !== '1' ? 's' : ''} generated successfully`; + return `Processing ${imageCount} generated image${imageCount !== '1' ? 's' : ''}`; } - if (stepLog?.message) { - const match = stepLog.message.match(/(\d+)\s+image.*generated/i); - if (match && match[1]) { - return `${match[1]} image${match[1] !== '1' ? 's' : ''} generated successfully`; - } - } - return 'Processing image URLs'; + return 'Processing generated images'; } else if (stepPhase === 'SAVE') { // Extract image count from SAVE step - const imageCount = extractCount(/(\d+)\s+image/i); + const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i); if (imageCount) { - return `Saving ${imageCount} image${imageCount !== '1' ? 's' : ''}`; + return `Uploading ${imageCount} image${imageCount !== '1' ? 's' : ''} to media library`; } - if (stepLog?.message) { - const match = stepLog.message.match(/Saved image (\d+)/i); - if (match && match[1]) { - return `Saving image ${match[1]}`; - } - } - return 'Saving image URLs'; + return 'Uploading images to media library'; } } else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) { // Image prompt generation - if (stepPhase === 'PREP') { - // Extract total image count from PREP step message - // Look for "Mapping Content for X Image Prompts" - const totalCount = extractCount(/(\d+)\s+Image Prompts/i) || extractCount(/(\d+)\s+image/i); + if (stepPhase === 'INIT') { + // Try to get image count + const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i); + if (imageCount) { + return `Analyzing content for ${imageCount} image opportunit${imageCount !== '1' ? 'ies' : 'y'}`; + } + return 'Analyzing content for image opportunities'; + } else if (stepPhase === 'PREP') { + // Extract total image count and calculate in-article count + const totalCount = extractCount(/(\d+)\s+Image Prompts/i) || extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i); if (totalCount) { - return `Mapping Content for ${totalCount} Image Prompts`; - } - // Try to extract from step log message - if (stepLog?.message) { - const match = stepLog.message.match(/Mapping Content for (\d+)\s+Image Prompts/i); - if (match && match[1]) { - return `Mapping Content for ${match[1]} Image Prompts`; + const total = parseInt(totalCount, 10); + const inArticleCount = total > 1 ? total - 1 : 0; + if (inArticleCount > 0) { + return `Identifying featured image and ${inArticleCount} in-article image slot${inArticleCount !== 1 ? 's' : ''}`; } + return `Identifying featured image slot`; } - return 'Mapping content for image prompts'; + return 'Identifying image slots'; } else if (stepPhase === 'AI_CALL') { - // For AI_CALL: Show "Writing Featured Image Prompts" - return 'Writing Featured Image Prompts'; + // For AI_CALL: Try to get count + const totalCount = extractCountFromLogs(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+prompt/i); + if (totalCount) { + return `Creating optimized prompts for ${totalCount} image${totalCount !== '1' ? 's' : ''}`; + } + return 'Creating optimized prompts'; } else if (stepPhase === 'PARSE') { // Extract in-article image count from PARSE step - // Look for "Writing X In‑article Image Prompts" - const inArticleCount = extractCount(/(\d+)\s+In‑article/i) || extractCount(/(\d+)\s+In-article/i); + const inArticleCount = extractCount(/(\d+)\s+In[‑-]article/i); if (inArticleCount) { - return `Writing ${inArticleCount} In‑article Image Prompts`; + return `Refining ${inArticleCount} contextual image description${inArticleCount !== '1' ? 's' : ''}`; } - // Try to extract from step log message - if (stepLog?.message) { - const match = stepLog.message.match(/Writing (\d+)\s+In[‑-]article Image Prompts/i); - if (match && match[1]) { - return `Writing ${match[1]} In‑article Image Prompts`; + // Fallback: calculate from total + const totalCount = extractCountFromLogs(/(\d+)\s+image/i); + if (totalCount) { + const total = parseInt(totalCount, 10); + const inArticle = total > 1 ? total - 1 : 0; + if (inArticle > 0) { + return `Refining ${inArticle} contextual image description${inArticle !== 1 ? 's' : ''}`; } } - return 'Writing In‑article Image Prompts'; + return 'Refining contextual image descriptions'; } else if (stepPhase === 'SAVE') { // For SAVE: Extract prompt count from message - const promptCount = extractCount(/(\d+)\s+Prompts/i) || extractCount(/(\d+)\s+prompt/i); + const promptCount = extractCount(/(\d+)\s+Prompts/i) || extractCount(/(\d+)\s+prompt/i) || extractCountFromLogs(/(\d+)\s+prompt/i); if (promptCount) { - return `Assigning ${promptCount} Prompts to Dedicated Slots`; + return `Assigning ${promptCount} prompt${promptCount !== '1' ? 's' : ''} to image slots`; } - // Try to extract from step log message - if (stepLog?.message) { - const match = stepLog.message.match(/Assigning (\d+)\s+Prompts/i); - if (match && match[1]) { - return `Assigning ${match[1]} Prompts to Dedicated Slots`; - } - } - return 'Assigning Prompts to Dedicated Slots'; + return 'Assigning prompts to image slots'; } } diff --git a/frontend/src/components/header/NotificationDropdown.tsx b/frontend/src/components/header/NotificationDropdown.tsx index 196c9aad..ea8cce5d 100644 --- a/frontend/src/components/header/NotificationDropdown.tsx +++ b/frontend/src/components/header/NotificationDropdown.tsx @@ -1,9 +1,10 @@ /** * NotificationDropdown - Dynamic notification dropdown using store * Shows AI task completions, system events, and other notifications + * Fetches from API and syncs with local state */ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import { Link, useNavigate } from "react-router-dom"; import { Dropdown } from "../ui/dropdown/Dropdown"; import { DropdownItem } from "../ui/dropdown/DropdownItem"; @@ -70,11 +71,39 @@ export default function NotificationDropdown() { const { notifications, unreadCount, + isLoading, + lastFetched, markAsRead, markAllAsRead, - removeNotification + removeNotification, + fetchNotifications: fetchFromAPI, + syncUnreadCount, } = useNotificationStore(); + // Fetch notifications on mount and periodically sync unread count + useEffect(() => { + // Initial fetch + fetchFromAPI(); + + // Sync unread count every 30 seconds + const interval = setInterval(() => { + syncUnreadCount(); + }, 30000); + + return () => clearInterval(interval); + }, [fetchFromAPI, syncUnreadCount]); + + // Refetch when dropdown opens if data is stale (> 1 minute) + useEffect(() => { + if (isOpen && lastFetched) { + const staleThreshold = 60000; // 1 minute + const isStale = Date.now() - lastFetched.getTime() > staleThreshold; + if (isStale) { + fetchFromAPI(); + } + } + }, [isOpen, lastFetched, fetchFromAPI]); + function toggleDropdown() { setIsOpen(!isOpen); } @@ -177,7 +206,16 @@ export default function NotificationDropdown() { {/* Notification List */}
+ Loading notifications... +
+