fixing issues of integration with wordpress plugin

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-12 23:25:47 +00:00
parent ad828a9fcd
commit 5c3aa90e91
18 changed files with 1414 additions and 427 deletions

View File

@@ -78,6 +78,7 @@ class SiteSerializer(serializers.ModelSerializer):
'industry', 'industry_name', 'industry_slug', 'industry', 'industry_name', 'industry_slug',
'is_active', 'status', 'is_active', 'status',
'site_type', 'hosting_type', 'seo_metadata', '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', 'sectors_count', 'active_sectors_count', 'selected_sectors',
'can_add_sectors', 'keywords_count', 'has_integration', 'can_add_sectors', 'keywords_count', 'has_integration',
'created_at', 'updated_at' 'created_at', 'updated_at'
@@ -86,6 +87,7 @@ class SiteSerializer(serializers.ModelSerializer):
# Explicitly specify required fields for clarity # Explicitly specify required fields for clarity
extra_kwargs = { extra_kwargs = {
'industry': {'required': True, 'error_messages': {'required': 'Industry is required when creating a site.'}}, '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): def __init__(self, *args, **kwargs):

View File

@@ -217,6 +217,7 @@ class IntegrationService:
dict: Connection test result with detailed health status dict: Connection test result with detailed health status
""" """
import requests import requests
from django.utils import timezone
config = integration.config_json config = integration.config_json
@@ -324,13 +325,6 @@ class IntegrationService:
health_checks['plugin_has_api_key'] 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 # Build response message
if is_healthy: if is_healthy:
message = "✅ WordPress integration is connected and authenticated via API key" message = "✅ WordPress integration is connected and authenticated via API key"
@@ -347,6 +341,28 @@ class IntegrationService:
else: else:
message = "❌ WordPress connection failed" 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 { return {
'success': is_healthy, 'success': is_healthy,
'fully_functional': is_healthy, 'fully_functional': is_healthy,

View File

@@ -127,33 +127,22 @@ class PublisherService:
# Get destination config # Get destination config
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: if hasattr(content, 'site') and content.site:
from igny8_core.business.integration.models import SiteIntegration site = content.site
logger.info(f"[PublisherService._publish_to_destination] 🔍 Looking for integration: site={content.site.name}, platform={destination}") logger.info(f"[PublisherService._publish_to_destination] 🔍 Getting config from site: {site.name}")
integration = SiteIntegration.objects.filter(
site=content.site,
platform=destination,
is_active=True
).first()
if integration: # API key is stored in Site.wp_api_key (SINGLE source of truth)
logger.info(f"[PublisherService._publish_to_destination] ✅ Integration found: id={integration.id}") if site.wp_api_key:
# Merge config_json (site_url, etc.) destination_config['api_key'] = site.wp_api_key
destination_config.update(integration.config_json or {}) logger.info(f"[PublisherService._publish_to_destination] 🔑 API key found on site")
# 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}")
else: 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 # Publish via adapter
logger.info(f"[PublisherService._publish_to_destination] 🚀 Calling adapter.publish() with config keys: {list(destination_config.keys())}") logger.info(f"[PublisherService._publish_to_destination] 🚀 Calling adapter.publish() with config keys: {list(destination_config.keys())}")

View File

@@ -12,6 +12,7 @@ from igny8_core.api.base import SiteSectorModelViewSet
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
from igny8_core.api.response import success_response, error_response from igny8_core.api.response import success_response, error_response
from igny8_core.api.throttles import DebugScopedRateThrottle 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.models import SiteIntegration
from igny8_core.business.integration.services.integration_service import IntegrationService from igny8_core.business.integration.services.integration_service import IntegrationService
from igny8_core.business.integration.services.sync_service import SyncService from igny8_core.business.integration.services.sync_service import SyncService
@@ -131,19 +132,23 @@ class IntegrationViewSet(SiteSectorModelViewSet):
permission_classes=[AllowAny], throttle_classes=[NoThrottle]) permission_classes=[AllowAny], throttle_classes=[NoThrottle])
def test_connection_collection(self, request): 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/ POST /api/v1/integration/integrations/test-connection/
Body: Body:
{ {
"site_id": 123, "site_id": 123
"api_key": "...",
"site_url": "https://example.com"
} }
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') 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: if not site_id:
return error_response('site_id is required', None, status.HTTP_400_BAD_REQUEST, request) 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): except (Site.DoesNotExist, ValueError, TypeError):
return error_response('Site not found or invalid', None, status.HTTP_404_NOT_FOUND, request) 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 # Authentication: user must be authenticated and belong to same account
api_key = request.data.get('api_key') or api_key if not hasattr(request, 'user') or not getattr(request.user, 'is_authenticated', False):
authenticated = False return error_response('Authentication required', None, status.HTTP_403_FORBIDDEN, request)
# If request has a valid user and belongs to same account, allow
if hasattr(request, 'user') and getattr(request.user, 'is_authenticated', False): try:
try: if site.account != request.user.account:
# If user has account, ensure site belongs to user's account return error_response('Site does not belong to your account', None, status.HTTP_403_FORBIDDEN, request)
if site.account == request.user.account: except Exception:
authenticated = True return error_response('Authentication failed', None, status.HTTP_403_FORBIDDEN, request)
except Exception:
# Ignore and fallback to api_key check
pass
# If not authenticated via session, allow if provided api_key matches site's stored wp_api_key # Get stored API key from Site model (single source of truth)
if not authenticated: stored_api_key = site.wp_api_key
stored_key = getattr(site, 'wp_api_key', None) if not stored_api_key:
if stored_key and api_key and str(api_key) == str(stored_key): return error_response(
authenticated = True 'API key not configured. Please generate an API key first.',
elif not stored_key: None,
# API key not set on site - provide helpful error message status.HTTP_400_BAD_REQUEST,
return error_response( request
'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
) )
integration_created = True
logger.info(f"[IntegrationViewSet] Created WordPress integration {integration.id} for site {site.id}")
service = IntegrationService() # Get site URL
# Mark this as initial connection test since API key was provided in request body site_url = site.domain or site.url
# This allows the test to pass even if WordPress plugin hasn't stored the key yet if not site_url:
is_initial_connection = bool(api_key and request.data.get('api_key')) return error_response(
result = service._test_wordpress_connection(integration, is_initial_connection=is_initial_connection) 'Site URL not configured',
None,
status.HTTP_400_BAD_REQUEST,
request
)
if result.get('success'): # Health check results
# Include integration_id in response so plugin can store it health_checks = {
result['integration_id'] = integration.id 'site_url_configured': True,
result['integration_created'] = integration_created 'api_key_configured': True,
return success_response(result, request=request) 'wp_rest_api_reachable': False,
else: 'plugin_installed': False,
# If test failed and we just created integration, delete it 'plugin_has_api_key': False,
if integration_created: 'api_key_verified': False, # NEW: Verifies IGNY8 and WordPress have SAME key
integration.delete() }
logger.info(f"[IntegrationViewSet] Deleted integration {integration.id} due to failed connection test") issues = []
return error_response(result.get('message', 'Connection test failed'), None, status.HTTP_400_BAD_REQUEST, request)
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']) @extend_schema(tags=['Integration'])
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
@@ -808,42 +879,71 @@ class IntegrationViewSet(SiteSectorModelViewSet):
site.wp_api_key = api_key site.wp_api_key = api_key
site.save(update_fields=['wp_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( logger.info(
f"Generated new API key for site {site.name} (ID: {site_id}), " f"Generated new API key for site {site.name} (ID: {site_id}), "
f"stored in Site.wp_api_key (single source of truth)" f"stored in Site.wp_api_key (single source of truth)"
) )
# Serialize the integration with the new key return success_response({
serializer = self.get_serializer(integration) '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({ return success_response({
'integration': serializer.data, 'site_id': site.id,
'api_key': api_key, 'site_name': site.name,
'message': f"API key {'generated' if created else 'regenerated'} successfully", 'message': 'API key revoked successfully. WordPress integration is now disconnected.',
}, request=request) }, request=request)

