diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index 43cac64f..a39b6468 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -78,6 +78,7 @@ class SiteSerializer(serializers.ModelSerializer): 'industry', 'industry_name', 'industry_slug', 'is_active', 'status', 'site_type', 'hosting_type', 'seo_metadata', + 'wp_api_key', # WordPress API key (single source of truth for integration) 'sectors_count', 'active_sectors_count', 'selected_sectors', 'can_add_sectors', 'keywords_count', 'has_integration', 'created_at', 'updated_at' @@ -86,6 +87,7 @@ class SiteSerializer(serializers.ModelSerializer): # Explicitly specify required fields for clarity extra_kwargs = { 'industry': {'required': True, 'error_messages': {'required': 'Industry is required when creating a site.'}}, + 'wp_api_key': {'read_only': True}, # Only set via generate-api-key endpoint } def __init__(self, *args, **kwargs): diff --git a/backend/igny8_core/business/integration/services/integration_service.py b/backend/igny8_core/business/integration/services/integration_service.py index 0094a78c..36ab2e1c 100644 --- a/backend/igny8_core/business/integration/services/integration_service.py +++ b/backend/igny8_core/business/integration/services/integration_service.py @@ -217,6 +217,7 @@ class IntegrationService: dict: Connection test result with detailed health status """ import requests + from django.utils import timezone config = integration.config_json @@ -324,13 +325,6 @@ class IntegrationService: health_checks['plugin_has_api_key'] ) - # Save site_url to config if successful and not already set - if is_healthy and not config.get('site_url'): - config['site_url'] = site_url - integration.config_json = config - integration.save(update_fields=['config_json']) - logger.info(f"[IntegrationService] Saved site_url to integration {integration.id} config: {site_url}") - # Build response message if is_healthy: message = "βœ… WordPress integration is connected and authenticated via API key" @@ -347,6 +341,28 @@ class IntegrationService: else: message = "❌ WordPress connection failed" + # Update integration status based on connection test result + if is_healthy: + integration.sync_status = 'success' + integration.sync_error = None + integration.last_sync_at = timezone.now() + logger.info(f"[IntegrationService] Connection test passed, set sync_status to 'success' for integration {integration.id}") + else: + integration.sync_status = 'failed' + integration.sync_error = message + logger.warning(f"[IntegrationService] Connection test failed, set sync_status to 'failed' for integration {integration.id}") + + # Save site_url to config if successful and not already set + if is_healthy and not config.get('site_url'): + config['site_url'] = site_url + integration.config_json = config + + # Save all changes to integration + integration.save() + + if is_healthy and not config.get('site_url'): + logger.info(f"[IntegrationService] Saved site_url to integration {integration.id} config: {site_url}") + return { 'success': is_healthy, 'fully_functional': is_healthy, diff --git a/backend/igny8_core/business/publishing/services/publisher_service.py b/backend/igny8_core/business/publishing/services/publisher_service.py index fcb1217b..4d14902a 100644 --- a/backend/igny8_core/business/publishing/services/publisher_service.py +++ b/backend/igny8_core/business/publishing/services/publisher_service.py @@ -127,33 +127,22 @@ class PublisherService: # Get destination config destination_config = {} - # If content has site, try to get integration config + # Get WordPress config directly from Site model (no SiteIntegration needed) if hasattr(content, 'site') and content.site: - from igny8_core.business.integration.models import SiteIntegration - logger.info(f"[PublisherService._publish_to_destination] πŸ” Looking for integration: site={content.site.name}, platform={destination}") - integration = SiteIntegration.objects.filter( - site=content.site, - platform=destination, - is_active=True - ).first() + site = content.site + logger.info(f"[PublisherService._publish_to_destination] πŸ” Getting config from site: {site.name}") - if integration: - logger.info(f"[PublisherService._publish_to_destination] βœ… Integration found: id={integration.id}") - # Merge config_json (site_url, etc.) - destination_config.update(integration.config_json or {}) - - # API key is stored in Site.wp_api_key (SINGLE source of truth) - if integration.site.wp_api_key: - destination_config['api_key'] = integration.site.wp_api_key - - logger.info(f"[PublisherService._publish_to_destination] πŸ”‘ Config merged: has_api_key={bool(destination_config.get('api_key'))}, has_site_url={bool(destination_config.get('site_url'))}") - - # Ensure site_url is set (from config or from site model) - if not destination_config.get('site_url'): - destination_config['site_url'] = content.site.url - logger.info(f"[PublisherService._publish_to_destination] 🌐 Using site.url: {content.site.url}") + # API key is stored in Site.wp_api_key (SINGLE source of truth) + if site.wp_api_key: + destination_config['api_key'] = site.wp_api_key + logger.info(f"[PublisherService._publish_to_destination] πŸ”‘ API key found on site") else: - logger.warning(f"[PublisherService._publish_to_destination] ⚠️ No integration found for site={content.site.name}, platform={destination}") + logger.error(f"[PublisherService._publish_to_destination] ❌ No API key found on site {site.name}") + raise ValueError(f"WordPress API key not configured for site {site.name}. Please generate an API key in Site Settings.") + + # Use Site.domain as site_url + destination_config['site_url'] = site.domain or site.url + logger.info(f"[PublisherService._publish_to_destination] 🌐 Using site URL: {destination_config['site_url']}") # Publish via adapter logger.info(f"[PublisherService._publish_to_destination] πŸš€ Calling adapter.publish() with config keys: {list(destination_config.keys())}") diff --git a/backend/igny8_core/modules/integration/views.py b/backend/igny8_core/modules/integration/views.py index d8829229..4caa86e3 100644 --- a/backend/igny8_core/modules/integration/views.py +++ b/backend/igny8_core/modules/integration/views.py @@ -12,6 +12,7 @@ from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove from igny8_core.api.response import success_response, error_response from igny8_core.api.throttles import DebugScopedRateThrottle +from igny8_core.auth.models import Site from igny8_core.business.integration.models import SiteIntegration from igny8_core.business.integration.services.integration_service import IntegrationService from igny8_core.business.integration.services.sync_service import SyncService @@ -131,19 +132,23 @@ class IntegrationViewSet(SiteSectorModelViewSet): permission_classes=[AllowAny], throttle_classes=[NoThrottle]) def test_connection_collection(self, request): """ - Collection-level test connection endpoint for frontend convenience. + Test WordPress connection using Site.wp_api_key (single source of truth). + POST /api/v1/integration/integrations/test-connection/ Body: { - "site_id": 123, - "api_key": "...", - "site_url": "https://example.com" + "site_id": 123 } + + Tests: + 1. WordPress site is reachable + 2. IGNY8 plugin is installed + 3. Plugin has API key configured (matching Site.wp_api_key) """ + import requests as http_requests + site_id = request.data.get('site_id') - api_key = request.data.get('api_key') - site_url = request.data.get('site_url') if not site_id: return error_response('site_id is required', None, status.HTTP_400_BAD_REQUEST, request) @@ -155,80 +160,146 @@ class IntegrationViewSet(SiteSectorModelViewSet): except (Site.DoesNotExist, ValueError, TypeError): return error_response('Site not found or invalid', None, status.HTTP_404_NOT_FOUND, request) - # Authentication: accept either authenticated user OR matching API key in body - api_key = request.data.get('api_key') or api_key - authenticated = False - # If request has a valid user and belongs to same account, allow - if hasattr(request, 'user') and getattr(request.user, 'is_authenticated', False): - try: - # If user has account, ensure site belongs to user's account - if site.account == request.user.account: - authenticated = True - except Exception: - # Ignore and fallback to api_key check - pass + # Authentication: user must be authenticated and belong to same account + if not hasattr(request, 'user') or not getattr(request.user, 'is_authenticated', False): + return error_response('Authentication required', None, status.HTTP_403_FORBIDDEN, request) + + try: + if site.account != request.user.account: + return error_response('Site does not belong to your account', None, status.HTTP_403_FORBIDDEN, request) + except Exception: + return error_response('Authentication failed', None, status.HTTP_403_FORBIDDEN, request) - # If not authenticated via session, allow if provided api_key matches site's stored wp_api_key - if not authenticated: - stored_key = getattr(site, 'wp_api_key', None) - if stored_key and api_key and str(api_key) == str(stored_key): - authenticated = True - elif not stored_key: - # API key not set on site - provide helpful error message - return error_response( - 'API key not configured for this site. Please generate an API key in the IGNY8 app and ensure it is saved to the site.', - None, - status.HTTP_403_FORBIDDEN, - request - ) - elif api_key and stored_key and str(api_key) != str(stored_key): - # API key provided but doesn't match - return error_response( - 'Invalid API key. The provided API key does not match the one stored for this site.', - None, - status.HTTP_403_FORBIDDEN, - request - ) - - if not authenticated: - return error_response('Authentication credentials were not provided.', None, status.HTTP_403_FORBIDDEN, request) - - # Try to find an existing integration for this site+platform - integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first() - - # If not found, create and save the integration to database (for status tracking, not credentials) - integration_created = False - if not integration: - integration = SiteIntegration.objects.create( - account=site.account, - site=site, - platform='wordpress', - platform_type='cms', - config_json={'site_url': site_url} if site_url else {}, - credentials_json={}, # API key is stored in Site.wp_api_key, not here - is_active=True, - sync_enabled=True + # Get stored API key from Site model (single source of truth) + stored_api_key = site.wp_api_key + if not stored_api_key: + return error_response( + 'API key not configured. Please generate an API key first.', + None, + status.HTTP_400_BAD_REQUEST, + request ) - integration_created = True - logger.info(f"[IntegrationViewSet] Created WordPress integration {integration.id} for site {site.id}") - service = IntegrationService() - # Mark this as initial connection test since API key was provided in request body - # This allows the test to pass even if WordPress plugin hasn't stored the key yet - is_initial_connection = bool(api_key and request.data.get('api_key')) - result = service._test_wordpress_connection(integration, is_initial_connection=is_initial_connection) + # Get site URL + site_url = site.domain or site.url + if not site_url: + return error_response( + 'Site URL not configured', + None, + status.HTTP_400_BAD_REQUEST, + request + ) - if result.get('success'): - # Include integration_id in response so plugin can store it - result['integration_id'] = integration.id - result['integration_created'] = integration_created - return success_response(result, request=request) - else: - # If test failed and we just created integration, delete it - if integration_created: - integration.delete() - logger.info(f"[IntegrationViewSet] Deleted integration {integration.id} due to failed connection test") - return error_response(result.get('message', 'Connection test failed'), None, status.HTTP_400_BAD_REQUEST, request) + # Health check results + health_checks = { + 'site_url_configured': True, + 'api_key_configured': True, + 'wp_rest_api_reachable': False, + 'plugin_installed': False, + 'plugin_has_api_key': False, + 'api_key_verified': False, # NEW: Verifies IGNY8 and WordPress have SAME key + } + issues = [] + + try: + # Check 1: WordPress REST API reachable + try: + rest_response = http_requests.get( + f"{site_url.rstrip('/')}/wp-json/", + timeout=10 + ) + if rest_response.status_code == 200: + health_checks['wp_rest_api_reachable'] = True + else: + issues.append(f"WordPress REST API not reachable: HTTP {rest_response.status_code}") + except Exception as e: + issues.append(f"WordPress REST API unreachable: {str(e)}") + + # Check 2: IGNY8 Plugin installed (public status endpoint) + try: + status_response = http_requests.get( + f"{site_url.rstrip('/')}/wp-json/igny8/v1/status", + timeout=10 + ) + if status_response.status_code == 200: + health_checks['plugin_installed'] = True + + status_data = status_response.json() + plugin_data = status_data.get('data', status_data) + + if plugin_data.get('connected') or plugin_data.get('has_api_key'): + health_checks['plugin_has_api_key'] = True + else: + issues.append("Plugin installed but no API key configured in WordPress") + else: + issues.append(f"IGNY8 plugin not found: HTTP {status_response.status_code}") + except Exception as e: + issues.append(f"Cannot detect IGNY8 plugin: {str(e)}") + + # Check 3: Verify API keys MATCH by making authenticated request + # This is the CRITICAL check - WordPress must accept our API key + if health_checks['plugin_installed'] and health_checks['plugin_has_api_key']: + try: + # Make authenticated request using Site.wp_api_key to dedicated verify endpoint + verify_response = http_requests.get( + f"{site_url.rstrip('/')}/wp-json/igny8/v1/verify-key", + headers={ + 'X-IGNY8-API-KEY': stored_api_key, + 'Content-Type': 'application/json' + }, + timeout=10 + ) + if verify_response.status_code == 200: + health_checks['api_key_verified'] = True + elif verify_response.status_code in [401, 403]: + issues.append("API key mismatch - WordPress has different key than IGNY8. Please copy the API key from IGNY8 to WordPress plugin settings.") + else: + issues.append(f"API key verification failed: HTTP {verify_response.status_code}") + except Exception as e: + issues.append(f"API key verification request failed: {str(e)}") + + # Determine overall status - MUST include api_key_verified for true connection + is_healthy = ( + health_checks['api_key_configured'] and + health_checks['wp_rest_api_reachable'] and + health_checks['plugin_installed'] and + health_checks['plugin_has_api_key'] and + health_checks['api_key_verified'] # CRITICAL: keys must match + ) + + # Build message with clear guidance + if is_healthy: + message = "βœ… WordPress integration is fully connected and verified" + elif not health_checks['wp_rest_api_reachable']: + message = "❌ Cannot reach WordPress site" + elif not health_checks['plugin_installed']: + message = "⚠️ WordPress is reachable but IGNY8 plugin not installed" + elif not health_checks['plugin_has_api_key']: + message = "⚠️ Plugin installed but no API key configured in WordPress" + elif not health_checks['api_key_verified']: + message = "⚠️ API key mismatch - copy the API key from IGNY8 to WordPress plugin" + else: + message = "❌ WordPress connection failed" + + return success_response({ + 'success': is_healthy, + 'message': message, + 'site_id': site.id, + 'site_name': site.name, + 'site_url': site_url, + 'api_key_configured': bool(stored_api_key), + 'health_checks': health_checks, + 'issues': issues if issues else None, + }, request=request) + + except Exception as e: + logger.error(f"WordPress connection test failed: {e}") + return error_response( + f'Connection test failed: {str(e)}', + None, + status.HTTP_500_INTERNAL_SERVER_ERROR, + request + ) @extend_schema(tags=['Integration']) @action(detail=True, methods=['post']) @@ -808,42 +879,71 @@ class IntegrationViewSet(SiteSectorModelViewSet): site.wp_api_key = api_key site.save(update_fields=['wp_api_key']) - # Get or create SiteIntegration (for integration status/config, NOT credentials) - integration, created = SiteIntegration.objects.get_or_create( - site=site, - platform='wordpress', - defaults={ - 'account': site.account, - 'platform': 'wordpress', - 'platform_type': 'cms', - 'is_active': True, - 'sync_enabled': True, - 'credentials_json': {}, # Empty - API key is on Site model - 'config_json': {} - } - ) - - # If integration already exists, just ensure it's active - if not created: - integration.is_active = True - integration.sync_enabled = True - # Clear any old credentials_json API key (migrate to Site.wp_api_key) - if integration.credentials_json.get('api_key'): - integration.credentials_json = {} - integration.save() - logger.info( f"Generated new API key for site {site.name} (ID: {site_id}), " f"stored in Site.wp_api_key (single source of truth)" ) - # Serialize the integration with the new key - serializer = self.get_serializer(integration) + return success_response({ + 'api_key': api_key, + 'site_id': site.id, + 'site_name': site.name, + 'site_url': site.domain or site.url, + 'message': 'API key generated successfully. WordPress integration is ready.', + }, request=request) + + @action(detail=False, methods=['post'], url_path='revoke-api-key') + def revoke_api_key(self, request): + """ + Revoke (delete) the API key for a site's WordPress integration. + + POST /api/v1/integration/integrations/revoke-api-key/ + + Body: + { + "site_id": 5 + } + """ + site_id = request.data.get('site_id') + if not site_id: + return error_response( + 'Site ID is required', + None, + status.HTTP_400_BAD_REQUEST, + request + ) + + try: + site = Site.objects.get(id=site_id) + except Site.DoesNotExist: + return error_response( + f'Site with ID {site_id} not found', + None, + status.HTTP_404_NOT_FOUND, + request + ) + + # Verify user has access to this site + if site.account != request.user.account: + return error_response( + 'You do not have permission to modify this site', + None, + status.HTTP_403_FORBIDDEN, + request + ) + + # SINGLE SOURCE OF TRUTH: Remove API key from Site.wp_api_key + site.wp_api_key = None + site.save(update_fields=['wp_api_key']) + + logger.info( + f"Revoked API key for site {site.name} (ID: {site_id})" + ) return success_response({ - 'integration': serializer.data, - 'api_key': api_key, - 'message': f"API key {'generated' if created else 'regenerated'} successfully", + 'site_id': site.id, + 'site_name': site.name, + 'message': 'API key revoked successfully. WordPress integration is now disconnected.', }, request=request) diff --git a/backend/igny8_core/modules/writer/admin.py b/backend/igny8_core/modules/writer/admin.py index 28a5b70b..ad155c16 100644 --- a/backend/igny8_core/modules/writer/admin.py +++ b/backend/igny8_core/modules/writer/admin.py @@ -429,11 +429,11 @@ class ContentResource(resources.ModelResource): @admin.register(Content) class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin): resource_class = ContentResource - list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'source', 'status', 'word_count', 'get_taxonomy_count', 'created_at'] - list_filter = ['status', 'content_type', 'content_structure', 'source', 'site', 'sector', 'cluster', 'word_count', 'created_at'] + list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'source', 'status', 'site_status', 'scheduled_publish_at', 'word_count', 'get_taxonomy_count', 'created_at'] + list_filter = ['status', 'site_status', 'content_type', 'content_structure', 'source', 'site', 'sector', 'cluster', 'word_count', 'created_at'] search_fields = ['title', 'content_html', 'external_url', 'meta_title', 'primary_keyword'] ordering = ['-created_at'] - readonly_fields = ['created_at', 'updated_at', 'word_count', 'get_tags_display', 'get_categories_display'] + readonly_fields = ['created_at', 'updated_at', 'word_count', 'site_status_updated_at', 'get_tags_display', 'get_categories_display'] autocomplete_fields = ['cluster', 'site', 'sector'] inlines = [ContentTaxonomyInline] actions = [ @@ -449,6 +449,10 @@ class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin): ('Basic Info', { 'fields': ('title', 'site', 'sector', 'cluster', 'status') }), + ('Publishing Status', { + 'fields': ('site_status', 'scheduled_publish_at', 'site_status_updated_at'), + 'description': 'WordPress/external site publishing status. Managed by automated publishing scheduler.' + }), ('Content Classification', { 'fields': ('content_type', 'content_structure', 'source') }), diff --git a/backend/igny8_core/plugins/migrations/0004_add_wordpress_plugin_data.py b/backend/igny8_core/plugins/migrations/0004_add_wordpress_plugin_data.py new file mode 100644 index 00000000..6970d409 --- /dev/null +++ b/backend/igny8_core/plugins/migrations/0004_add_wordpress_plugin_data.py @@ -0,0 +1,97 @@ +# Generated manually on 2026-01-12 + +from django.db import migrations + + +def add_wordpress_plugin(apps, schema_editor): + """ + Add the IGNY8 WordPress Bridge plugin to the database. + """ + Plugin = apps.get_model('plugins', 'Plugin') + PluginVersion = apps.get_model('plugins', 'PluginVersion') + + # Create or update the WordPress plugin + plugin, created = Plugin.objects.get_or_create( + slug='igny8-wp-bridge', + defaults={ + 'name': 'IGNY8 WordPress Bridge', + 'platform': 'wordpress', + 'description': 'Connect your WordPress site to IGNY8 for AI-powered content publishing, SEO optimization, and seamless automation. Features API key authentication, automated updates, advanced template rendering, and webhook sync.', + 'homepage_url': 'https://igny8.com/docs/wordpress-integration', + 'is_active': True, + } + ) + + if not created: + # Update existing plugin with latest information + plugin.name = 'IGNY8 WordPress Bridge' + plugin.description = 'Connect your WordPress site to IGNY8 for AI-powered content publishing, SEO optimization, and seamless automation. Features API key authentication, automated updates, advanced template rendering, and webhook sync.' + plugin.homepage_url = 'https://igny8.com/docs/wordpress-integration' + plugin.is_active = True + plugin.save() + + # Add current version (1.3.4) if it doesn't exist + version, created = PluginVersion.objects.get_or_create( + plugin=plugin, + version='1.3.4', + defaults={ + 'version_code': 10304, # 1.03.04 + 'status': 'released', + 'changelog': '''## Version 1.3.4 (January 12, 2026) + +### Major Changes +- **API Key Authentication Only**: Removed username/password authentication +- **Simplified Integration**: Single API key for all communication +- **Bearer Token Auth**: Uses `Authorization: Bearer {api_key}` header +- **Webhooks Deprecated**: Removed webhook signature validation + +### Authentication +- API key stored in WordPress options table: `igny8_api_key` +- Accepts both `X-IGNY8-API-KEY` header and `Authorization: Bearer` header +- Single source of truth: Site.wp_api_key in IGNY8 backend + +### Technical Improvements +- Streamlined connection test endpoint +- Improved error handling and validation +- Better health check reporting +- Enhanced auto-update mechanism + +### Backward Compatibility +- Legacy username/password fields removed +- No migration needed for existing installations +- API key authentication works with all IGNY8 API versions 1.0+ +''', + 'min_api_version': '1.0', + 'min_platform_version': '5.6', + 'min_php_version': '7.4', + 'file_path': 'plugins/wordpress/dist/igny8-wp-bridge-1.3.4.zip', + 'file_size': 0, # Will be updated when file is generated + 'checksum': '', # Will be updated when file is generated + 'force_update': False, + } + ) + + if created: + print(f"βœ… Created WordPress plugin version 1.3.4") + else: + print(f"ℹ️ WordPress plugin version 1.3.4 already exists") + + +def remove_wordpress_plugin(apps, schema_editor): + """ + Reverse migration - remove the WordPress plugin. + This is optional and can be commented out if you want to keep the data. + """ + Plugin = apps.get_model('plugins', 'Plugin') + Plugin.objects.filter(slug='igny8-wp-bridge').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0003_simplify_status_choices'), + ] + + operations = [ + migrations.RunPython(add_wordpress_plugin, remove_wordpress_plugin), + ] diff --git a/backend/igny8_core/tasks/publishing_scheduler.py b/backend/igny8_core/tasks/publishing_scheduler.py index 04d3880b..37e7fdd1 100644 --- a/backend/igny8_core/tasks/publishing_scheduler.py +++ b/backend/igny8_core/tasks/publishing_scheduler.py @@ -255,7 +255,7 @@ def process_scheduled_publications() -> Dict[str, Any]: due_content = Content.objects.filter( site_status='scheduled', scheduled_publish_at__lte=now - ).select_related('site', 'task') + ).select_related('site', 'sector', 'cluster') for content in due_content: results['processed'] += 1 @@ -284,11 +284,9 @@ def process_scheduled_publications() -> Dict[str, Any]: continue # Queue the WordPress publishing task - task_id = content.task_id if hasattr(content, 'task') and content.task else None publish_content_to_wordpress.delay( content_id=content.id, - site_integration_id=site_integration.id, - task_id=task_id + site_integration_id=site_integration.id ) logger.info(f"Queued content {content.id} for WordPress publishing") diff --git a/docs/40-WORKFLOWS/SCHEDULED-CONTENT-PUBLISHING.md b/docs/40-WORKFLOWS/SCHEDULED-CONTENT-PUBLISHING.md new file mode 100644 index 00000000..866e659f --- /dev/null +++ b/docs/40-WORKFLOWS/SCHEDULED-CONTENT-PUBLISHING.md @@ -0,0 +1,371 @@ +# Scheduled Content Publishing Workflow + +**Last Updated:** January 12, 2026 +**Module:** Publishing / Automation + +--- + +## Overview + +IGNY8 provides automated content publishing to WordPress sites. Content goes through a scheduling process before being published at the designated time. + +--- + +## Content Lifecycle for Publishing + +### Understanding Content.status vs Content.site_status + +Content has **TWO separate status fields**: + +1. **`status`** - Editorial workflow status + - `draft` - Being created/edited + - `review` - Submitted for review + - `approved` - Ready for publishing + - `published` - Legacy (not used for external publishing) + +2. **`site_status`** - External site publishing status + - `not_published` - Not yet published to WordPress + - `scheduled` - Has a scheduled_publish_at time + - `publishing` - Currently being published + - `published` - Successfully published to WordPress + - `failed` - Publishing failed + +### Publishing Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ DRAFT β”‚ ← Content is being created/edited +β”‚ status: draft β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ User approves content + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ APPROVED β”‚ ← Content is ready for publishing +β”‚ status: approvedβ”‚ status='approved', site_status='not_published' +β”‚ site_status: β”‚ +β”‚ not_published β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Hourly: schedule_approved_content task + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SCHEDULED β”‚ ← Content has a scheduled_publish_at time +β”‚ status: approvedβ”‚ site_status='scheduled' +β”‚ site_status: β”‚ scheduled_publish_at set to future datetime +β”‚ scheduled β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Every 5 min: process_scheduled_publications task + β”‚ (when scheduled_publish_at <= now) + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ PUBLISHING β”‚ ← WordPress API call in progress +β”‚ status: approvedβ”‚ site_status='publishing' +β”‚ site_status: β”‚ +β”‚ publishing β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β” + β”‚ β”‚ + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚PUBLISHEDβ”‚ β”‚ FAILED β”‚ +β”‚status: β”‚ β”‚status: β”‚ +β”‚approved β”‚ β”‚approvedβ”‚ +β”‚site_ β”‚ β”‚site_ β”‚ +β”‚status: β”‚ β”‚status: β”‚ +β”‚publishedβ”‚ β”‚failed β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Celery Tasks + +### 1. `schedule_approved_content` + +**Schedule:** Every hour at :00 +**Task Name:** `publishing.schedule_approved_content` +**File:** `backend/igny8_core/tasks/publishing_scheduler.py` + +#### What It Does: +1. Finds all sites with `PublishingSettings.auto_publish_enabled = True` +2. Gets approved content (`status='approved'`, `site_status='not_published'`, `scheduled_publish_at=null`) +3. Calculates available publishing slots based on: + - `publish_days` - which days are allowed (e.g., Mon-Fri) + - `publish_time_slots` - which times are allowed (e.g., 09:00, 14:00, 18:00) + - `daily_publish_limit` - max posts per day + - `weekly_publish_limit` - max posts per week + - `monthly_publish_limit` - max posts per month +4. Assigns `scheduled_publish_at` datetime and sets `site_status='scheduled'` + +#### Configuration Location: +`PublishingSettings` model linked to each Site. Configurable via: +- Admin: `/admin/integration/publishingsettings/` +- API: `/api/v1/sites/{site_id}/publishing-settings/` + +--- + +### 2. `process_scheduled_publications` + +**Schedule:** Every 5 minutes +**Task Name:** `publishing.process_scheduled_publications` +**File:** `backend/igny8_core/tasks/publishing_scheduler.py` + +#### What It Does: +1. Finds all content where: + - `site_status='scheduled'` + - `scheduled_publish_at <= now` +2. For each content item: + - Updates `site_status='publishing'` + - Gets the site's WordPress integration + - Queues `publish_content_to_wordpress` Celery task +3. Logs results and any errors + +--- + +### 3. `publish_content_to_wordpress` + +**Type:** On-demand Celery task (queued by `process_scheduled_publications`) +**Task Name:** `publishing.publish_content_to_wordpress` +**File:** `backend/igny8_core/tasks/wordpress_publishing.py` + +#### What It Does: +1. **Load Content & Integration** - Gets content and WordPress credentials +2. **Check Already Published** - Skips if `external_id` exists +3. **Generate Excerpt** - Creates excerpt from HTML content +4. **Get Taxonomy Terms** - Loads categories and tags from `ContentTaxonomy` +5. **Get Images** - Loads featured image and gallery images +6. **Build API Payload** - Constructs WordPress REST API payload +7. **Call WordPress API** - POSTs to WordPress via IGNY8 Bridge plugin +8. **Update Content** - Sets `external_id`, `external_url`, `site_status='published'` +9. **Log Sync Event** - Records in `SyncEvent` model + +#### WordPress Connection: +- Uses the IGNY8 WordPress Bridge plugin installed on the site +- API endpoint: `{site_url}/wp-json/igny8-bridge/v1/publish` +- Authentication: API key stored in `Site.wp_api_key` + +--- + +## Database Models + +### Content Fields (Publishing Related) + +| Field | Type | Description | +|-------|------|-------------| +| `status` | CharField | **Editorial workflow**: `draft`, `review`, `approved` | +| `site_status` | CharField | **WordPress publishing status**: `not_published`, `scheduled`, `publishing`, `published`, `failed` | +| `site_status_updated_at` | DateTimeField | When site_status was last changed | +| `scheduled_publish_at` | DateTimeField | When content should be published (null if not scheduled) | +| `external_id` | CharField | WordPress post ID after publishing | +| `external_url` | URLField | WordPress post URL after publishing | + +**Important:** These are separate concerns: +- `status` tracks editorial approval +- `site_status` tracks external publishing +- Content typically has `status='approved'` AND `site_status='not_published'` before scheduling + +### PublishingSettings Fields + +| Field | Type | Description | +|-------|------|-------------| +| `site` | ForeignKey | The site these settings apply to | +| `auto_publish_enabled` | BooleanField | Whether automatic scheduling is enabled | +| `publish_days` | JSONField | List of allowed days: `['mon', 'tue', 'wed', 'thu', 'fri']` | +| `publish_time_slots` | JSONField | List of times: `['09:00', '14:00', '18:00']` | +| `daily_publish_limit` | IntegerField | Max posts per day (null = unlimited) | +| `weekly_publish_limit` | IntegerField | Max posts per week (null = unlimited) | +| `monthly_publish_limit` | IntegerField | Max posts per month (null = unlimited) | + +--- + +## Celery Beat Schedule + +From `backend/igny8_core/celery.py`: + +```python +app.conf.beat_schedule = { + # ... + 'schedule-approved-content': { + 'task': 'publishing.schedule_approved_content', + 'schedule': crontab(minute=0), # Every hour at :00 + }, + 'process-scheduled-publications': { + 'task': 'publishing.process_scheduled_publications', + 'schedule': crontab(minute='*/5'), # Every 5 minutes + }, + # ... +} +``` + +--- + +## Manual Publishing + +Content can also be published immediately via: + +### API Endpoint +``` +POST /api/v1/content/{content_id}/publish/ +``` + +### Admin Action +In Django Admin, select content and use "Publish to WordPress" action. + +--- + +## Monitoring & Debugging + +### Log Files +- **Publish Logs:** `backend/logs/publish-sync-logs/` +- **API Logs:** `backend/logs/wordpress_api.log` + +### Check Celery Status +```bash +docker compose -f docker-compose.app.yml -p igny8-app logs igny8_celery_worker +docker compose -f docker-compose.app.yml -p igny8-app logs igny8_celery_beat +``` + +### Check Scheduled Content +```python +# Django shell +from igny8_core.business.content.models import Content +from django.utils import timezone + +# Past due content (should have been published) +Content.objects.filter( + site_status='scheduled', + scheduled_publish_at__lt=timezone.now() +).count() + +# Upcoming scheduled content +Content.objects.filter( + site_status='scheduled', + scheduled_publish_at__gt=timezone.now() +).order_by('scheduled_publish_at')[:10] +``` + +### Manual Task Execution +```python +# Django shell +from igny8_core.tasks.publishing_scheduler import ( + schedule_approved_content, + process_scheduled_publications +) + +# Run scheduling task +schedule_approved_content() + +# Process due publications +process_scheduled_publications() +``` + +--- + +## Error Handling + +### Common Failure Reasons + +| Error | Cause | Solution | +|-------|-------|----------| +| No active WordPress integration | Site doesn't have WordPress connected | Configure integration in Site settings | +| API key invalid/expired | WordPress API key issue | Regenerate API key in WordPress plugin | +| Connection timeout | WordPress site unreachable | Check site availability | +| Plugin not active | IGNY8 Bridge plugin disabled | Enable plugin in WordPress | +| Content already published | Duplicate publish attempt | Check `external_id` field | + +### Retry Policy +- `publish_content_to_wordpress` has `max_retries=3` +- Automatic retry on transient failures +- Failed content marked with `site_status='failed'` + +--- + +## Troubleshooting + +### Content Not Being Scheduled + +1. Check `PublishingSettings.auto_publish_enabled` is `True` +2. Verify content has `status='approved'` and `site_status='not_published'` +3. Check `scheduled_publish_at` is null (already scheduled content won't reschedule) +4. Verify publish limits haven't been reached + +### Content Not Publishing + +1. Check Celery Beat is running: `docker compose logs igny8_celery_beat` +2. Check Celery Worker is running: `docker compose logs igny8_celery_worker` +3. Look for errors in worker logs +4. Verify WordPress integration is active +5. Test WordPress API connectivity + +### Resetting Failed Content + +```python +# Reset failed content to try again +from igny8_core.business.content.models import Content + +Content.objects.filter(site_status='failed').update( + site_status='not_published', + scheduled_publish_at=None +) +``` + +--- + +## Architecture Diagram + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ IGNY8 Backend β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Celery Beat β”‚ β”‚ Celery Worker β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Sends tasks at │───▢│ Executes tasks β”‚ β”‚ +β”‚ β”‚ scheduled times β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Publishing Tasks β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ 1. schedule_approved_content (hourly) β”‚ β”‚ +β”‚ β”‚ - Find approved content β”‚ β”‚ +β”‚ β”‚ - Calculate publish slots β”‚ β”‚ +β”‚ β”‚ - Set scheduled_publish_at β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ 2. process_scheduled_publications (every 5 min) β”‚ β”‚ +β”‚ β”‚ - Find due content β”‚ β”‚ +β”‚ β”‚ - Queue publish_content_to_wordpress β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ 3. publish_content_to_wordpress β”‚ β”‚ +β”‚ β”‚ - Build API payload β”‚ β”‚ +β”‚ β”‚ - Call WordPress REST API β”‚ β”‚ +β”‚ β”‚ - Update content status β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό HTTPS +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ WordPress Site β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ IGNY8 Bridge Plugin β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ /wp-json/igny8-bridge/v1/publish β”‚ β”‚ +β”‚ β”‚ - Receives content payload β”‚ β”‚ +β”‚ β”‚ - Creates/updates WordPress post β”‚ β”‚ +β”‚ β”‚ - Handles images, categories, tags β”‚ β”‚ +β”‚ β”‚ - Returns post ID and URL β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Related Documentation + +- [Publisher Module](../10-MODULES/PUBLISHER.md) +- [WordPress Integration](../60-PLUGINS/WORDPRESS-INTEGRATION.md) +- [Content Pipeline](CONTENT-PIPELINE.md) diff --git a/docs/50-DEPLOYMENT/WORDPRESS-INTEGRATION-FLOW.md b/docs/50-DEPLOYMENT/WORDPRESS-INTEGRATION-FLOW.md index a2a88910..322efd43 100644 --- a/docs/50-DEPLOYMENT/WORDPRESS-INTEGRATION-FLOW.md +++ b/docs/50-DEPLOYMENT/WORDPRESS-INTEGRATION-FLOW.md @@ -28,9 +28,10 @@ IGNY8 integrates with WordPress sites through a **custom WordPress plugin** (`igny8-wp-bridge`) that: - Receives content from IGNY8 via a custom REST endpoint (`/wp-json/igny8/v1/publish`) - Sends status updates back to IGNY8 via webhooks -- Authenticates using API keys stored in both systems +- **Authenticates using API key ONLY** (stored in Site.wp_api_key - single source of truth) - Auto-updates via IGNY8 plugin distribution system (v1.7.0+) - Supports advanced template rendering with image layouts +- **No WordPress admin credentials required** (username/password authentication deprecated) ### Communication Pattern @@ -108,19 +109,22 @@ IGNY8 App ←→ WordPress Site **Step 2: User clicks "Generate API Key"** - Frontend calls: `POST /v1/integration/integrations/generate-api-key/` - Body: `{ "site_id": 123 }` -- Backend creates/updates `SiteIntegration` record with new API key +- Backend stores API key in `Site.wp_api_key` field (SINGLE source of truth) +- Creates/updates `SiteIntegration` record with empty credentials_json **Step 3: User configures WordPress plugin** - Configures plugin with: - IGNY8 API URL: `https://api.igny8.com` - Site API Key: (copied from IGNY8) - Site ID: (shown in IGNY8) +- **Note:** No WordPress admin credentials needed **Step 4: Test Connection** -- User clicks "Test Connection" in either app -- IGNY8 calls: `GET {wordpress_url}/wp-json/wp/v2/users/me` -- Uses API key in `X-IGNY8-API-KEY` header -- Success: Connection verified, `is_active` set to true, plugin registers installation +- Plugin calls: `POST https://api.igny8.com/api/v1/integration/integrations/test-connection/` +- Headers: `Authorization: Bearer {api_key}` +- Body: `{ "site_id": 123, "api_key": "...", "site_url": "https://..." }` +- Backend validates API key against `Site.wp_api_key` +- Success: SiteIntegration created with empty credentials_json, plugin registers installation - Failure: Error message displayed ### 2.4 Data Created During Setup @@ -137,14 +141,100 @@ IGNY8 App ←→ WordPress Site "site_url": "https://example.com" }, "credentials_json": { - "api_key": "igny8_xxxxxxxxxxxxxxxxxxxx" + "plugin_version": "1.3.4", + "debug_enabled": false }, "is_active": true, "sync_enabled": true, - "sync_status": "pending" + "sync_status": "pending", + "_note": "API key stored in Site.wp_api_key, not in credentials_json" } ``` +**Site Model (API Key Storage):** +```json +{ + "id": 123, + "name": "Example Site", + "url": "https://example.com", + "wp_api_key": "igny8_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "hosting_type": "wordpress" +} +``` + +--- + +## 2.5 Authentication Architecture (v1.3.4+) + +### API Key as Single Source of Truth + +**Storage:** +- API key stored in `Site.wp_api_key` field (Django backend) +- Plugin stores same API key in WordPress options table: `igny8_api_key` +- SiteIntegration.credentials_json does NOT contain API key + +**Authentication Flow (IGNY8 β†’ WordPress):** +```python +# Backend: publisher_service.py +destination_config = { + 'site_url': integration.config_json.get('site_url'), + 'api_key': integration.site.wp_api_key # From Site model +} + +# WordPress Adapter: wordpress_adapter.py +headers = { + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json' +} +requests.post(f"{site_url}/wp-json/igny8/v1/publish", headers=headers, json=payload) +``` + +**Authentication Flow (WordPress β†’ IGNY8):** +```php +// Plugin: class-igny8-api.php +$api_key = get_option('igny8_api_key'); +$headers = array( + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json' +); +wp_remote_post('https://api.igny8.com/api/v1/...', array('headers' => $headers)); +``` + +**Validation (WordPress Side):** +```php +// Plugin: class-igny8-rest-api.php +public function check_permission($request) { + // Check X-IGNY8-API-KEY header + $header_api_key = $request->get_header('x-igny8-api-key'); + + // Check Authorization Bearer header + $auth_header = $request->get_header('Authorization'); + + $stored_api_key = get_option('igny8_api_key'); + + if (hash_equals($stored_api_key, $header_api_key) || + strpos($auth_header, 'Bearer ' . $stored_api_key) !== false) { + return true; + } + + return new WP_Error('rest_forbidden', 'Invalid API key', array('status' => 401)); +} +``` + +### Deprecated Authentication Methods + +**No Longer Supported (removed in v1.3.4):** +- ❌ Username/password authentication +- ❌ App passwords via WordPress REST API +- ❌ OAuth/token exchange +- ❌ Webhook signature validation (webhooks deprecated) +- ❌ Storing API key in SiteIntegration.credentials_json + +**Legacy Fields (do not use):** +- `Site.wp_username` - deprecated +- `Site.wp_app_password` - deprecated +- `Site.wp_url` - deprecated (use SiteIntegration.config_json.site_url) + --- ## 3. Manual Publishing Flow @@ -415,18 +505,119 @@ Refreshes understanding of WordPress site: |-------|------|---------| | id | AutoField | Primary key | | account | FK(Account) | Owner account | -| site | FK(Site) | IGNY8 site | +| site | FK(Site) | IGNY8 site (contains wp_api_key) | | platform | CharField | 'wordpress' | | platform_type | CharField | 'cms' | | config_json | JSONField | `{ "site_url": "https://..." }` | -| credentials_json | JSONField | `{ "api_key": "igny8_xxx" }` | +| credentials_json | JSONField | `{ "plugin_version": "1.3.4", "debug_enabled": false }` | | is_active | Boolean | Connection enabled | | sync_enabled | Boolean | Two-way sync enabled | | last_sync_at | DateTime | Last successful sync | | sync_status | CharField | pending/success/failed/syncing | | sync_error | TextField | Last error message | -### 7.2 SyncEvent +**Note:** `credentials_json` no longer stores API key. API key is stored in `Site.wp_api_key` (single source of truth). + +### 7.1a Site Model (API Key Storage) + +| Field | Type | Purpose | +|-------|------|---------|-------| +| id | AutoField | Primary key | +| account | FK(Account) | Owner account | +| name | CharField | Site display name | +| url | URLField | WordPress site URL | +| wp_api_key | CharField | **API key for WordPress integration (SINGLE source of truth)** | +| wp_url | URLField | Legacy field (deprecated) | +| wp_username | CharField | Legacy field (deprecated) | +| wp_app_password | CharField | Legacy field (deprecated) | +| hosting_type | CharField | 'wordpress', 'shopify', 'igny8_sites', 'multi' | + +### 7.2 Plugin Models + +#### Plugin + +Core plugin registry (platform-agnostic). + +| Field | Type | Purpose | +|-------|------|---------|-------| +| id | AutoField | Primary key | +| name | CharField | Plugin display name (e.g., "IGNY8 WordPress Bridge") | +| slug | SlugField | URL-safe identifier (e.g., "igny8-wp-bridge") | +| platform | CharField | Target platform ('wordpress', 'shopify', etc.) | +| description | TextField | Plugin description | +| author | CharField | Plugin author | +| author_url | URLField | Author website | +| plugin_url | URLField | Plugin homepage | +| icon_url | URLField | Plugin icon (256x256) | +| banner_url | URLField | Plugin banner (772x250) | +| is_active | Boolean | Whether plugin is available for download | +| created_at | DateTime | Record creation | +| updated_at | DateTime | Last modified | + +**Current WordPress Plugin:** +- Name: "IGNY8 WordPress Bridge" +- Slug: "igny8-wp-bridge" +- Platform: "wordpress" +- Description: "Connect your WordPress site to IGNY8 for AI-powered content publishing, SEO optimization, and seamless automation." +- Author: "IGNY8 Team" + +#### PluginVersion + +Version tracking with distribution files. + +| Field | Type | Purpose | +|-------|------|---------|-------| +| id | AutoField | Primary key | +| plugin | FK(Plugin) | Parent plugin | +| version | CharField | Semantic version (e.g., "1.3.4") | +| status | CharField | 'development', 'beta', 'released', 'deprecated' | +| release_notes | TextField | Changelog/release notes | +| file_path | CharField | Path to ZIP file in /plugins/{platform}/dist/ | +| file_size | BigInteger | ZIP file size in bytes | +| checksum_md5 | CharField | MD5 hash for verification | +| checksum_sha256 | CharField | SHA256 hash for verification | +| requires_version | CharField | Minimum platform version (e.g., WP 5.6+) | +| tested_version | CharField | Tested up to version | +| is_latest | Boolean | Whether this is the latest stable version | +| download_count | Integer | Number of downloads | +| released_at | DateTime | Public release date | +| created_at | DateTime | Record creation | + +**Current Latest Version (1.3.4):** +- Status: "released" +- File: `/plugins/wordpress/dist/igny8-wp-bridge-1.3.4.zip` +- Requires: WordPress 5.6+ +- Tested: WordPress 6.4 +- Features: API key authentication only, template improvements, image layout fixes + +#### PluginInstallation + +Tracks plugin installations per site. + +| Field | Type | Purpose | +|-------|------|---------|-------| +| id | AutoField | Primary key | +| site | FK(Site) | Site where plugin is installed | +| plugin_version | FK(PluginVersion) | Installed version | +| installed_at | DateTime | Installation timestamp | +| last_seen | DateTime | Last health check | +| status | CharField | 'active', 'inactive', 'error' | +| metadata | JSONField | Installation-specific data (PHP version, WP version, etc.) | + +#### PluginDownload + +Download analytics. + +| Field | Type | Purpose | +|-------|------|---------|-------| +| id | AutoField | Primary key | +| plugin_version | FK(PluginVersion) | Downloaded version | +| site | FK(Site, null=True) | Site that downloaded (if authenticated) | +| ip_address | GenericIPAddressField | Downloader IP | +| user_agent | TextField | Browser/client info | +| downloaded_at | DateTime | Download timestamp | + +### 7.3 SyncEvent | Field | Type | Purpose | |-------|------|---------| @@ -540,8 +731,7 @@ POST /api/plugins/igny8-wp-bridge/health-check/ - Health monitoring ### 8.3 Version History (Recent) | Version | Date | Changes | -|---------|------|---------| -| 1.3.3 | Jan 10, 2026 | Template design: Square image grid fixes, landscape positioning, direct styling for images without captions | +|---------|------|---------|| 1.3.4 | Jan 12, 2026 | **API key authentication only** (removed username/password support), webhooks deprecated, Bearer token auth, simplified integration || 1.3.3 | Jan 10, 2026 | Template design: Square image grid fixes, landscape positioning, direct styling for images without captions | | 1.3.2 | Jan 9, 2026 | Template rendering improvements, image layout enhancements | | 1.3.1 | Jan 9, 2026 | Plugin versioning updates | | 1.3.0 | Jan 8, 2026 | Distribution system release, auto-update mechanism | @@ -616,42 +806,69 @@ POST /api/plugins/igny8-wp-bridge/health-check/ - Health monitoring ## 10. Flow Diagrams -### 9.1 Integration Setup +### 10.1 Integration Setup (API Key Authentication) ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ User β”‚ β”‚ IGNY8 App β”‚ β”‚ WordPress β”‚ -β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β”‚ β”‚ - β”‚ 1. Open Site Settings β”‚ - β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚ β”‚ - β”‚ β”‚ β”‚ - β”‚ 2. Download Plugin β”‚ - β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚ β”‚ - β”‚ β”‚ β”‚ - β”‚<────────────────── β”‚ - β”‚ 3. Plugin ZIP β”‚ β”‚ - β”‚ β”‚ β”‚ - β”‚ 4. Install Plugin──────────────────────┼──────────> - β”‚ β”‚ β”‚ - β”‚ 5. Generate API Key β”‚ - β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚ β”‚ - β”‚<────────────────── β”‚ - β”‚ 6. Display API Key β”‚ - β”‚ β”‚ β”‚ - β”‚ 7. Enter API Key in Plugin─────────────┼──────────> - β”‚ β”‚ β”‚ - β”‚ 8. Test Connection β”‚ - β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚ β”‚ - β”‚ β”‚ 9. GET /wp-json/... β”‚ - β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚ - β”‚ β”‚<───────────────────── - β”‚<────────────────── 10. Success β”‚ - β”‚ β”‚ β”‚ - β”‚ β”‚ 11. Register Installβ”‚ - β”‚ β”‚<───────────────────── +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ User β”‚ β”‚ IGNY8 API β”‚ β”‚ WordPress β”‚ +β”‚ β”‚ β”‚ (Backend) β”‚ β”‚ Site β”‚ +β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β”‚ 1. Generate API Key (Site Settings) β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚ β”‚ + β”‚ β”‚ Store in Site.wp_api_key + β”‚<──────────────────── (SINGLE source) β”‚ + β”‚ 2. API Key: igny8_live_xxxxx β”‚ + β”‚ β”‚ β”‚ + β”‚ 3. Download Plugin ZIP β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚ β”‚ + β”‚ β”‚ GET /api/plugins/ β”‚ + β”‚ β”‚ igny8-wp-bridge/ β”‚ + β”‚ β”‚ download/ β”‚ + β”‚<──────────────────── β”‚ + β”‚ 4. igny8-wp-bridge-1.3.4.zip β”‚ + β”‚ β”‚ β”‚ + β”‚ 5. Install & Activate Plugin──────────────┼────────> + β”‚ β”‚ β”‚ + β”‚ 6. Enter API Key + Site ID in WP Settings─┼────────> + β”‚ β”‚ β”‚ + β”‚ 7. Click "Test Connection" in Plugin──────┼────────> + β”‚ β”‚ β”‚ + β”‚ β”‚ 8. POST /api/v1/ β”‚ + β”‚ β”‚ integration/ β”‚ + β”‚ β”‚ integrations/ β”‚ + β”‚ β”‚ test-connection/ β”‚ + β”‚ β”‚<────────────────────── + β”‚ β”‚ Headers: β”‚ + β”‚ β”‚ Authorization: β”‚ + β”‚ β”‚ Bearer {api_key} β”‚ + β”‚ β”‚ β”‚ + β”‚ β”‚ Validate against β”‚ + β”‚ β”‚ Site.wp_api_key β”‚ + β”‚ β”‚ β”‚ + β”‚ β”‚ Create/Update β”‚ + β”‚ β”‚ SiteIntegration β”‚ + β”‚ β”‚ (credentials_json={})β”‚ + β”‚ β”‚ β”‚ + β”‚ β”‚ 9. 200 OK β”‚ + β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚ + β”‚ β”‚ {success: true} β”‚ + β”‚ β”‚ β”‚ + β”‚ 10. Success Message in Plugin─────────────┼────────> + β”‚ β”‚ β”‚ + β”‚ β”‚ 11. POST /register/ β”‚ + β”‚ β”‚<────────────────────── + β”‚ β”‚ Store PluginInstallation + β”‚ β”‚ β”‚ ``` +**Key Changes in v1.3.4:** +- βœ… API key stored in `Site.wp_api_key` (not in SiteIntegration) +- βœ… `credentials_json` is empty (only stores plugin_version, debug_enabled) +- βœ… Authentication via `Authorization: Bearer {api_key}` header +- βœ… No WordPress admin username/password needed +- βœ… Simplified setup - single API key for all communication + ### 10.2 Manual Publishing ``` diff --git a/frontend/src/components/sites/WordPressIntegrationForm.tsx b/frontend/src/components/sites/WordPressIntegrationForm.tsx index 9e80d400..290dc345 100644 --- a/frontend/src/components/sites/WordPressIntegrationForm.tsx +++ b/frontend/src/components/sites/WordPressIntegrationForm.tsx @@ -1,6 +1,6 @@ /** * WordPress Integration Form Component - * Inline form for WordPress integration with API key generation and plugin download + * Simplified - uses only Site.wp_api_key, no SiteIntegration model needed */ import React, { useState, useEffect } from 'react'; import { Card } from '../ui/card'; @@ -11,7 +11,6 @@ import Input from '../form/input/InputField'; import Checkbox from '../form/input/Checkbox'; import Switch from '../form/switch/Switch'; import { useToast } from '../ui/toast/ToastContainer'; -import { integrationApi, SiteIntegration } from '../../services/integration.api'; import { fetchAPI, API_BASE_URL } from '../../services/api'; import { CheckCircleIcon, @@ -28,18 +27,18 @@ import { interface WordPressIntegrationFormProps { siteId: number; - integration: SiteIntegration | null; siteName?: string; siteUrl?: string; - onIntegrationUpdate?: (integration: SiteIntegration) => void; + wpApiKey?: string; // API key from Site.wp_api_key + onApiKeyUpdate?: (apiKey: string | null) => void; } export default function WordPressIntegrationForm({ siteId, - integration, siteName, siteUrl, - onIntegrationUpdate, + wpApiKey, + onApiKeyUpdate, }: WordPressIntegrationFormProps) { const toast = useToast(); const [loading, setLoading] = useState(false); @@ -48,15 +47,20 @@ export default function WordPressIntegrationForm({ const [apiKeyVisible, setApiKeyVisible] = useState(false); const [pluginInfo, setPluginInfo] = useState(null); const [loadingPlugin, setLoadingPlugin] = useState(false); + + // Connection status state + const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'testing' | 'connected' | 'api_key_pending' | 'plugin_missing' | 'error'>('unknown'); + const [connectionMessage, setConnectionMessage] = useState(''); + const [testingConnection, setTestingConnection] = useState(false); - // Load API key from integration on mount or when integration changes + // Load API key from wpApiKey prop (from Site.wp_api_key) on mount or when it changes useEffect(() => { - if (integration?.api_key) { - setApiKey(integration.api_key); + if (wpApiKey) { + setApiKey(wpApiKey); } else { setApiKey(''); } - }, [integration]); + }, [wpApiKey]); // Fetch plugin information useEffect(() => { @@ -75,11 +79,84 @@ export default function WordPressIntegrationForm({ fetchPluginInfo(); }, []); + // Test connection when API key exists + const testConnection = async () => { + if (!apiKey || !siteUrl) { + setConnectionStatus('unknown'); + setConnectionMessage('API key or site URL missing'); + return; + } + + try { + setTestingConnection(true); + setConnectionStatus('testing'); + setConnectionMessage('Testing connection...'); + + // Call backend to test connection to WordPress + // Backend reads API key from Site.wp_api_key (single source of truth) + const response = await fetchAPI('/v1/integration/integrations/test-connection/', { + method: 'POST', + body: JSON.stringify({ + site_id: siteId, + }), + }); + + if (response.success) { + // Check the health checks from response + const healthChecks = response.health_checks || {}; + + // CRITICAL: api_key_verified confirms WordPress accepts our API key + if (healthChecks.api_key_verified) { + setConnectionStatus('connected'); + setConnectionMessage('WordPress is connected and API key verified'); + toast.success('WordPress connection verified!'); + } else if (healthChecks.plugin_has_api_key && !healthChecks.api_key_verified) { + // WordPress has A key, but it's NOT the same as IGNY8's key + setConnectionStatus('api_key_pending'); + setConnectionMessage('API key mismatch - copy the key from IGNY8 to WordPress plugin'); + toast.warning('WordPress has different API key. Please update WordPress with the key shown above.'); + } else if (healthChecks.plugin_installed && !healthChecks.plugin_has_api_key) { + setConnectionStatus('api_key_pending'); + setConnectionMessage('Plugin installed - please add API key in WordPress'); + toast.warning('Plugin found but API key not configured in WordPress'); + } else if (!healthChecks.plugin_installed) { + setConnectionStatus('plugin_missing'); + setConnectionMessage('IGNY8 plugin not installed on WordPress site'); + toast.warning('WordPress site reachable but plugin not found'); + } else { + setConnectionStatus('error'); + setConnectionMessage(response.message || 'Connection verification incomplete'); + toast.error(response.message || 'Connection test incomplete'); + } + } else { + setConnectionStatus('error'); + setConnectionMessage(response.message || 'Connection test failed'); + toast.error(response.message || 'Connection test failed'); + } + } catch (error: any) { + setConnectionStatus('error'); + setConnectionMessage(error.message || 'Connection test failed'); + toast.error(`Connection test failed: ${error.message}`); + } finally { + setTestingConnection(false); + } + }; + + // Auto-test connection when API key changes + useEffect(() => { + if (apiKey && siteUrl) { + testConnection(); + } else { + setConnectionStatus('unknown'); + setConnectionMessage(''); + } + }, [apiKey, siteUrl]); + const handleGenerateApiKey = async () => { try { setGeneratingKey(true); - // Call the new generate-api-key endpoint + // Call the simplified generate-api-key endpoint const response = await fetchAPI('/v1/integration/integrations/generate-api-key/', { method: 'POST', body: JSON.stringify({ site_id: siteId }), @@ -89,9 +166,9 @@ export default function WordPressIntegrationForm({ setApiKey(newKey); setApiKeyVisible(true); - // Trigger integration update - if (onIntegrationUpdate && response.integration) { - onIntegrationUpdate(response.integration); + // Notify parent component + if (onApiKeyUpdate) { + onApiKeyUpdate(newKey); } toast.success('API key generated successfully'); @@ -119,9 +196,9 @@ export default function WordPressIntegrationForm({ setApiKey(newKey); setApiKeyVisible(true); - // Trigger integration update - if (onIntegrationUpdate && response.integration) { - onIntegrationUpdate(response.integration); + // Notify parent component + if (onApiKeyUpdate) { + onApiKeyUpdate(newKey); } toast.success('API key regenerated successfully'); @@ -139,20 +216,20 @@ export default function WordPressIntegrationForm({ try { setGeneratingKey(true); - if (!integration) { - toast.error('No integration found'); - return; - } - - // Delete the integration to revoke the API key - await integrationApi.deleteIntegration(integration.id); + // Revoke API key via dedicated endpoint (single source of truth: Site.wp_api_key) + await fetchAPI('/v1/integration/integrations/revoke-api-key/', { + method: 'POST', + body: JSON.stringify({ site_id: siteId }), + }); setApiKey(''); setApiKeyVisible(false); + setConnectionStatus('unknown'); + setConnectionMessage(''); - // Trigger integration update - if (onIntegrationUpdate) { - onIntegrationUpdate(null as any); + // Notify parent component + if (onApiKeyUpdate) { + onApiKeyUpdate(null); } toast.success('API key revoked successfully'); @@ -183,47 +260,9 @@ export default function WordPressIntegrationForm({ return key.substring(0, 8) + '**********' + key.substring(key.length - 4); }; - // Toggle integration sync enabled status (not creation - that happens automatically) - const [integrationEnabled, setIntegrationEnabled] = useState(integration?.sync_enabled ?? false); - - const handleToggleIntegration = async (enabled: boolean) => { - try { - setIntegrationEnabled(enabled); - - if (integration) { - // Update existing integration - only toggle sync_enabled, not creation - await integrationApi.updateIntegration(integration.id, { - sync_enabled: enabled, - } as any); - toast.success(enabled ? 'Sync enabled' : 'Sync disabled'); - - // Reload integration - const updated = await integrationApi.getWordPressIntegration(siteId); - if (onIntegrationUpdate && updated) { - onIntegrationUpdate(updated); - } - } else { - // Integration doesn't exist - it should be created automatically by plugin - // when user connects from WordPress side - toast.info('Integration will be created automatically when you connect from WordPress plugin. Please connect from the plugin first.'); - setIntegrationEnabled(false); - } - } catch (error: any) { - toast.error(`Failed to update integration: ${error.message}`); - // Revert on error - setIntegrationEnabled(!enabled); - } - }; - - useEffect(() => { - if (integration) { - setIntegrationEnabled(integration.sync_enabled ?? false); - } - }, [integration]); - return (
- {/* Header with Toggle */} + {/* Header */}
@@ -239,13 +278,60 @@ export default function WordPressIntegrationForm({
- {/* Toggle Switch */} + {/* Connection Status */} {apiKey && ( - handleToggleIntegration(checked)} - /> +
+ {/* Status Badge */} +
+ {connectionStatus === 'connected' && ( + <> + Connected + )} + {connectionStatus === 'testing' && ( + <> + Testing... + )} + {connectionStatus === 'api_key_pending' && ( + <> + Pending Setup + )} + {connectionStatus === 'plugin_missing' && ( + <> + Plugin Missing + )} + {connectionStatus === 'error' && ( + <> + Error + )} + {connectionStatus === 'unknown' && ( + <> + Not Tested + )} +
+ + {/* Test Connection Button */} + +
)}
@@ -264,18 +350,9 @@ export default function WordPressIntegrationForm({ onClick={handleGenerateApiKey} variant="solid" disabled={generatingKey} + startIcon={generatingKey ? : } > - {generatingKey ? ( - <> - - Generating... - - ) : ( - <> - - Add API Key - - )} + {generatingKey ? 'Generating...' : 'Add API Key'}
)} @@ -306,6 +383,7 @@ export default function WordPressIntegrationForm({ readOnly type={apiKeyVisible ? 'text' : 'password'} value={apiKeyVisible ? apiKey : maskApiKey(apiKey)} + onChange={() => {}} // No-op to satisfy React /> void; - publishStatusFilter: string; - setPublishStatusFilter: (value: string) => void; + statusFilter: string; + setStatusFilter: (value: string) => void; + siteStatusFilter: string; + setSiteStatusFilter: (value: string) => void; setCurrentPage: (page: number) => void; activeSector: { id: number; name: string } | null; onRowClick?: (row: Content) => void; @@ -97,10 +99,12 @@ export function createApprovedPageConfig(params: { sortable: true, sortField: 'status', render: (value: string, row: Content) => { - // Map internal status to user-friendly labels + // Map internal status to standard labels const statusConfig: Record = { - 'approved': { color: 'blue', label: 'Ready to Publish' }, - 'published': { color: 'success', label: row.external_id ? 'On Site' : 'Approved' }, + 'draft': { color: 'gray', label: 'Draft' }, + 'review': { color: 'amber', label: 'Review' }, + 'approved': { color: 'blue', label: 'Approved' }, + 'published': { color: 'success', label: 'Published' }, }; const config = statusConfig[value] || { color: 'gray' as const, label: value || '-' }; @@ -112,31 +116,21 @@ export function createApprovedPageConfig(params: { }, }, { - key: 'wordpress_status', - label: 'Site Content Status', - sortable: false, - width: '120px', - render: (_value: any, row: Content) => { - // Check if content has been published to WordPress - if (!row.external_id) { - return ( - - Not Published - - ); - } - - // WordPress status badge - use external_status if available, otherwise show 'Published' - const wpStatus = (row as any).wordpress_status || 'publish'; + key: 'site_status', + label: 'Site Status', + sortable: true, + sortField: 'site_status', + width: '130px', + render: (value: string, row: Content) => { + // Show actual site_status field const statusConfig: Record = { - publish: { color: 'success', label: 'Published' }, - draft: { color: 'gray', label: 'Draft' }, - pending: { color: 'amber', label: 'Pending' }, - future: { color: 'blue', label: 'Scheduled' }, - private: { color: 'amber', label: 'Private' }, - trash: { color: 'red', label: 'Trashed' }, + 'not_published': { color: 'gray', label: 'Not Published' }, + 'scheduled': { color: 'amber', label: 'Scheduled' }, + 'publishing': { color: 'amber', label: 'Publishing' }, + 'published': { color: 'success', label: 'Published' }, + 'failed': { color: 'red', label: 'Failed' }, }; - const config = statusConfig[wpStatus] || { color: 'success' as const, label: 'Published' }; + const config = statusConfig[value] || { color: 'gray' as const, label: value || 'Not Published' }; return ( @@ -145,6 +139,28 @@ export function createApprovedPageConfig(params: { ); }, }, + { + key: 'scheduled_publish_at', + label: 'Publish Date', + sortable: true, + sortField: 'scheduled_publish_at', + date: true, + width: '150px', + render: (value: string, row: Content) => { + if (!value) { + return Not scheduled; + } + const publishDate = new Date(value); + const now = new Date(); + const isFuture = publishDate > now; + + return ( + + {formatRelativeDate(value)} + + ); + }, + }, { key: 'content_type', @@ -283,13 +299,46 @@ export function createApprovedPageConfig(params: { placeholder: 'Search approved content...', }, { - key: 'publishStatus', + key: 'status', + label: 'Status', + type: 'select', + options: [ + { value: '', label: 'All' }, + { value: 'draft', label: 'Draft' }, + { value: 'review', label: 'Review' }, + { value: 'approved', label: 'Approved' }, + { value: 'published', label: 'Published' }, + ], + }, + { + key: 'site_status', label: 'Site Status', type: 'select', options: [ { value: '', label: 'All' }, - { value: 'published', label: 'Published to Site' }, { value: 'not_published', label: 'Not Published' }, + { value: 'scheduled', label: 'Scheduled' }, + { value: 'publishing', label: 'Publishing' }, + { value: 'published', label: 'Published' }, + { value: 'failed', label: 'Failed' }, + ], + }, + { + key: 'content_type', + label: 'Type', + type: 'select', + options: [ + { value: '', label: 'All Types' }, + ...CONTENT_TYPE_OPTIONS, + ], + }, + { + key: 'content_structure', + label: 'Structure', + type: 'select', + options: [ + { value: '', label: 'All Structures' }, + ...ALL_CONTENT_STRUCTURES, ], }, ]; diff --git a/frontend/src/config/pages/images.config.tsx b/frontend/src/config/pages/images.config.tsx index 1b5bdeb9..f4a368f3 100644 --- a/frontend/src/config/pages/images.config.tsx +++ b/frontend/src/config/pages/images.config.tsx @@ -188,9 +188,21 @@ export const createImagesPageConfig = ( type: 'text', placeholder: 'Search by content title...', }, + { + key: 'content_status', + label: 'Content Status', + type: 'select', + options: [ + { value: '', label: 'All' }, + { value: 'draft', label: 'Draft' }, + { value: 'review', label: 'Review' }, + { value: 'approved', label: 'Approved' }, + { value: 'published', label: 'Published' }, + ], + }, { key: 'status', - label: 'Status', + label: 'Image Status', type: 'select', options: [ { value: '', label: 'All Status' }, diff --git a/frontend/src/config/pages/review.config.tsx b/frontend/src/config/pages/review.config.tsx index a524550e..f6e882cd 100644 --- a/frontend/src/config/pages/review.config.tsx +++ b/frontend/src/config/pages/review.config.tsx @@ -7,7 +7,7 @@ import { Content } from '../../services/api'; import Badge from '../../components/ui/badge/Badge'; import { formatRelativeDate } from '../../utils/date'; import { CheckCircleIcon } from '../../icons'; -import { STRUCTURE_LABELS, TYPE_LABELS } from '../structureMapping'; +import { STRUCTURE_LABELS, TYPE_LABELS, CONTENT_TYPE_OPTIONS, ALL_CONTENT_STRUCTURES } from '../structureMapping'; export interface ColumnConfig { key: string; @@ -256,6 +256,49 @@ export function createReviewPageConfig(params: { type: 'text', placeholder: 'Search content...', }, + { + key: 'status', + label: 'Status', + type: 'select', + options: [ + { value: '', label: 'All' }, + { value: 'draft', label: 'Draft' }, + { value: 'review', label: 'Review' }, + { value: 'approved', label: 'Approved' }, + { value: 'published', label: 'Published' }, + ], + }, + { + key: 'site_status', + label: 'Site Status', + type: 'select', + options: [ + { value: '', label: 'All' }, + { value: 'not_published', label: 'Not Published' }, + { value: 'scheduled', label: 'Scheduled' }, + { value: 'publishing', label: 'Publishing' }, + { value: 'published', label: 'Published' }, + { value: 'failed', label: 'Failed' }, + ], + }, + { + key: 'content_type', + label: 'Type', + type: 'select', + options: [ + { value: '', label: 'All Types' }, + ...CONTENT_TYPE_OPTIONS, + ], + }, + { + key: 'content_structure', + label: 'Structure', + type: 'select', + options: [ + { value: '', label: 'All Structures' }, + ...ALL_CONTENT_STRUCTURES, + ], + }, ], headerMetrics: [ { diff --git a/frontend/src/pages/Sites/Settings.tsx b/frontend/src/pages/Sites/Settings.tsx index 0ed26fc0..1267b261 100644 --- a/frontend/src/pages/Sites/Settings.tsx +++ b/frontend/src/pages/Sites/Settings.tsx @@ -29,7 +29,6 @@ import { } from '../../services/api'; import { useSiteStore } from '../../store/siteStore'; import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm'; -import { integrationApi, SiteIntegration } from '../../services/integration.api'; import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon, ChevronDownIcon, CloseIcon, PlusIcon, RefreshCwIcon, FileTextIcon, ImageIcon, SaveIcon, Loader2Icon, ArrowRightIcon, SettingsIcon, GlobeIcon, LayersIcon, CheckCircleIcon, CalendarIcon, InfoIcon } from '../../icons'; import Badge from '../../components/ui/badge/Badge'; import { Dropdown } from '../../components/ui/dropdown/Dropdown'; @@ -45,8 +44,6 @@ export default function SiteSettings() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [site, setSite] = useState(null); - const [wordPressIntegration, setWordPressIntegration] = useState(null); - const [integrationLoading, setIntegrationLoading] = useState(false); // Site selector state const [sites, setSites] = useState([]); @@ -134,12 +131,10 @@ export default function SiteSettings() { useEffect(() => { if (siteId) { // Clear state when site changes - setWordPressIntegration(null); setSite(null); // Load new site data loadSite(); - loadIntegrations(); loadIndustries(); } }, [siteId]); @@ -248,17 +243,10 @@ export default function SiteSettings() { } }; - const loadIntegrations = async () => { - if (!siteId) return; - try { - setIntegrationLoading(true); - const integration = await integrationApi.getWordPressIntegration(Number(siteId)); - setWordPressIntegration(integration); - } catch (error: any) { - // Integration might not exist, that's okay - setWordPressIntegration(null); - } finally { - setIntegrationLoading(false); + const handleApiKeyUpdate = (newApiKey: string | null) => { + // Update site state with new API key + if (site) { + setSite({ ...site, wp_api_key: newApiKey }); } }; @@ -495,11 +483,6 @@ export default function SiteSettings() { } }; - const handleIntegrationUpdate = async (integration: SiteIntegration) => { - setWordPressIntegration(integration); - await loadIntegrations(); - }; - const formatRelativeTime = (iso: string | null) => { if (!iso) return '-'; const then = new Date(iso).getTime(); @@ -516,83 +499,56 @@ export default function SiteSettings() { return `${months}mo ago`; }; - // Integration status with authentication check + // Integration status - tracks actual connection state const [integrationStatus, setIntegrationStatus] = useState<'connected' | 'configured' | 'not_configured'>('not_configured'); - const [testingAuth, setTestingAuth] = useState(false); - // Check basic configuration - integration must exist in DB and have sync_enabled + // Check integration status based on API key presence (will be updated by WordPressIntegrationForm) useEffect(() => { - const checkStatus = async () => { - // Integration must exist in database and have sync_enabled = true - if (wordPressIntegration && wordPressIntegration.id && wordPressIntegration.sync_enabled) { - setIntegrationStatus('configured'); - // Test authentication - testAuthentication(); - } else { - setIntegrationStatus('not_configured'); - } - }; - checkStatus(); - }, [wordPressIntegration, site]); - - // Auto-refresh integration list periodically to detect plugin-created integrations - useEffect(() => { - const interval = setInterval(() => { - if (!wordPressIntegration) { - loadIntegrations(); - } - }, 5000); // Check every 5 seconds if integration doesn't exist - - return () => clearInterval(interval); - }, [wordPressIntegration]); - - // Test authentication with WordPress API - const testAuthentication = async () => { - if (testingAuth || !wordPressIntegration?.id) return; - - try { - setTestingAuth(true); - const resp = await fetchAPI(`/v1/integration/integrations/${wordPressIntegration.id}/test_connection/`, { - method: 'POST', - body: {} - }); - - if (resp && resp.success) { - setIntegrationStatus('connected'); - } else { - // Keep as 'configured' if auth fails - setIntegrationStatus('configured'); - } - } catch (err) { - // Keep as 'configured' if auth test fails + if (site?.wp_api_key) { + // API key exists - mark as configured (actual connection tested in WordPressIntegrationForm) setIntegrationStatus('configured'); - } finally { - setTestingAuth(false); + } else { + setIntegrationStatus('not_configured'); } - }; + }, [site?.wp_api_key]); - // Sync Now handler extracted + // Sync Now handler - tests actual WordPress connection const [syncLoading, setSyncLoading] = useState(false); const [lastSyncTime, setLastSyncTime] = useState(null); const handleManualSync = async () => { + if (!site?.wp_api_key) { + toast.error('WordPress API key not configured. Please generate an API key first.'); + return; + } setSyncLoading(true); try { - if (wordPressIntegration && wordPressIntegration.id) { - const res = await integrationApi.syncIntegration(wordPressIntegration.id, 'metadata'); - if (res && res.success) { - toast.success('WordPress structure synced successfully'); - if (res.last_sync_at) { - setLastSyncTime(res.last_sync_at); - } - setTimeout(() => loadContentTypes(), 1500); + // Test connection to WordPress using backend test endpoint + // Backend reads API key from Site.wp_api_key (single source of truth) + const res = await fetchAPI('/v1/integration/integrations/test-connection/', { + method: 'POST', + body: JSON.stringify({ + site_id: siteId, + }), + }); + if (res && res.success) { + // Check health checks + const healthChecks = res.health_checks || {}; + + if (healthChecks.plugin_has_api_key) { + setIntegrationStatus('connected'); + toast.success('WordPress connection verified - fully connected!'); + } else if (healthChecks.plugin_installed) { + setIntegrationStatus('configured'); + toast.warning('Plugin found but API key not configured in WordPress'); } else { - toast.error(res?.message || 'Sync failed to start'); + toast.warning('WordPress reachable but IGNY8 plugin not installed'); } + setLastSyncTime(new Date().toISOString()); } else { - toast.error('No integration configured. Please configure WordPress integration first.'); + toast.error(res?.message || 'Connection test failed'); } } catch (err: any) { - toast.error(`Sync failed: ${err?.message || String(err)}`); + toast.error(`Connection test failed: ${err?.message || String(err)}`); } finally { setSyncLoading(false); } @@ -739,7 +695,7 @@ export default function SiteSettings() { /> {integrationStatus === 'connected' && 'Connected'} - {integrationStatus === 'configured' && (testingAuth ? 'Testing...' : 'Configured')} + {integrationStatus === 'configured' && 'Configured'} {integrationStatus === 'not_configured' && 'Not Configured'} @@ -1874,10 +1830,10 @@ export default function SiteSettings() { {activeTab === 'integrations' && siteId && ( )} diff --git a/frontend/src/pages/Writer/Approved.tsx b/frontend/src/pages/Writer/Approved.tsx index 53e5a9f0..77600212 100644 --- a/frontend/src/pages/Writer/Approved.tsx +++ b/frontend/src/pages/Writer/Approved.tsx @@ -13,7 +13,6 @@ import { ContentListResponse, ContentFilters, fetchAPI, - fetchWordPressStatus, deleteContent, bulkDeleteContent, } from '../../services/api'; @@ -46,9 +45,12 @@ export default function Approved() { const [totalPublished, setTotalPublished] = useState(0); const [totalImagesCount, setTotalImagesCount] = useState(0); - // Filter state - default to approved status + // Filter state const [searchTerm, setSearchTerm] = useState(''); - const [publishStatusFilter, setPublishStatusFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); // Status filter (draft/review/approved/published) + const [siteStatusFilter, setSiteStatusFilter] = useState(''); // Site status filter (not_published/scheduled/published/failed) + const [contentTypeFilter, setContentTypeFilter] = useState(''); // Content type filter (post/page/product/taxonomy) + const [contentStructureFilter, setContentStructureFilter] = useState(''); // Content structure filter const [selectedIds, setSelectedIds] = useState([]); // Pagination state @@ -99,7 +101,10 @@ export default function Approved() { const filters: ContentFilters = { ...(searchTerm && { search: searchTerm }), - status__in: 'approved,published', // Both approved and published content + // Default to approved+published if no status filter selected + ...(statusFilter ? { status: statusFilter } : { status__in: 'approved,published' }), + ...(contentTypeFilter && { content_type: contentTypeFilter }), + ...(contentStructureFilter && { content_structure: contentStructureFilter }), page: currentPage, page_size: pageSize, ordering, @@ -107,34 +112,13 @@ export default function Approved() { const data: ContentListResponse = await fetchContent(filters); - // Client-side filter for WordPress publish status if needed + // Client-side filter for site_status if needed (backend may not support this filter yet) let filteredResults = data.results || []; - if (publishStatusFilter === 'published') { - filteredResults = filteredResults.filter(c => c.external_id); - } else if (publishStatusFilter === 'not_published') { - filteredResults = filteredResults.filter(c => !c.external_id); + if (siteStatusFilter) { + filteredResults = filteredResults.filter(c => c.site_status === siteStatusFilter); } - // Fetch WordPress status for published content - const resultsWithWPStatus = await Promise.all( - filteredResults.map(async (content) => { - if (content.external_id) { - try { - const wpStatus = await fetchWordPressStatus(content.id); - return { - ...content, - wordpress_status: wpStatus.wordpress_status, - }; - } catch (error) { - console.warn(`Failed to fetch WP status for content ${content.id}:`, error); - return content; - } - } - return content; - }) - ); - - setContent(resultsWithWPStatus); + setContent(filteredResults); setTotalCount(data.count || 0); setTotalPages(Math.ceil((data.count || 0) / pageSize)); @@ -148,7 +132,7 @@ export default function Approved() { setShowContent(true); setLoading(false); } - }, [currentPage, publishStatusFilter, sortBy, sortDirection, searchTerm, pageSize, toast]); + }, [currentPage, statusFilter, siteStatusFilter, contentTypeFilter, contentStructureFilter, sortBy, sortDirection, searchTerm, pageSize, toast]); useEffect(() => { loadContent(); @@ -326,15 +310,17 @@ export default function Approved() { return createApprovedPageConfig({ searchTerm, setSearchTerm, - publishStatusFilter, - setPublishStatusFilter, + statusFilter, + setStatusFilter, + siteStatusFilter, + setSiteStatusFilter, setCurrentPage, activeSector, onRowClick: (row: Content) => { navigate(`/writer/content/${row.id}`); }, }); - }, [searchTerm, publishStatusFilter, activeSector, navigate]); + }, [searchTerm, statusFilter, siteStatusFilter, contentTypeFilter, contentStructureFilter, activeSector, navigate]); // Calculate header metrics - use totals from API calls (not page data) // This ensures metrics show correct totals across all pages, not just current page @@ -392,7 +378,10 @@ export default function Approved() { filters={pageConfig.filters} filterValues={{ search: searchTerm, - publishStatus: publishStatusFilter, + status: statusFilter, + site_status: siteStatusFilter, + content_type: contentTypeFilter, + content_structure: contentStructureFilter, }} primaryAction={{ label: 'Publish to Site', @@ -403,8 +392,17 @@ export default function Approved() { onFilterChange={(key: string, value: any) => { if (key === 'search') { setSearchTerm(value); - } else if (key === 'publishStatus') { - setPublishStatusFilter(value); + } else if (key === 'status') { + setStatusFilter(value); + setCurrentPage(1); + } else if (key === 'site_status') { + setSiteStatusFilter(value); + setCurrentPage(1); + } else if (key === 'content_type') { + setContentTypeFilter(value); + setCurrentPage(1); + } else if (key === 'content_structure') { + setContentStructureFilter(value); setCurrentPage(1); } }} diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index 0bd140cb..000dec89 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -25,6 +25,33 @@ export function formatRelativeDate(dateString: string | Date): string { const diffTime = today.getTime() - dateOnly.getTime(); const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + // Handle future dates (negative diffDays) + if (diffDays < 0) { + const futureDays = Math.abs(diffDays); + if (futureDays === 1) { + return 'Tomorrow'; + } else if (futureDays < 30) { + return `in ${futureDays} days`; + } else if (futureDays < 365) { + const months = Math.floor(futureDays / 30); + const remainingDays = futureDays % 30; + if (remainingDays === 0) { + return `in ${months} month${months > 1 ? 's' : ''}`; + } else { + return `in ${months} month${months > 1 ? 's' : ''} ${remainingDays} day${remainingDays > 1 ? 's' : ''}`; + } + } else { + const years = Math.floor(futureDays / 365); + const remainingMonths = Math.floor((futureDays % 365) / 30); + if (remainingMonths === 0) { + return `in ${years} year${years > 1 ? 's' : ''}`; + } else { + return `in ${years} year${years > 1 ? 's' : ''} ${remainingMonths} month${remainingMonths > 1 ? 's' : ''}`; + } + } + } + + // Handle past dates (positive diffDays) if (diffDays === 0) { return 'Today'; } else if (diffDays === 1) { diff --git a/plugins/wordpress/source/igny8-wp-bridge/igny8-bridge.php b/plugins/wordpress/source/igny8-wp-bridge/igny8-bridge.php index 011b4de4..6f1e279e 100644 --- a/plugins/wordpress/source/igny8-wp-bridge/igny8-bridge.php +++ b/plugins/wordpress/source/igny8-wp-bridge/igny8-bridge.php @@ -3,7 +3,7 @@ * Plugin Name: IGNY8 WordPress Bridge * Plugin URI: https://igny8.com/igny8-wp-bridge * Description: Lightweight bridge plugin that connects WordPress to IGNY8 API for one-way content publishing. - * Version: 1.3.4 + * Version: 1.3.8 * Author: IGNY8 * Author URI: https://igny8.com/ * License: GPL v2 or later @@ -22,7 +22,7 @@ if (!defined('ABSPATH')) { } // Define plugin constants -define('IGNY8_BRIDGE_VERSION', '1.3.4'); +define('IGNY8_BRIDGE_VERSION', '1.3.8'); define('IGNY8_BRIDGE_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('IGNY8_BRIDGE_PLUGIN_URL', plugin_dir_url(__FILE__)); define('IGNY8_BRIDGE_PLUGIN_FILE', __FILE__); diff --git a/plugins/wordpress/source/igny8-wp-bridge/includes/class-igny8-rest-api.php b/plugins/wordpress/source/igny8-wp-bridge/includes/class-igny8-rest-api.php index e166b4bb..562e2a42 100644 --- a/plugins/wordpress/source/igny8-wp-bridge/includes/class-igny8-rest-api.php +++ b/plugins/wordpress/source/igny8-wp-bridge/includes/class-igny8-rest-api.php @@ -85,6 +85,14 @@ class Igny8RestAPI { 'permission_callback' => '__return_true', // Public endpoint for health checks )); + // API key verification endpoint - requires valid API key in header + // Used by IGNY8 to verify the API keys match + register_rest_route('igny8/v1', '/verify-key', array( + 'methods' => 'GET', + 'callback' => array($this, 'verify_api_key'), + 'permission_callback' => array($this, 'check_permission'), + )); + // Manual publish endpoint - for triggering WordPress publish from IGNY8 // Route: /wp-json/igny8/v1/publish register_rest_route('igny8/v1', '/publish', array( @@ -406,6 +414,28 @@ class Igny8RestAPI { return $this->build_unified_response(true, $data, 'Plugin status retrieved', null, null, 200); } + /** + * GET /verify-key - Verify API key is valid and matches + * This endpoint requires authentication - if we get here, the API key is valid + * + * @param WP_REST_Request $request + * @return WP_REST_Response + */ + public function verify_api_key($request) { + // If we reach here, check_permission passed, meaning API key is valid + $api_key = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_api_key') : get_option('igny8_api_key'); + $site_id = get_option('igny8_site_id', ''); + + $data = array( + 'verified' => true, + 'site_id' => $site_id, + 'plugin_version' => defined('IGNY8_BRIDGE_VERSION') ? IGNY8_BRIDGE_VERSION : '1.0.0', + 'api_key_prefix' => !empty($api_key) ? substr($api_key, 0, 15) . '...' : null, + ); + + return $this->build_unified_response(true, $data, 'API key verified successfully', null, null, 200); + } + /** * GET /site-metadata/ - returns post types, taxonomies and counts in unified format *