final all done 2nd last plan before goign live
This commit is contained in:
1
backend/igny8_core/business/notifications/__init__.py
Normal file
1
backend/igny8_core/business/notifications/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Notifications module
|
||||||
40
backend/igny8_core/business/notifications/admin.py
Normal file
40
backend/igny8_core/business/notifications/admin.py
Normal file
@@ -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',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
13
backend/igny8_core/business/notifications/apps.py
Normal file
13
backend/igny8_core/business/notifications/apps.py
Normal file
@@ -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'
|
||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
191
backend/igny8_core/business/notifications/models.py
Normal file
191
backend/igny8_core/business/notifications/models.py
Normal file
@@ -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
|
||||||
90
backend/igny8_core/business/notifications/serializers.py
Normal file
90
backend/igny8_core/business/notifications/serializers.py
Normal file
@@ -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.'
|
||||||
|
)
|
||||||
306
backend/igny8_core/business/notifications/services.py
Normal file
306
backend/igny8_core/business/notifications/services.py
Normal file
@@ -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}
|
||||||
|
)
|
||||||
15
backend/igny8_core/business/notifications/urls.py
Normal file
15
backend/igny8_core/business/notifications/urls.py
Normal file
@@ -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)),
|
||||||
|
]
|
||||||
132
backend/igny8_core/business/notifications/views.py
Normal file
132
backend/igny8_core/business/notifications/views.py
Normal file
@@ -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)
|
||||||
@@ -66,6 +66,7 @@ INSTALLED_APPS = [
|
|||||||
'igny8_core.modules.system.apps.SystemConfig',
|
'igny8_core.modules.system.apps.SystemConfig',
|
||||||
'igny8_core.modules.billing.apps.BillingConfig',
|
'igny8_core.modules.billing.apps.BillingConfig',
|
||||||
'igny8_core.business.automation', # AI Automation Pipeline
|
'igny8_core.business.automation', # AI Automation Pipeline
|
||||||
|
'igny8_core.business.notifications.apps.NotificationsConfig', # User Notifications
|
||||||
'igny8_core.business.optimization.apps.OptimizationConfig',
|
'igny8_core.business.optimization.apps.OptimizationConfig',
|
||||||
'igny8_core.business.publishing.apps.PublishingConfig',
|
'igny8_core.business.publishing.apps.PublishingConfig',
|
||||||
'igny8_core.business.integration.apps.IntegrationConfig',
|
'igny8_core.business.integration.apps.IntegrationConfig',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ urlpatterns = [
|
|||||||
path('api/v1/billing/', include('igny8_core.business.billing.urls')), # Billing (credits, invoices, payments, packages)
|
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/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/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/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints
|
||||||
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer 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
|
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
|
||||||
|
|||||||
@@ -46,41 +46,47 @@ const getSuccessMessage = (functionId?: string, title?: string, stepLogs?: any[]
|
|||||||
const clusterCount = extractCount(/(\d+)\s+cluster/i, stepLogs || []);
|
const clusterCount = extractCount(/(\d+)\s+cluster/i, stepLogs || []);
|
||||||
|
|
||||||
if (keywordCount && clusterCount) {
|
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) {
|
} else if (clusterCount) {
|
||||||
return `Clustering complete\n${clusterCount} cluster${clusterCount !== '1' ? 's' : ''} created`;
|
return `✓ Created ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`;
|
||||||
} else if (keywordCount) {
|
} 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')) {
|
if (funcName.includes('idea')) {
|
||||||
const ideaCount = extractCount(/(\d+)\s+idea/i, stepLogs || []);
|
const ideaCount = extractCount(/(\d+)\s+idea/i, stepLogs || []);
|
||||||
|
const clusterCount = extractCount(/(\d+)\s+cluster/i, stepLogs || []);
|
||||||
|
|
||||||
if (ideaCount) {
|
if (ideaCount && clusterCount) {
|
||||||
return `Content ideas & outlines created successfully`;
|
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')) {
|
if (funcName.includes('content')) {
|
||||||
const taskCount = extractCount(/(\d+)\s+task/i, stepLogs || []);
|
const taskCount = extractCount(/(\d+)\s+task/i, stepLogs || []);
|
||||||
const articleCount = extractCount(/(\d+)\s+article/i, stepLogs || []);
|
const articleCount = extractCount(/(\d+)\s+article/i, stepLogs || []);
|
||||||
|
const wordCount = extractCount(/(\d+[,\d]*)\s+word/i, stepLogs || []);
|
||||||
|
|
||||||
if (articleCount) {
|
if (articleCount && wordCount) {
|
||||||
return `Article${articleCount !== '1' ? 's' : ''} drafted successfully — ${articleCount} article${articleCount !== '1' ? 's' : ''} generated.`;
|
return `✓ ${articleCount} article${articleCount !== '1' ? 's' : ''} generated (${wordCount} words total)`;
|
||||||
|
} else if (articleCount) {
|
||||||
|
return `✓ ${articleCount} article${articleCount !== '1' ? 's' : ''} generated`;
|
||||||
} else if (taskCount) {
|
} 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)
|
// Check for image generation from prompts FIRST (more specific)
|
||||||
if (funcName.includes('image') && funcName.includes('from')) {
|
if (funcName.includes('image') && funcName.includes('from')) {
|
||||||
// Image generation from prompts
|
// Image generation from prompts
|
||||||
const imageCount = extractCount(/(\d+)\s+image/i, stepLogs || []);
|
const imageCount = extractCount(/(\d+)\s+image/i, stepLogs || []);
|
||||||
if (imageCount) {
|
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'))) {
|
} else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) {
|
||||||
// Image prompt generation
|
// Image prompt generation
|
||||||
// Try to extract from SAVE step message first (most reliable)
|
// 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 totalPrompts = parseInt(countMatch[1], 10);
|
||||||
const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0;
|
const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0;
|
||||||
if (inArticleCount > 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 {
|
} 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 totalPrompts = parseInt(match[1], 10);
|
||||||
const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0;
|
const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0;
|
||||||
if (inArticleCount > 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 {
|
} 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 totalPrompts = parseInt(promptCount, 10);
|
||||||
const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0;
|
const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0;
|
||||||
if (inArticleCount > 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 {
|
} else {
|
||||||
return `Featured Image Prompt ready for image generation`;
|
return `✓ 1 image prompt ready`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default message
|
// 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 getStepsForFunction = (functionId?: string, title?: string): Array<{phase: string, label: string}> => {
|
||||||
const funcName = functionId?.toLowerCase() || title?.toLowerCase() || '';
|
const funcName = functionId?.toLowerCase() || title?.toLowerCase() || '';
|
||||||
|
|
||||||
if (funcName.includes('cluster')) {
|
if (funcName.includes('cluster')) {
|
||||||
return [
|
return [
|
||||||
{ phase: 'INIT', label: 'Validating keywords' },
|
{ phase: 'INIT', label: 'Validating keywords for clustering' },
|
||||||
{ phase: 'PREP', label: 'Loading keyword data' },
|
{ phase: 'PREP', label: 'Analyzing keyword relationships' },
|
||||||
{ phase: 'AI_CALL', label: 'Generating clusters with Igny8 Semantic SEO Model' },
|
{ phase: 'AI_CALL', label: 'Grouping keywords by search intent' },
|
||||||
{ phase: 'PARSE', label: 'Organizing clusters' },
|
{ phase: 'PARSE', label: 'Organizing semantic clusters' },
|
||||||
{ phase: 'SAVE', label: 'Saving clusters' },
|
{ phase: 'SAVE', label: 'Saving clusters' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (funcName.includes('idea')) {
|
if (funcName.includes('idea')) {
|
||||||
return [
|
return [
|
||||||
{ phase: 'INIT', label: 'Verifying cluster integrity' },
|
{ phase: 'INIT', label: 'Analyzing clusters for content opportunities' },
|
||||||
{ phase: 'PREP', label: 'Loading cluster keywords' },
|
{ phase: 'PREP', label: 'Mapping keywords to topic briefs' },
|
||||||
{ phase: 'AI_CALL', label: 'Generating ideas with Igny8 Semantic AI' },
|
{ phase: 'AI_CALL', label: 'Generating content ideas' },
|
||||||
{ phase: 'PARSE', label: 'High-opportunity ideas generated' },
|
{ phase: 'PARSE', label: 'Structuring article outlines' },
|
||||||
{ phase: 'SAVE', label: 'Content Outline for Ideas generated' },
|
{ phase: 'SAVE', label: 'Saving content ideas with outlines' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (funcName.includes('content')) {
|
if (funcName.includes('content')) {
|
||||||
return [
|
return [
|
||||||
{ phase: 'INIT', label: 'Validating task' },
|
{ phase: 'INIT', label: 'Preparing articles for generation' },
|
||||||
{ phase: 'PREP', label: 'Preparing content idea' },
|
{ phase: 'PREP', label: 'Building content brief with target keywords' },
|
||||||
{ phase: 'AI_CALL', label: 'Writing article with Igny8 Semantic AI' },
|
{ phase: 'AI_CALL', label: 'Writing articles with Igny8 Semantic AI' },
|
||||||
{ phase: 'PARSE', label: 'Formatting content' },
|
{ phase: 'PARSE', label: 'Formatting HTML content and metadata' },
|
||||||
{ phase: 'SAVE', label: 'Saving article' },
|
{ phase: 'SAVE', label: 'Saving articles' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,20 +176,20 @@ const getStepsForFunction = (functionId?: string, title?: string): Array<{phase:
|
|||||||
if (funcName.includes('image') && funcName.includes('from')) {
|
if (funcName.includes('image') && funcName.includes('from')) {
|
||||||
// Image generation from prompts
|
// Image generation from prompts
|
||||||
return [
|
return [
|
||||||
{ phase: 'INIT', label: 'Validating image prompts' },
|
{ phase: 'INIT', label: 'Queuing images for generation' },
|
||||||
{ phase: 'PREP', label: 'Preparing image generation queue' },
|
{ phase: 'PREP', label: 'Preparing AI image generation' },
|
||||||
{ phase: 'AI_CALL', label: 'Generating images with AI' },
|
{ phase: 'AI_CALL', label: 'Generating images with AI' },
|
||||||
{ phase: 'PARSE', label: 'Processing image URLs' },
|
{ phase: 'PARSE', label: 'Processing generated images' },
|
||||||
{ phase: 'SAVE', label: 'Saving image URLs' },
|
{ phase: 'SAVE', label: 'Uploading images to media library' },
|
||||||
];
|
];
|
||||||
} else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) {
|
} else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) {
|
||||||
// Image prompt generation
|
// Image prompt generation
|
||||||
return [
|
return [
|
||||||
{ phase: 'INIT', label: 'Checking content and image slots' },
|
{ phase: 'INIT', label: 'Analyzing content for image opportunities' },
|
||||||
{ phase: 'PREP', label: 'Mapping content for image prompts' },
|
{ phase: 'PREP', label: 'Identifying image slots' },
|
||||||
{ phase: 'AI_CALL', label: 'Writing Featured Image Prompts' },
|
{ phase: 'AI_CALL', label: 'Creating optimized prompts' },
|
||||||
{ phase: 'PARSE', label: 'Writing In‑article Image Prompts' },
|
{ phase: 'PARSE', label: 'Refining contextual image descriptions' },
|
||||||
{ phase: 'SAVE', label: 'Assigning Prompts to Dedicated Slots' },
|
{ phase: 'SAVE', label: 'Assigning prompts to image slots' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,210 +292,248 @@ export default function ProgressModal({
|
|||||||
return match && match[1] ? match[1] : '';
|
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 (funcName.includes('cluster')) {
|
||||||
if (stepPhase === 'INIT') {
|
if (stepPhase === 'INIT') {
|
||||||
// For INIT: Try to extract keyword count from message or stepLogs
|
// For INIT: Try to extract keyword count
|
||||||
// Backend message might include keyword names (e.g., "Validating keyword1, keyword2, keyword3 and 5 more keywords")
|
const keywordCount = extractCount(/(\d+)\s+keyword/i) || extractCountFromLogs(/(\d+)\s+keyword/i);
|
||||||
// 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);
|
|
||||||
if (keywordCount) {
|
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') {
|
} else if (stepPhase === 'AI_CALL') {
|
||||||
// For AI_CALL: Show "Generating clusters with Igny8 Semantic SEO Model"
|
// For AI_CALL: Try to get keyword count
|
||||||
return 'Generating clusters with Igny8 Semantic SEO Model';
|
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') {
|
} 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);
|
const clusterCount = extractCount(/(\d+)\s+cluster/i);
|
||||||
if (clusterCount) {
|
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
|
// Try to find cluster count in any step log
|
||||||
for (const log of allStepLogs) {
|
for (const log of allStepLogs) {
|
||||||
const count = log.message?.match(/(\d+)\s+cluster/i);
|
const count = log.message?.match(/(\d+)\s+cluster/i);
|
||||||
if (count && count[1]) {
|
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;
|
return 'Analyzing clusters for content opportunities';
|
||||||
} else if (stepPhase === 'SAVE') {
|
} else if (stepPhase === 'PREP') {
|
||||||
// For SAVE: Show count of clusters being saved
|
// 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);
|
const clusterCount = extractCount(/(\d+)\s+cluster/i);
|
||||||
if (clusterCount) {
|
if (clusterCount) {
|
||||||
return `Saving ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`;
|
return `Generating content ideas for ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`;
|
||||||
}
|
}
|
||||||
return message;
|
// Try to find cluster count in any step log
|
||||||
}
|
for (const log of allStepLogs) {
|
||||||
} else if (funcName.includes('idea')) {
|
const count = log.message?.match(/(\d+)\s+cluster/i);
|
||||||
if (stepPhase === 'INIT') {
|
if (count && count[1]) {
|
||||||
// For INIT: Show "Verifying cluster integrity"
|
return `Generating content ideas for ${count[1]} cluster${count[1] !== '1' ? 's' : ''}`;
|
||||||
return 'Verifying cluster integrity';
|
}
|
||||||
} else if (stepPhase === 'PREP') {
|
}
|
||||||
// For PREP: Show "Loading cluster keywords"
|
return 'Generating content ideas';
|
||||||
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';
|
|
||||||
} else if (stepPhase === 'PARSE') {
|
} 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);
|
const ideaCount = extractCount(/(\d+)\s+idea/i);
|
||||||
if (ideaCount) {
|
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
|
// Try to find idea count in any step log
|
||||||
for (const log of allStepLogs) {
|
for (const log of allStepLogs) {
|
||||||
const count = log.message?.match(/(\d+)\s+idea/i);
|
const count = log.message?.match(/(\d+)\s+idea/i);
|
||||||
if (count && count[1]) {
|
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') {
|
} else if (stepPhase === 'SAVE') {
|
||||||
// For SAVE: Show "Content Outline for Ideas generated"
|
// For SAVE: Show "Saving X content ideas with outlines"
|
||||||
return 'Content Outline for Ideas generated';
|
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')) {
|
} else if (funcName.includes('content')) {
|
||||||
if (stepPhase === 'AI_CALL') {
|
if (stepPhase === 'INIT') {
|
||||||
// For AI_CALL: Show "Writing article with Igny8 Semantic AI"
|
// Try to extract task/article count
|
||||||
return 'Writing article with Igny8 Semantic AI';
|
const taskCount = extractCount(/(\d+)\s+task/i) || extractCount(/(\d+)\s+article/i);
|
||||||
} else if (stepPhase === 'PARSE') {
|
if (taskCount) {
|
||||||
const articleCount = extractCount(/(\d+)\s+article/i);
|
return `Preparing ${taskCount} article${taskCount !== '1' ? 's' : ''} for generation`;
|
||||||
if (articleCount) {
|
|
||||||
return `${articleCount} article${articleCount !== '1' ? 's' : ''} created`;
|
|
||||||
}
|
}
|
||||||
|
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')) {
|
} else if (funcName.includes('image') && funcName.includes('from')) {
|
||||||
// Image generation from prompts
|
// Image generation from prompts
|
||||||
if (stepPhase === 'PREP') {
|
if (stepPhase === 'INIT') {
|
||||||
// Extract image count from PREP step message
|
// Try to get image count
|
||||||
const imageCount = extractCount(/(\d+)\s+image/i);
|
const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i);
|
||||||
if (imageCount) {
|
if (imageCount) {
|
||||||
return `Preparing to generate ${imageCount} image${imageCount !== '1' ? 's' : ''}`;
|
return `Queuing ${imageCount} image${imageCount !== '1' ? 's' : ''} for generation`;
|
||||||
}
|
}
|
||||||
if (stepLog?.message) {
|
return 'Queuing images for generation';
|
||||||
const match = stepLog.message.match(/Preparing to generate (\d+)\s+image/i);
|
} else if (stepPhase === 'PREP') {
|
||||||
if (match && match[1]) {
|
// Extract image count from PREP step message
|
||||||
return `Preparing to generate ${match[1]} image${match[1] !== '1' ? 's' : ''}`;
|
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') {
|
} else if (stepPhase === 'AI_CALL') {
|
||||||
// Extract current image number from message
|
// Extract current image number from message for "Generating image X/Y..."
|
||||||
const match = stepLog?.message?.match(/Generating.*image (\d+)/i);
|
const currentMatch = stepLog?.message?.match(/image (\d+)/i);
|
||||||
if (match && match[1]) {
|
const totalCount = extractCountFromLogs(/(\d+)\s+image/i);
|
||||||
return `Generating image ${match[1]} with AI`;
|
if (currentMatch && totalCount) {
|
||||||
|
return `Generating image ${currentMatch[1]}/${totalCount}...`;
|
||||||
|
} else if (currentMatch) {
|
||||||
|
return `Generating image ${currentMatch[1]}...`;
|
||||||
}
|
}
|
||||||
return 'Generating images with AI';
|
return 'Generating images with AI';
|
||||||
} else if (stepPhase === 'PARSE') {
|
} else if (stepPhase === 'PARSE') {
|
||||||
// Extract image count from PARSE step
|
// 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) {
|
if (imageCount) {
|
||||||
return `${imageCount} image${imageCount !== '1' ? 's' : ''} generated successfully`;
|
return `Processing ${imageCount} generated image${imageCount !== '1' ? 's' : ''}`;
|
||||||
}
|
}
|
||||||
if (stepLog?.message) {
|
return 'Processing generated images';
|
||||||
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';
|
|
||||||
} else if (stepPhase === 'SAVE') {
|
} else if (stepPhase === 'SAVE') {
|
||||||
// Extract image count from SAVE step
|
// 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) {
|
if (imageCount) {
|
||||||
return `Saving ${imageCount} image${imageCount !== '1' ? 's' : ''}`;
|
return `Uploading ${imageCount} image${imageCount !== '1' ? 's' : ''} to media library`;
|
||||||
}
|
}
|
||||||
if (stepLog?.message) {
|
return 'Uploading images to media library';
|
||||||
const match = stepLog.message.match(/Saved image (\d+)/i);
|
|
||||||
if (match && match[1]) {
|
|
||||||
return `Saving image ${match[1]}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'Saving image URLs';
|
|
||||||
}
|
}
|
||||||
} else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) {
|
} else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) {
|
||||||
// Image prompt generation
|
// Image prompt generation
|
||||||
if (stepPhase === 'PREP') {
|
if (stepPhase === 'INIT') {
|
||||||
// Extract total image count from PREP step message
|
// Try to get image count
|
||||||
// Look for "Mapping Content for X Image Prompts"
|
const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i);
|
||||||
const totalCount = extractCount(/(\d+)\s+Image Prompts/i) || extractCount(/(\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) {
|
if (totalCount) {
|
||||||
return `Mapping Content for ${totalCount} Image Prompts`;
|
const total = parseInt(totalCount, 10);
|
||||||
}
|
const inArticleCount = total > 1 ? total - 1 : 0;
|
||||||
// Try to extract from step log message
|
if (inArticleCount > 0) {
|
||||||
if (stepLog?.message) {
|
return `Identifying featured image and ${inArticleCount} in-article image slot${inArticleCount !== 1 ? 's' : ''}`;
|
||||||
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`;
|
|
||||||
}
|
}
|
||||||
|
return `Identifying featured image slot`;
|
||||||
}
|
}
|
||||||
return 'Mapping content for image prompts';
|
return 'Identifying image slots';
|
||||||
} else if (stepPhase === 'AI_CALL') {
|
} else if (stepPhase === 'AI_CALL') {
|
||||||
// For AI_CALL: Show "Writing Featured Image Prompts"
|
// For AI_CALL: Try to get count
|
||||||
return 'Writing Featured Image Prompts';
|
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') {
|
} else if (stepPhase === 'PARSE') {
|
||||||
// Extract in-article image count from PARSE step
|
// Extract in-article image count from PARSE step
|
||||||
// Look for "Writing X In‑article Image Prompts"
|
const inArticleCount = extractCount(/(\d+)\s+In[‑-]article/i);
|
||||||
const inArticleCount = extractCount(/(\d+)\s+In‑article/i) || extractCount(/(\d+)\s+In-article/i);
|
|
||||||
if (inArticleCount) {
|
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
|
// Fallback: calculate from total
|
||||||
if (stepLog?.message) {
|
const totalCount = extractCountFromLogs(/(\d+)\s+image/i);
|
||||||
const match = stepLog.message.match(/Writing (\d+)\s+In[‑-]article Image Prompts/i);
|
if (totalCount) {
|
||||||
if (match && match[1]) {
|
const total = parseInt(totalCount, 10);
|
||||||
return `Writing ${match[1]} In‑article Image Prompts`;
|
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') {
|
} else if (stepPhase === 'SAVE') {
|
||||||
// For SAVE: Extract prompt count from message
|
// 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) {
|
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
|
return 'Assigning prompts to image slots';
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* NotificationDropdown - Dynamic notification dropdown using store
|
* NotificationDropdown - Dynamic notification dropdown using store
|
||||||
* Shows AI task completions, system events, and other notifications
|
* 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 { Link, useNavigate } from "react-router-dom";
|
||||||
import { Dropdown } from "../ui/dropdown/Dropdown";
|
import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
||||||
@@ -70,11 +71,39 @@ export default function NotificationDropdown() {
|
|||||||
const {
|
const {
|
||||||
notifications,
|
notifications,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
isLoading,
|
||||||
|
lastFetched,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
removeNotification
|
removeNotification,
|
||||||
|
fetchNotifications: fetchFromAPI,
|
||||||
|
syncUnreadCount,
|
||||||
} = useNotificationStore();
|
} = 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() {
|
function toggleDropdown() {
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
}
|
}
|
||||||
@@ -177,7 +206,16 @@ export default function NotificationDropdown() {
|
|||||||
|
|
||||||
{/* Notification List */}
|
{/* Notification List */}
|
||||||
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar flex-1">
|
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar flex-1">
|
||||||
{notifications.length === 0 ? (
|
{isLoading && notifications.length === 0 ? (
|
||||||
|
<li className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="w-12 h-12 mb-3 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center animate-pulse">
|
||||||
|
<BoltIcon className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Loading notifications...
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
<li className="flex flex-col items-center justify-center py-12 text-center">
|
<li className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<div className="w-12 h-12 mb-3 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
<div className="w-12 h-12 mb-3 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||||
<BoltIcon className="w-6 h-6 text-gray-400" />
|
<BoltIcon className="w-6 h-6 text-gray-400" />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { createApprovedPageConfig } from '../../config/pages/approved.config';
|
|||||||
import { useSectorStore } from '../../store/sectorStore';
|
import { useSectorStore } from '../../store/sectorStore';
|
||||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
|
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||||
|
|
||||||
export default function Approved() {
|
export default function Approved() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -358,29 +358,83 @@ export default function Approved() {
|
|||||||
getItemDisplayName={(row: Content) => row.title || `Content #${row.id}`}
|
getItemDisplayName={(row: Content) => row.title || `Content #${row.id}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Module Metrics Footer */}
|
{/* Three Widget Footer - Section 3 Layout */}
|
||||||
<ModuleMetricsFooter
|
<ThreeWidgetFooter
|
||||||
metrics={[
|
submoduleColor="green"
|
||||||
{
|
pageProgress={{
|
||||||
title: 'Approved Content',
|
title: 'Page Progress',
|
||||||
value: content.length.toLocaleString(),
|
submoduleColor: 'green',
|
||||||
subtitle: 'ready for publishing',
|
metrics: [
|
||||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
{ label: 'Published', value: totalCount },
|
||||||
accentColor: 'green',
|
{ label: 'On Site', value: content.filter(c => c.external_id).length, percentage: `${totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0}%` },
|
||||||
|
{ label: 'Pending', value: content.filter(c => !c.external_id).length },
|
||||||
|
],
|
||||||
|
progress: {
|
||||||
|
value: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
|
||||||
|
label: 'On Site',
|
||||||
|
color: 'green',
|
||||||
},
|
},
|
||||||
{
|
hint: content.filter(c => !c.external_id).length > 0
|
||||||
title: 'Published to Site',
|
? `${content.filter(c => !c.external_id).length} article${content.filter(c => !c.external_id).length !== 1 ? 's' : ''} pending sync to site`
|
||||||
value: content.filter(c => c.external_id).length.toLocaleString(),
|
: 'All articles synced to site!',
|
||||||
subtitle: 'on WordPress',
|
}}
|
||||||
icon: <RocketLaunchIcon className="w-5 h-5" />,
|
moduleStats={{
|
||||||
accentColor: 'blue',
|
title: 'Writer Module',
|
||||||
href: '/writer/approved',
|
pipeline: [
|
||||||
},
|
{
|
||||||
]}
|
fromLabel: 'Tasks',
|
||||||
progress={{
|
fromValue: 0,
|
||||||
label: 'Site Publishing Progress',
|
fromHref: '/writer/tasks',
|
||||||
value: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
|
actionLabel: 'Generate Content',
|
||||||
color: 'success',
|
toLabel: 'Drafts',
|
||||||
|
toValue: 0,
|
||||||
|
toHref: '/writer/content',
|
||||||
|
progress: 0,
|
||||||
|
color: 'blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fromLabel: 'Drafts',
|
||||||
|
fromValue: 0,
|
||||||
|
fromHref: '/writer/content',
|
||||||
|
actionLabel: 'Generate Images',
|
||||||
|
toLabel: 'Images',
|
||||||
|
toValue: 0,
|
||||||
|
toHref: '/writer/images',
|
||||||
|
progress: 0,
|
||||||
|
color: 'purple',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fromLabel: 'Ready',
|
||||||
|
fromValue: 0,
|
||||||
|
fromHref: '/writer/review',
|
||||||
|
actionLabel: 'Review & Publish',
|
||||||
|
toLabel: 'Published',
|
||||||
|
toValue: totalCount,
|
||||||
|
toHref: '/writer/published',
|
||||||
|
progress: 100,
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ label: 'Tasks', href: '/writer/tasks' },
|
||||||
|
{ label: 'Content', href: '/writer/content' },
|
||||||
|
{ label: 'Images', href: '/writer/images' },
|
||||||
|
{ label: 'Published', href: '/writer/published' },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
completion={{
|
||||||
|
title: 'Workflow Completion',
|
||||||
|
plannerItems: [
|
||||||
|
{ label: 'Keywords Clustered', value: 0, color: 'blue' },
|
||||||
|
{ label: 'Clusters Created', value: 0, color: 'green' },
|
||||||
|
{ label: 'Ideas Generated', value: 0, color: 'amber' },
|
||||||
|
],
|
||||||
|
writerItems: [
|
||||||
|
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||||
|
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||||
|
{ label: 'Articles Published', value: totalCount, color: 'green' },
|
||||||
|
],
|
||||||
|
analyticsHref: '/account/usage',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQu
|
|||||||
import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal';
|
import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import { Modal } from '../../components/ui/modal';
|
import { Modal } from '../../components/ui/modal';
|
||||||
|
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||||
|
|
||||||
export default function Images() {
|
export default function Images() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -575,6 +576,86 @@ export default function Images() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Three Widget Footer - Section 3 Layout */}
|
||||||
|
<ThreeWidgetFooter
|
||||||
|
submoduleColor="purple"
|
||||||
|
pageProgress={{
|
||||||
|
title: 'Page Progress',
|
||||||
|
submoduleColor: 'purple',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Total', value: totalCount },
|
||||||
|
{ label: 'Generated', value: images.filter(i => i.images?.some(img => img.status === 'generated')).length, percentage: `${totalCount > 0 ? Math.round((images.filter(i => i.images?.some(img => img.status === 'generated')).length / totalCount) * 100) : 0}%` },
|
||||||
|
{ label: 'Pending', value: images.filter(i => i.images?.some(img => img.status === 'pending')).length },
|
||||||
|
],
|
||||||
|
progress: {
|
||||||
|
value: totalCount > 0 ? Math.round((images.filter(i => i.images?.some(img => img.status === 'generated')).length / totalCount) * 100) : 0,
|
||||||
|
label: 'Generated',
|
||||||
|
color: 'purple',
|
||||||
|
},
|
||||||
|
hint: images.filter(i => i.images?.some(img => img.status === 'pending')).length > 0
|
||||||
|
? `${images.filter(i => i.images?.some(img => img.status === 'pending')).length} content item${images.filter(i => i.images?.some(img => img.status === 'pending')).length !== 1 ? 's' : ''} need image generation`
|
||||||
|
: 'All images generated!',
|
||||||
|
}}
|
||||||
|
moduleStats={{
|
||||||
|
title: 'Writer Module',
|
||||||
|
pipeline: [
|
||||||
|
{
|
||||||
|
fromLabel: 'Tasks',
|
||||||
|
fromValue: 0,
|
||||||
|
fromHref: '/writer/tasks',
|
||||||
|
actionLabel: 'Generate Content',
|
||||||
|
toLabel: 'Drafts',
|
||||||
|
toValue: 0,
|
||||||
|
toHref: '/writer/content',
|
||||||
|
progress: 0,
|
||||||
|
color: 'blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fromLabel: 'Drafts',
|
||||||
|
fromValue: 0,
|
||||||
|
fromHref: '/writer/content',
|
||||||
|
actionLabel: 'Generate Images',
|
||||||
|
toLabel: 'Images',
|
||||||
|
toValue: totalCount,
|
||||||
|
toHref: '/writer/images',
|
||||||
|
progress: 100,
|
||||||
|
color: 'purple',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fromLabel: 'Ready',
|
||||||
|
fromValue: 0,
|
||||||
|
fromHref: '/writer/review',
|
||||||
|
actionLabel: 'Review & Publish',
|
||||||
|
toLabel: 'Published',
|
||||||
|
toValue: 0,
|
||||||
|
toHref: '/writer/published',
|
||||||
|
progress: 0,
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ label: 'Tasks', href: '/writer/tasks' },
|
||||||
|
{ label: 'Content', href: '/writer/content' },
|
||||||
|
{ label: 'Images', href: '/writer/images' },
|
||||||
|
{ label: 'Published', href: '/writer/published' },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
completion={{
|
||||||
|
title: 'Workflow Completion',
|
||||||
|
plannerItems: [
|
||||||
|
{ label: 'Keywords Clustered', value: 0, color: 'blue' },
|
||||||
|
{ label: 'Clusters Created', value: 0, color: 'green' },
|
||||||
|
{ label: 'Ideas Generated', value: 0, color: 'amber' },
|
||||||
|
],
|
||||||
|
writerItems: [
|
||||||
|
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||||
|
{ label: 'Images Created', value: totalCount, color: 'purple' },
|
||||||
|
{ label: 'Articles Published', value: 0, color: 'green' },
|
||||||
|
],
|
||||||
|
analyticsHref: '/account/usage',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { createReviewPageConfig } from '../../config/pages/review.config';
|
|||||||
import { useSectorStore } from '../../store/sectorStore';
|
import { useSectorStore } from '../../store/sectorStore';
|
||||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
|
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
||||||
|
|
||||||
export default function Review() {
|
export default function Review() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -454,16 +454,84 @@ export default function Review() {
|
|||||||
}}
|
}}
|
||||||
onRowAction={handleRowAction}
|
onRowAction={handleRowAction}
|
||||||
/>
|
/>
|
||||||
<ModuleMetricsFooter
|
|
||||||
metrics={[
|
{/* Three Widget Footer - Section 3 Layout */}
|
||||||
{
|
<ThreeWidgetFooter
|
||||||
title: 'Ready to Publish',
|
submoduleColor="amber"
|
||||||
value: content.length,
|
pageProgress={{
|
||||||
subtitle: 'Total review items',
|
title: 'Page Progress',
|
||||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
submoduleColor: 'amber',
|
||||||
accentColor: 'blue',
|
metrics: [
|
||||||
|
{ label: 'Review', value: totalCount },
|
||||||
|
{ label: 'Ready', value: content.filter(c => c.status === 'review').length, percentage: '100%' },
|
||||||
|
],
|
||||||
|
progress: {
|
||||||
|
value: 100,
|
||||||
|
label: 'Ready for Review',
|
||||||
|
color: 'amber',
|
||||||
},
|
},
|
||||||
]}
|
hint: totalCount > 0
|
||||||
|
? `${totalCount} article${totalCount !== 1 ? 's' : ''} ready for review and publishing`
|
||||||
|
: 'No content pending review',
|
||||||
|
}}
|
||||||
|
moduleStats={{
|
||||||
|
title: 'Writer Module',
|
||||||
|
pipeline: [
|
||||||
|
{
|
||||||
|
fromLabel: 'Tasks',
|
||||||
|
fromValue: 0,
|
||||||
|
fromHref: '/writer/tasks',
|
||||||
|
actionLabel: 'Generate Content',
|
||||||
|
toLabel: 'Drafts',
|
||||||
|
toValue: 0,
|
||||||
|
toHref: '/writer/content',
|
||||||
|
progress: 0,
|
||||||
|
color: 'blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fromLabel: 'Drafts',
|
||||||
|
fromValue: 0,
|
||||||
|
fromHref: '/writer/content',
|
||||||
|
actionLabel: 'Generate Images',
|
||||||
|
toLabel: 'Images',
|
||||||
|
toValue: 0,
|
||||||
|
toHref: '/writer/images',
|
||||||
|
progress: 0,
|
||||||
|
color: 'purple',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fromLabel: 'Ready',
|
||||||
|
fromValue: totalCount,
|
||||||
|
fromHref: '/writer/review',
|
||||||
|
actionLabel: 'Review & Publish',
|
||||||
|
toLabel: 'Published',
|
||||||
|
toValue: 0,
|
||||||
|
toHref: '/writer/published',
|
||||||
|
progress: 0,
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ label: 'Tasks', href: '/writer/tasks' },
|
||||||
|
{ label: 'Content', href: '/writer/content' },
|
||||||
|
{ label: 'Images', href: '/writer/images' },
|
||||||
|
{ label: 'Published', href: '/writer/published' },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
completion={{
|
||||||
|
title: 'Workflow Completion',
|
||||||
|
plannerItems: [
|
||||||
|
{ label: 'Keywords Clustered', value: 0, color: 'blue' },
|
||||||
|
{ label: 'Clusters Created', value: 0, color: 'green' },
|
||||||
|
{ label: 'Ideas Generated', value: 0, color: 'amber' },
|
||||||
|
],
|
||||||
|
writerItems: [
|
||||||
|
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||||
|
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||||
|
{ label: 'Articles Published', value: 0, color: 'green' },
|
||||||
|
],
|
||||||
|
analyticsHref: '/account/usage',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
106
frontend/src/services/notifications.api.ts
Normal file
106
frontend/src/services/notifications.api.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Notification API Service
|
||||||
|
* Fetches notifications from /api/v1/notifications/ endpoints
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchAPI } from './api';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type NotificationTypeAPI = 'ai_task' | 'system' | 'credit' | 'billing' | 'integration' | 'content' | 'info';
|
||||||
|
export type NotificationSeverityAPI = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface NotificationAPI {
|
||||||
|
id: number;
|
||||||
|
notification_type: NotificationTypeAPI;
|
||||||
|
severity: NotificationSeverityAPI;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
is_read: boolean;
|
||||||
|
created_at: string;
|
||||||
|
read_at: string | null;
|
||||||
|
action_label: string | null;
|
||||||
|
action_url: string | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
related_object_type: string | null;
|
||||||
|
related_object_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationListResponse {
|
||||||
|
count: number;
|
||||||
|
next: string | null;
|
||||||
|
previous: string | null;
|
||||||
|
results: NotificationAPI[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnreadCountResponse {
|
||||||
|
unread_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch notifications list
|
||||||
|
*/
|
||||||
|
export async function fetchNotifications(params?: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
is_read?: boolean;
|
||||||
|
notification_type?: NotificationTypeAPI;
|
||||||
|
}): Promise<NotificationListResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.page) searchParams.set('page', params.page.toString());
|
||||||
|
if (params?.page_size) searchParams.set('page_size', params.page_size.toString());
|
||||||
|
if (params?.is_read !== undefined) searchParams.set('is_read', params.is_read.toString());
|
||||||
|
if (params?.notification_type) searchParams.set('notification_type', params.notification_type);
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `v1/notifications/${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
return fetchAPI(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread notification count
|
||||||
|
*/
|
||||||
|
export async function fetchUnreadCount(): Promise<UnreadCountResponse> {
|
||||||
|
return fetchAPI('v1/notifications/unread-count/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a single notification as read
|
||||||
|
*/
|
||||||
|
export async function markNotificationRead(id: number): Promise<NotificationAPI> {
|
||||||
|
return fetchAPI(`v1/notifications/${id}/read/`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read
|
||||||
|
*/
|
||||||
|
export async function markAllNotificationsRead(): Promise<{ message: string; count: number }> {
|
||||||
|
return fetchAPI('v1/notifications/read-all/', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a notification
|
||||||
|
*/
|
||||||
|
export async function deleteNotification(id: number): Promise<void> {
|
||||||
|
await fetchAPI(`v1/notifications/${id}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple notifications
|
||||||
|
*/
|
||||||
|
export async function deleteNotifications(ids: number[]): Promise<void> {
|
||||||
|
await Promise.all(ids.map(id => deleteNotification(id)));
|
||||||
|
}
|
||||||
@@ -3,13 +3,22 @@
|
|||||||
* Manages notifications for AI task completions and system events
|
* Manages notifications for AI task completions and system events
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - In-memory notification queue
|
* - In-memory notification queue for optimistic UI
|
||||||
|
* - API sync for persistent notifications
|
||||||
* - Auto-dismissal with configurable timeout
|
* - Auto-dismissal with configurable timeout
|
||||||
* - Read/unread state tracking
|
* - Read/unread state tracking
|
||||||
* - Category-based filtering (ai_task, system, info)
|
* - Category-based filtering (ai_task, system, info)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
fetchUnreadCount,
|
||||||
|
markNotificationRead,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
deleteNotification as deleteNotificationAPI,
|
||||||
|
type NotificationAPI,
|
||||||
|
} from '../services/notifications.api';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -20,6 +29,7 @@ export type NotificationCategory = 'ai_task' | 'system' | 'info';
|
|||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
|
apiId?: number; // Server ID for synced notifications
|
||||||
type: NotificationType;
|
type: NotificationType;
|
||||||
category: NotificationCategory;
|
category: NotificationCategory;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -39,6 +49,8 @@ export interface Notification {
|
|||||||
interface NotificationStore {
|
interface NotificationStore {
|
||||||
notifications: Notification[];
|
notifications: Notification[];
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
lastFetched: Date | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
|
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
|
||||||
@@ -54,17 +66,61 @@ interface NotificationStore {
|
|||||||
message: string,
|
message: string,
|
||||||
metadata?: Notification['metadata']
|
metadata?: Notification['metadata']
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
|
// API sync
|
||||||
|
fetchNotifications: () => Promise<void>;
|
||||||
|
syncUnreadCount: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const generateId = () => `notif_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert API notification to store format
|
||||||
|
*/
|
||||||
|
function apiToStoreNotification(api: NotificationAPI): Notification {
|
||||||
|
// Map API notification_type to store category
|
||||||
|
const categoryMap: Record<string, NotificationCategory> = {
|
||||||
|
'ai_task': 'ai_task',
|
||||||
|
'system': 'system',
|
||||||
|
'credit': 'system',
|
||||||
|
'billing': 'system',
|
||||||
|
'integration': 'system',
|
||||||
|
'content': 'ai_task',
|
||||||
|
'info': 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `api_${api.id}`,
|
||||||
|
apiId: api.id,
|
||||||
|
type: api.severity as NotificationType,
|
||||||
|
category: categoryMap[api.notification_type] || 'info',
|
||||||
|
title: api.title,
|
||||||
|
message: api.message,
|
||||||
|
timestamp: new Date(api.created_at),
|
||||||
|
read: api.is_read,
|
||||||
|
actionLabel: api.action_label || undefined,
|
||||||
|
actionHref: api.action_url || undefined,
|
||||||
|
metadata: api.metadata ? {
|
||||||
|
functionName: api.metadata.function_name as string | undefined,
|
||||||
|
count: api.metadata.count as number | undefined,
|
||||||
|
credits: api.metadata.credits as number | undefined,
|
||||||
|
} : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// STORE IMPLEMENTATION
|
// STORE IMPLEMENTATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const generateId = () => `notif_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
||||||
|
|
||||||
export const useNotificationStore = create<NotificationStore>((set, get) => ({
|
export const useNotificationStore = create<NotificationStore>((set, get) => ({
|
||||||
notifications: [],
|
notifications: [],
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
|
isLoading: false,
|
||||||
|
lastFetched: null,
|
||||||
|
|
||||||
addNotification: (notification) => {
|
addNotification: (notification) => {
|
||||||
const newNotification: Notification = {
|
const newNotification: Notification = {
|
||||||
@@ -80,31 +136,60 @@ export const useNotificationStore = create<NotificationStore>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
markAsRead: (id) => {
|
markAsRead: async (id) => {
|
||||||
|
const notification = get().notifications.find(n => n.id === id);
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
notifications: state.notifications.map((n) =>
|
notifications: state.notifications.map((n) =>
|
||||||
n.id === id ? { ...n, read: true } : n
|
n.id === id ? { ...n, read: true } : n
|
||||||
),
|
),
|
||||||
unreadCount: Math.max(0, state.notifications.filter(n => !n.read && n.id !== id).length),
|
unreadCount: Math.max(0, state.notifications.filter(n => !n.read && n.id !== id).length),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Sync with API if this is a server notification
|
||||||
|
if (notification?.apiId) {
|
||||||
|
try {
|
||||||
|
await markNotificationRead(notification.apiId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark notification as read:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
markAllAsRead: () => {
|
markAllAsRead: async () => {
|
||||||
|
// Optimistic update
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
notifications: state.notifications.map((n) => ({ ...n, read: true })),
|
notifications: state.notifications.map((n) => ({ ...n, read: true })),
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Sync with API
|
||||||
|
try {
|
||||||
|
await markAllNotificationsRead();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark all notifications as read:', error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
removeNotification: (id) => {
|
removeNotification: async (id) => {
|
||||||
set((state) => {
|
const notification = get().notifications.find(n => n.id === id);
|
||||||
const notification = state.notifications.find(n => n.id === id);
|
const wasUnread = notification && !notification.read;
|
||||||
const wasUnread = notification && !notification.read;
|
|
||||||
return {
|
// Optimistic update
|
||||||
notifications: state.notifications.filter((n) => n.id !== id),
|
set((state) => ({
|
||||||
unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
|
notifications: state.notifications.filter((n) => n.id !== id),
|
||||||
};
|
unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
// Sync with API if this is a server notification
|
||||||
|
if (notification?.apiId) {
|
||||||
|
try {
|
||||||
|
await deleteNotificationAPI(notification.apiId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAll: () => {
|
clearAll: () => {
|
||||||
@@ -145,6 +230,43 @@ export const useNotificationStore = create<NotificationStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchNotifications: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const response = await fetchNotifications({ page_size: 50 });
|
||||||
|
const apiNotifications = response.results.map(apiToStoreNotification);
|
||||||
|
|
||||||
|
// Merge API notifications with local (non-synced) notifications
|
||||||
|
const localNotifications = get().notifications.filter(n => !n.apiId);
|
||||||
|
const merged = [...localNotifications, ...apiNotifications]
|
||||||
|
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||||
|
.slice(0, 50);
|
||||||
|
|
||||||
|
const unread = merged.filter(n => !n.read).length;
|
||||||
|
|
||||||
|
set({
|
||||||
|
notifications: merged,
|
||||||
|
unreadCount: unread,
|
||||||
|
lastFetched: new Date(),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch notifications:', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncUnreadCount: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchUnreadCount();
|
||||||
|
// Only update if we have API notifications
|
||||||
|
const localUnread = get().notifications.filter(n => !n.apiId && !n.read).length;
|
||||||
|
set({ unreadCount: response.unread_count + localUnread });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync unread count:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user