View File

@@ -429,11 +429,11 @@ class ContentResource(resources.ModelResource):
@admin.register(Content) @admin.register(Content)
class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin): class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ContentResource resource_class = ContentResource
list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'source', 'status', 'word_count', 'get_taxonomy_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', 'content_type', 'content_structure', 'source', 'site', 'sector', 'cluster', 'word_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'] search_fields = ['title', 'content_html', 'external_url', 'meta_title', 'primary_keyword']
ordering = ['-created_at'] 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'] autocomplete_fields = ['cluster', 'site', 'sector']
inlines = [ContentTaxonomyInline] inlines = [ContentTaxonomyInline]
actions = [ actions = [
@@ -449,6 +449,10 @@ class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
('Basic Info', { ('Basic Info', {
'fields': ('title', 'site', 'sector', 'cluster', 'status') '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', { ('Content Classification', {
'fields': ('content_type', 'content_structure', 'source') 'fields': ('content_type', 'content_structure', 'source')
}), }),

View File

@@ -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),
]

View File

@@ -255,7 +255,7 @@ def process_scheduled_publications() -> Dict[str, Any]:
due_content = Content.objects.filter( due_content = Content.objects.filter(
site_status='scheduled', site_status='scheduled',
scheduled_publish_at__lte=now scheduled_publish_at__lte=now
).select_related('site', 'task') ).select_related('site', 'sector', 'cluster')
for content in due_content: for content in due_content:
results['processed'] += 1 results['processed'] += 1
@@ -284,11 +284,9 @@ def process_scheduled_publications() -> Dict[str, Any]:
continue continue
# Queue the WordPress publishing task # Queue the WordPress publishing task
task_id = content.task_id if hasattr(content, 'task') and content.task else None
publish_content_to_wordpress.delay( publish_content_to_wordpress.delay(
content_id=content.id, content_id=content.id,
site_integration_id=site_integration.id, site_integration_id=site_integration.id
task_id=task_id
) )
logger.info(f"Queued content {content.id} for WordPress publishing") logger.info(f"Queued content {content.id} for WordPress publishing")

View File

@@ -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)

View File

@@ -28,9 +28,10 @@
IGNY8 integrates with WordPress sites through a **custom WordPress plugin** (`igny8-wp-bridge`) that: 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`) - Receives content from IGNY8 via a custom REST endpoint (`/wp-json/igny8/v1/publish`)
- Sends status updates back to IGNY8 via webhooks - 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+) - Auto-updates via IGNY8 plugin distribution system (v1.7.0+)
- Supports advanced template rendering with image layouts - Supports advanced template rendering with image layouts
- **No WordPress admin credentials required** (username/password authentication deprecated)
### Communication Pattern ### Communication Pattern
@@ -108,19 +109,22 @@ IGNY8 App ←→ WordPress Site
**Step 2: User clicks "Generate API Key"** **Step 2: User clicks "Generate API Key"**
- Frontend calls: `POST /v1/integration/integrations/generate-api-key/` - Frontend calls: `POST /v1/integration/integrations/generate-api-key/`
- Body: `{ "site_id": 123 }` - 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** **Step 3: User configures WordPress plugin**
- Configures plugin with: - Configures plugin with:
- IGNY8 API URL: `https://api.igny8.com` - IGNY8 API URL: `https://api.igny8.com`
- Site API Key: (copied from IGNY8) - Site API Key: (copied from IGNY8)
- Site ID: (shown in IGNY8) - Site ID: (shown in IGNY8)
- **Note:** No WordPress admin credentials needed
**Step 4: Test Connection** **Step 4: Test Connection**
- User clicks "Test Connection" in either app - Plugin calls: `POST https://api.igny8.com/api/v1/integration/integrations/test-connection/`
- IGNY8 calls: `GET {wordpress_url}/wp-json/wp/v2/users/me` - Headers: `Authorization: Bearer {api_key}`
- Uses API key in `X-IGNY8-API-KEY` header - Body: `{ "site_id": 123, "api_key": "...", "site_url": "https://..." }`
- Success: Connection verified, `is_active` set to true, plugin registers installation - Backend validates API key against `Site.wp_api_key`
- Success: SiteIntegration created with empty credentials_json, plugin registers installation
- Failure: Error message displayed - Failure: Error message displayed
### 2.4 Data Created During Setup ### 2.4 Data Created During Setup
@@ -137,14 +141,100 @@ IGNY8 App ←→ WordPress Site
"site_url": "https://example.com" "site_url": "https://example.com"
}, },
"credentials_json": { "credentials_json": {
"api_key": "igny8_xxxxxxxxxxxxxxxxxxxx" "plugin_version": "1.3.4",
"debug_enabled": false
}, },
"is_active": true, "is_active": true,
"sync_enabled": 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 ## 3. Manual Publishing Flow
@@ -415,18 +505,119 @@ Refreshes understanding of WordPress site:
|-------|------|---------| |-------|------|---------|
| id | AutoField | Primary key | | id | AutoField | Primary key |
| account | FK(Account) | Owner account | | account | FK(Account) | Owner account |
| site | FK(Site) | IGNY8 site | | site | FK(Site) | IGNY8 site (contains wp_api_key) |
| platform | CharField | 'wordpress' | | platform | CharField | 'wordpress' |
| platform_type | CharField | 'cms' | | platform_type | CharField | 'cms' |
| config_json | JSONField | `{ "site_url": "https://..." }` | | 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 | | is_active | Boolean | Connection enabled |
| sync_enabled | Boolean | Two-way sync enabled | | sync_enabled | Boolean | Two-way sync enabled |
| last_sync_at | DateTime | Last successful sync | | last_sync_at | DateTime | Last successful sync |
| sync_status | CharField | pending/success/failed/syncing | | sync_status | CharField | pending/success/failed/syncing |
| sync_error | TextField | Last error message | | 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 | | Field | Type | Purpose |
|-------|------|---------| |-------|------|---------|
@@ -540,8 +731,7 @@ POST /api/plugins/igny8-wp-bridge/health-check/ - Health monitoring
### 8.3 Version History (Recent) ### 8.3 Version History (Recent)
| Version | Date | Changes | | Version | Date | Changes |
|---------|------|---------| |---------|------|---------|| 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.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.2 | Jan 9, 2026 | Template rendering improvements, image layout enhancements |
| 1.3.1 | Jan 9, 2026 | Plugin versioning updates | | 1.3.1 | Jan 9, 2026 | Plugin versioning updates |
| 1.3.0 | Jan 8, 2026 | Distribution system release, auto-update mechanism | | 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 ## 10. Flow Diagrams
### 9.1 Integration Setup ### 10.1 Integration Setup (API Key Authentication)
``` ```
┌──────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────┐ ┌─────────────────┐ ┌───────────────┐
│ User │ │ IGNY8 App │ │ WordPress │ │ User │ │ IGNY8 API │ │ WordPress │
└────┬─────┘ └──────┬───────┘ └───────┬───────┘ │ │ │ (Backend) │ │ Site │
│ │ │ └────┬─────┘ └────────┬────────┘ └───────┬───────┘
1. Open Site Settings
├─────────────────>│ │ 1. Generate API Key (Site Settings)
│ │ ├───────────────────>│
2. Download Plugin │ │ Store in Site.wp_api_key
─────────────────>│ │<───────────────────┤ (SINGLE source)
2. API Key: igny8_live_xxxxx
<─────────────────┤
│ 3. Plugin ZIP │ 3. Download Plugin ZIP │
│ │ ├───────────────────>│
4. Install Plugin──────────────────────┼──────────> │ GET /api/plugins/ │
│ │ igny8-wp-bridge/
5. Generate API Key download/
─────────────────>│ │<───────────────────
<─────────────────┤ 4. igny8-wp-bridge-1.3.4.zip
6. Display API Key
│ │ 5. Install & Activate Plugin──────────────┼────────>
7. Enter API Key in Plugin─────────────┼──────────> │ │
│ │ 6. Enter API Key + Site ID in WP Settings─┼────────>
8. Test Connection
├─────────────────>│ │ │ 7. Click "Test Connection" in Plugin──────────────>
│ 9. GET /wp-json/...
├────────────────────> │ 8. POST /api/v1/
│<────────────────────┤ │ integration/ │
<─────────────────┤ 10. Success │ integrations/
│ │ test-connection/
│ 11. Register Install│ │<─────────────────────┤
│<────────────────────┤ │ 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 ### 10.2 Manual Publishing
``` ```

View File

@@ -1,6 +1,6 @@
/** /**
* WordPress Integration Form Component * 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 React, { useState, useEffect } from 'react';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
@@ -11,7 +11,6 @@ import Input from '../form/input/InputField';
import Checkbox from '../form/input/Checkbox'; import Checkbox from '../form/input/Checkbox';
import Switch from '../form/switch/Switch'; import Switch from '../form/switch/Switch';
import { useToast } from '../ui/toast/ToastContainer'; import { useToast } from '../ui/toast/ToastContainer';
import { integrationApi, SiteIntegration } from '../../services/integration.api';
import { fetchAPI, API_BASE_URL } from '../../services/api'; import { fetchAPI, API_BASE_URL } from '../../services/api';
import { import {
CheckCircleIcon, CheckCircleIcon,
@@ -28,18 +27,18 @@ import {
interface WordPressIntegrationFormProps { interface WordPressIntegrationFormProps {
siteId: number; siteId: number;
integration: SiteIntegration | null;
siteName?: string; siteName?: string;
siteUrl?: 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({ export default function WordPressIntegrationForm({
siteId, siteId,
integration,
siteName, siteName,
siteUrl, siteUrl,
onIntegrationUpdate, wpApiKey,
onApiKeyUpdate,
}: WordPressIntegrationFormProps) { }: WordPressIntegrationFormProps) {
const toast = useToast(); const toast = useToast();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -48,15 +47,20 @@ export default function WordPressIntegrationForm({
const [apiKeyVisible, setApiKeyVisible] = useState(false); const [apiKeyVisible, setApiKeyVisible] = useState(false);
const [pluginInfo, setPluginInfo] = useState<any>(null); const [pluginInfo, setPluginInfo] = useState<any>(null);
const [loadingPlugin, setLoadingPlugin] = useState(false); 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<string>('');
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(() => { useEffect(() => {
if (integration?.api_key) { if (wpApiKey) {
setApiKey(integration.api_key); setApiKey(wpApiKey);
} else { } else {
setApiKey(''); setApiKey('');
} }
}, [integration]); }, [wpApiKey]);
// Fetch plugin information // Fetch plugin information
useEffect(() => { useEffect(() => {
@@ -75,11 +79,84 @@ export default function WordPressIntegrationForm({
fetchPluginInfo(); 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 () => { const handleGenerateApiKey = async () => {
try { try {
setGeneratingKey(true); 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/', { const response = await fetchAPI('/v1/integration/integrations/generate-api-key/', {
method: 'POST', method: 'POST',
body: JSON.stringify({ site_id: siteId }), body: JSON.stringify({ site_id: siteId }),
@@ -89,9 +166,9 @@ export default function WordPressIntegrationForm({
setApiKey(newKey); setApiKey(newKey);
setApiKeyVisible(true); setApiKeyVisible(true);
// Trigger integration update // Notify parent component
if (onIntegrationUpdate && response.integration) { if (onApiKeyUpdate) {
onIntegrationUpdate(response.integration); onApiKeyUpdate(newKey);
} }
toast.success('API key generated successfully'); toast.success('API key generated successfully');
@@ -119,9 +196,9 @@ export default function WordPressIntegrationForm({
setApiKey(newKey); setApiKey(newKey);
setApiKeyVisible(true); setApiKeyVisible(true);
// Trigger integration update // Notify parent component
if (onIntegrationUpdate && response.integration) { if (onApiKeyUpdate) {
onIntegrationUpdate(response.integration); onApiKeyUpdate(newKey);
} }
toast.success('API key regenerated successfully'); toast.success('API key regenerated successfully');
@@ -139,20 +216,20 @@ export default function WordPressIntegrationForm({
try { try {
setGeneratingKey(true); setGeneratingKey(true);
if (!integration) { // Revoke API key via dedicated endpoint (single source of truth: Site.wp_api_key)
toast.error('No integration found'); await fetchAPI('/v1/integration/integrations/revoke-api-key/', {
return; method: 'POST',
} body: JSON.stringify({ site_id: siteId }),
});
// Delete the integration to revoke the API key
await integrationApi.deleteIntegration(integration.id);
setApiKey(''); setApiKey('');
setApiKeyVisible(false); setApiKeyVisible(false);
setConnectionStatus('unknown');
setConnectionMessage('');
// Trigger integration update // Notify parent component
if (onIntegrationUpdate) { if (onApiKeyUpdate) {
onIntegrationUpdate(null as any); onApiKeyUpdate(null);
} }
toast.success('API key revoked successfully'); toast.success('API key revoked successfully');
@@ -183,47 +260,9 @@ export default function WordPressIntegrationForm({
return key.substring(0, 8) + '**********' + key.substring(key.length - 4); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header with Toggle */} {/* Header */}
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg"> <div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
@@ -239,13 +278,60 @@ export default function WordPressIntegrationForm({
</div> </div>
</div> </div>
{/* Toggle Switch */} {/* Connection Status */}
{apiKey && ( {apiKey && (
<Switch <div className="flex items-center gap-2">
label={integrationEnabled ? 'Sync Enabled' : 'Sync Disabled'} {/* Status Badge */}
checked={integrationEnabled} <div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${
onChange={(checked) => handleToggleIntegration(checked)} connectionStatus === 'connected'
/> ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: connectionStatus === 'testing'
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: connectionStatus === 'api_key_pending'
? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'
: connectionStatus === 'plugin_missing'
? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'
: connectionStatus === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
}`}>
{connectionStatus === 'connected' && (
<><CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-700 dark:text-green-300">Connected</span></>
)}
{connectionStatus === 'testing' && (
<><RefreshCwIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Testing...</span></>
)}
{connectionStatus === 'api_key_pending' && (
<><AlertIcon className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">Pending Setup</span></>
)}
{connectionStatus === 'plugin_missing' && (
<><AlertIcon className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">Plugin Missing</span></>
)}
{connectionStatus === 'error' && (
<><AlertIcon className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-sm font-medium text-red-700 dark:text-red-300">Error</span></>
)}
{connectionStatus === 'unknown' && (
<><InfoIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">Not Tested</span></>
)}
</div>
{/* Test Connection Button */}
<Button
onClick={testConnection}
variant="outline"
size="sm"
disabled={testingConnection || !apiKey}
startIcon={<RefreshCwIcon className={`w-4 h-4 ${testingConnection ? 'animate-spin' : ''}`} />}
>
Test
</Button>
</div>
)} )}
</div> </div>
@@ -264,18 +350,9 @@ export default function WordPressIntegrationForm({
onClick={handleGenerateApiKey} onClick={handleGenerateApiKey}
variant="solid" variant="solid"
disabled={generatingKey} disabled={generatingKey}
startIcon={generatingKey ? <RefreshCwIcon className="w-4 h-4 animate-spin" /> : <PlusIcon className="w-4 h-4" />}
> >
{generatingKey ? ( {generatingKey ? 'Generating...' : 'Add API Key'}
<>
<RefreshCwIcon className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<PlusIcon className="w-4 h-4 mr-2" />
Add API Key
</>
)}
</Button> </Button>
</div> </div>
)} )}
@@ -306,6 +383,7 @@ export default function WordPressIntegrationForm({
readOnly readOnly
type={apiKeyVisible ? 'text' : 'password'} type={apiKeyVisible ? 'text' : 'password'}
value={apiKeyVisible ? apiKey : maskApiKey(apiKey)} value={apiKeyVisible ? apiKey : maskApiKey(apiKey)}
onChange={() => {}} // No-op to satisfy React
/> />
<IconButton <IconButton
onClick={handleCopyApiKey} onClick={handleCopyApiKey}

View File

@@ -7,7 +7,7 @@ import { Content } from '../../services/api';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date'; import { formatRelativeDate } from '../../utils/date';
import { CheckCircleIcon, ArrowRightIcon } from '../../icons'; import { CheckCircleIcon, ArrowRightIcon } 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 { export interface ColumnConfig {
key: string; key: string;
@@ -48,8 +48,10 @@ export interface ApprovedPageConfig {
export function createApprovedPageConfig(params: { export function createApprovedPageConfig(params: {
searchTerm: string; searchTerm: string;
setSearchTerm: (value: string) => void; setSearchTerm: (value: string) => void;
publishStatusFilter: string; statusFilter: string;
setPublishStatusFilter: (value: string) => void; setStatusFilter: (value: string) => void;
siteStatusFilter: string;
setSiteStatusFilter: (value: string) => void;
setCurrentPage: (page: number) => void; setCurrentPage: (page: number) => void;
activeSector: { id: number; name: string } | null; activeSector: { id: number; name: string } | null;
onRowClick?: (row: Content) => void; onRowClick?: (row: Content) => void;
@@ -97,10 +99,12 @@ export function createApprovedPageConfig(params: {
sortable: true, sortable: true,
sortField: 'status', sortField: 'status',
render: (value: string, row: Content) => { render: (value: string, row: Content) => {
// Map internal status to user-friendly labels // Map internal status to standard labels
const statusConfig: Record<string, { color: 'success' | 'blue' | 'amber' | 'gray'; label: string }> = { const statusConfig: Record<string, { color: 'success' | 'blue' | 'amber' | 'gray'; label: string }> = {
'approved': { color: 'blue', label: 'Ready to Publish' }, 'draft': { color: 'gray', label: 'Draft' },
'published': { color: 'success', label: row.external_id ? 'On Site' : 'Approved' }, '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 || '-' }; const config = statusConfig[value] || { color: 'gray' as const, label: value || '-' };
@@ -112,31 +116,21 @@ export function createApprovedPageConfig(params: {
}, },
}, },
{ {
key: 'wordpress_status', key: 'site_status',
label: 'Site Content Status', label: 'Site Status',
sortable: false, sortable: true,
width: '120px', sortField: 'site_status',
render: (_value: any, row: Content) => { width: '130px',
// Check if content has been published to WordPress render: (value: string, row: Content) => {
if (!row.external_id) { // Show actual site_status field
return (
<Badge color="amber" size="xs" variant="soft">
<span className="text-[11px] font-normal">Not Published</span>
</Badge>
);
}
// WordPress status badge - use external_status if available, otherwise show 'Published'
const wpStatus = (row as any).wordpress_status || 'publish';
const statusConfig: Record<string, { color: 'success' | 'amber' | 'blue' | 'gray' | 'red'; label: string }> = { const statusConfig: Record<string, { color: 'success' | 'amber' | 'blue' | 'gray' | 'red'; label: string }> = {
publish: { color: 'success', label: 'Published' }, 'not_published': { color: 'gray', label: 'Not Published' },
draft: { color: 'gray', label: 'Draft' }, 'scheduled': { color: 'amber', label: 'Scheduled' },
pending: { color: 'amber', label: 'Pending' }, 'publishing': { color: 'amber', label: 'Publishing' },
future: { color: 'blue', label: 'Scheduled' }, 'published': { color: 'success', label: 'Published' },
private: { color: 'amber', label: 'Private' }, 'failed': { color: 'red', label: 'Failed' },
trash: { color: 'red', label: 'Trashed' },
}; };
const config = statusConfig[wpStatus] || { color: 'success' as const, label: 'Published' }; const config = statusConfig[value] || { color: 'gray' as const, label: value || 'Not Published' };
return ( return (
<Badge color={config.color} size="xs" variant="soft"> <Badge color={config.color} size="xs" variant="soft">
@@ -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 <span className="text-gray-400 dark:text-gray-500 text-[11px]">Not scheduled</span>;
}
const publishDate = new Date(value);
const now = new Date();
const isFuture = publishDate > now;
return (
<span className={isFuture ? "text-blue-600 dark:text-blue-400 font-medium" : "text-amber-600 dark:text-amber-400 font-medium"}>
{formatRelativeDate(value)}
</span>
);
},
},
{ {
key: 'content_type', key: 'content_type',
@@ -283,13 +299,46 @@ export function createApprovedPageConfig(params: {
placeholder: 'Search approved content...', 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', label: 'Site Status',
type: 'select', type: 'select',
options: [ options: [
{ value: '', label: 'All' }, { value: '', label: 'All' },
{ value: 'published', label: 'Published to Site' },
{ value: 'not_published', label: 'Not Published' }, { 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,
], ],
}, },
]; ];

View File

@@ -188,9 +188,21 @@ export const createImagesPageConfig = (
type: 'text', type: 'text',
placeholder: 'Search by content title...', 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', key: 'status',
label: 'Status', label: 'Image Status',
type: 'select', type: 'select',
options: [ options: [
{ value: '', label: 'All Status' }, { value: '', label: 'All Status' },

View File

@@ -7,7 +7,7 @@ import { Content } from '../../services/api';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date'; import { formatRelativeDate } from '../../utils/date';
import { CheckCircleIcon } from '../../icons'; 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 { export interface ColumnConfig {
key: string; key: string;
@@ -256,6 +256,49 @@ export function createReviewPageConfig(params: {
type: 'text', type: 'text',
placeholder: 'Search content...', 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: [ headerMetrics: [
{ {

View File

@@ -29,7 +29,6 @@ import {
} from '../../services/api'; } from '../../services/api';
import { useSiteStore } from '../../store/siteStore'; import { useSiteStore } from '../../store/siteStore';
import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm'; 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 { 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 Badge from '../../components/ui/badge/Badge';
import { Dropdown } from '../../components/ui/dropdown/Dropdown'; import { Dropdown } from '../../components/ui/dropdown/Dropdown';
@@ -45,8 +44,6 @@ export default function SiteSettings() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [site, setSite] = useState<any>(null); const [site, setSite] = useState<any>(null);
const [wordPressIntegration, setWordPressIntegration] = useState<SiteIntegration | null>(null);
const [integrationLoading, setIntegrationLoading] = useState(false);
// Site selector state // Site selector state
const [sites, setSites] = useState<Site[]>([]); const [sites, setSites] = useState<Site[]>([]);
@@ -134,12 +131,10 @@ export default function SiteSettings() {
useEffect(() => { useEffect(() => {
if (siteId) { if (siteId) {
// Clear state when site changes // Clear state when site changes
setWordPressIntegration(null);
setSite(null); setSite(null);
// Load new site data // Load new site data
loadSite(); loadSite();
loadIntegrations();
loadIndustries(); loadIndustries();
} }
}, [siteId]); }, [siteId]);
@@ -248,17 +243,10 @@ export default function SiteSettings() {
} }
}; };
const loadIntegrations = async () => { const handleApiKeyUpdate = (newApiKey: string | null) => {
if (!siteId) return; // Update site state with new API key
try { if (site) {
setIntegrationLoading(true); setSite({ ...site, wp_api_key: newApiKey });
const integration = await integrationApi.getWordPressIntegration(Number(siteId));
setWordPressIntegration(integration);
} catch (error: any) {
// Integration might not exist, that's okay
setWordPressIntegration(null);
} finally {
setIntegrationLoading(false);
} }
}; };
@@ -495,11 +483,6 @@ export default function SiteSettings() {
} }
}; };
const handleIntegrationUpdate = async (integration: SiteIntegration) => {
setWordPressIntegration(integration);
await loadIntegrations();
};
const formatRelativeTime = (iso: string | null) => { const formatRelativeTime = (iso: string | null) => {
if (!iso) return '-'; if (!iso) return '-';
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
@@ -516,83 +499,56 @@ export default function SiteSettings() {
return `${months}mo ago`; 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 [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(() => { useEffect(() => {
const checkStatus = async () => { if (site?.wp_api_key) {
// Integration must exist in database and have sync_enabled = true // API key exists - mark as configured (actual connection tested in WordPressIntegrationForm)
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
setIntegrationStatus('configured'); setIntegrationStatus('configured');
} finally { } else {
setTestingAuth(false); setIntegrationStatus('not_configured');
} }
}; }, [site?.wp_api_key]);
// Sync Now handler extracted // Sync Now handler - tests actual WordPress connection
const [syncLoading, setSyncLoading] = useState(false); const [syncLoading, setSyncLoading] = useState(false);
const [lastSyncTime, setLastSyncTime] = useState<string | null>(null); const [lastSyncTime, setLastSyncTime] = useState<string | null>(null);
const handleManualSync = async () => { const handleManualSync = async () => {
if (!site?.wp_api_key) {
toast.error('WordPress API key not configured. Please generate an API key first.');
return;
}
setSyncLoading(true); setSyncLoading(true);
try { try {
if (wordPressIntegration && wordPressIntegration.id) { // Test connection to WordPress using backend test endpoint
const res = await integrationApi.syncIntegration(wordPressIntegration.id, 'metadata'); // Backend reads API key from Site.wp_api_key (single source of truth)
if (res && res.success) { const res = await fetchAPI('/v1/integration/integrations/test-connection/', {
toast.success('WordPress structure synced successfully'); method: 'POST',
if (res.last_sync_at) { body: JSON.stringify({
setLastSyncTime(res.last_sync_at); site_id: siteId,
} }),
setTimeout(() => loadContentTypes(), 1500); });
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 { } else {
toast.error(res?.message || 'Sync failed to start'); toast.warning('WordPress reachable but IGNY8 plugin not installed');
} }
setLastSyncTime(new Date().toISOString());
} else { } else {
toast.error('No integration configured. Please configure WordPress integration first.'); toast.error(res?.message || 'Connection test failed');
} }
} catch (err: any) { } catch (err: any) {
toast.error(`Sync failed: ${err?.message || String(err)}`); toast.error(`Connection test failed: ${err?.message || String(err)}`);
} finally { } finally {
setSyncLoading(false); setSyncLoading(false);
} }
@@ -739,7 +695,7 @@ export default function SiteSettings() {
/> />
<span className="text-sm font-medium text-gray-700 dark:text-gray-200"> <span className="text-sm font-medium text-gray-700 dark:text-gray-200">
{integrationStatus === 'connected' && 'Connected'} {integrationStatus === 'connected' && 'Connected'}
{integrationStatus === 'configured' && (testingAuth ? 'Testing...' : 'Configured')} {integrationStatus === 'configured' && 'Configured'}
{integrationStatus === 'not_configured' && 'Not Configured'} {integrationStatus === 'not_configured' && 'Not Configured'}
</span> </span>
</div> </div>
@@ -1874,10 +1830,10 @@ export default function SiteSettings() {
{activeTab === 'integrations' && siteId && ( {activeTab === 'integrations' && siteId && (
<WordPressIntegrationForm <WordPressIntegrationForm
siteId={Number(siteId)} siteId={Number(siteId)}
integration={wordPressIntegration}
siteName={site?.name} siteName={site?.name}
siteUrl={site?.domain || site?.wp_url} siteUrl={site?.domain || site?.wp_url}
onIntegrationUpdate={handleIntegrationUpdate} wpApiKey={site?.wp_api_key}
onApiKeyUpdate={handleApiKeyUpdate}
/> />
)} )}
</div> </div>

View File

@@ -13,7 +13,6 @@ import {
ContentListResponse, ContentListResponse,
ContentFilters, ContentFilters,
fetchAPI, fetchAPI,
fetchWordPressStatus,
deleteContent, deleteContent,
bulkDeleteContent, bulkDeleteContent,
} from '../../services/api'; } from '../../services/api';
@@ -46,9 +45,12 @@ export default function Approved() {
const [totalPublished, setTotalPublished] = useState(0); const [totalPublished, setTotalPublished] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0); const [totalImagesCount, setTotalImagesCount] = useState(0);
// Filter state - default to approved status // Filter state
const [searchTerm, setSearchTerm] = useState(''); 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<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state // Pagination state
@@ -99,7 +101,10 @@ export default function Approved() {
const filters: ContentFilters = { const filters: ContentFilters = {
...(searchTerm && { search: searchTerm }), ...(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: currentPage,
page_size: pageSize, page_size: pageSize,
ordering, ordering,
@@ -107,34 +112,13 @@ export default function Approved() {
const data: ContentListResponse = await fetchContent(filters); 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 || []; let filteredResults = data.results || [];
if (publishStatusFilter === 'published') { if (siteStatusFilter) {
filteredResults = filteredResults.filter(c => c.external_id); filteredResults = filteredResults.filter(c => c.site_status === siteStatusFilter);
} else if (publishStatusFilter === 'not_published') {
filteredResults = filteredResults.filter(c => !c.external_id);
} }
// Fetch WordPress status for published content setContent(filteredResults);
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);
setTotalCount(data.count || 0); setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize)); setTotalPages(Math.ceil((data.count || 0) / pageSize));
@@ -148,7 +132,7 @@ export default function Approved() {
setShowContent(true); setShowContent(true);
setLoading(false); setLoading(false);
} }
}, [currentPage, publishStatusFilter, sortBy, sortDirection, searchTerm, pageSize, toast]); }, [currentPage, statusFilter, siteStatusFilter, contentTypeFilter, contentStructureFilter, sortBy, sortDirection, searchTerm, pageSize, toast]);
useEffect(() => { useEffect(() => {
loadContent(); loadContent();
@@ -326,15 +310,17 @@ export default function Approved() {
return createApprovedPageConfig({ return createApprovedPageConfig({
searchTerm, searchTerm,
setSearchTerm, setSearchTerm,
publishStatusFilter, statusFilter,
setPublishStatusFilter, setStatusFilter,
siteStatusFilter,
setSiteStatusFilter,
setCurrentPage, setCurrentPage,
activeSector, activeSector,
onRowClick: (row: Content) => { onRowClick: (row: Content) => {
navigate(`/writer/content/${row.id}`); 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) // Calculate header metrics - use totals from API calls (not page data)
// This ensures metrics show correct totals across all pages, not just current page // This ensures metrics show correct totals across all pages, not just current page
@@ -392,7 +378,10 @@ export default function Approved() {
filters={pageConfig.filters} filters={pageConfig.filters}
filterValues={{ filterValues={{
search: searchTerm, search: searchTerm,
publishStatus: publishStatusFilter, status: statusFilter,
site_status: siteStatusFilter,
content_type: contentTypeFilter,
content_structure: contentStructureFilter,
}} }}
primaryAction={{ primaryAction={{
label: 'Publish to Site', label: 'Publish to Site',
@@ -403,8 +392,17 @@ export default function Approved() {
onFilterChange={(key: string, value: any) => { onFilterChange={(key: string, value: any) => {
if (key === 'search') { if (key === 'search') {
setSearchTerm(value); setSearchTerm(value);
} else if (key === 'publishStatus') { } else if (key === 'status') {
setPublishStatusFilter(value); 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); setCurrentPage(1);
} }
}} }}

View File

@@ -25,6 +25,33 @@ export function formatRelativeDate(dateString: string | Date): string {
const diffTime = today.getTime() - dateOnly.getTime(); const diffTime = today.getTime() - dateOnly.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); 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) { if (diffDays === 0) {
return 'Today'; return 'Today';
} else if (diffDays === 1) { } else if (diffDays === 1) {

View File

@@ -3,7 +3,7 @@
* Plugin Name: IGNY8 WordPress Bridge * Plugin Name: IGNY8 WordPress Bridge
* Plugin URI: https://igny8.com/igny8-wp-bridge * Plugin URI: https://igny8.com/igny8-wp-bridge
* Description: Lightweight bridge plugin that connects WordPress to IGNY8 API for one-way content publishing. * 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: IGNY8
* Author URI: https://igny8.com/ * Author URI: https://igny8.com/
* License: GPL v2 or later * License: GPL v2 or later
@@ -22,7 +22,7 @@ if (!defined('ABSPATH')) {
} }
// Define plugin constants // 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_DIR', plugin_dir_path(__FILE__));
define('IGNY8_BRIDGE_PLUGIN_URL', plugin_dir_url(__FILE__)); define('IGNY8_BRIDGE_PLUGIN_URL', plugin_dir_url(__FILE__));
define('IGNY8_BRIDGE_PLUGIN_FILE', __FILE__); define('IGNY8_BRIDGE_PLUGIN_FILE', __FILE__);

View File

@@ -85,6 +85,14 @@ class Igny8RestAPI {
'permission_callback' => '__return_true', // Public endpoint for health checks '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 // Manual publish endpoint - for triggering WordPress publish from IGNY8
// Route: /wp-json/igny8/v1/publish // Route: /wp-json/igny8/v1/publish
register_rest_route('igny8/v1', '/publish', array( 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); 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 * GET /site-metadata/ - returns post types, taxonomies and counts in unified format
* *