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:
logger.info(f"[PublisherService._publish_to_destination] ✅ Integration found: id={integration.id}")
# Merge config_json (site_url, etc.)
destination_config.update(integration.config_json or {})
# API key is stored in Site.wp_api_key (SINGLE source of truth) # API key is stored in Site.wp_api_key (SINGLE source of truth)
if integration.site.wp_api_key: if site.wp_api_key:
destination_config['api_key'] = integration.site.wp_api_key destination_config['api_key'] = site.wp_api_key
logger.info(f"[PublisherService._publish_to_destination] 🔑 API key found on site")
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 user has account, ensure site belongs to user's account if site.account != request.user.account:
if site.account == request.user.account: return error_response('Site does not belong to your account', None, status.HTTP_403_FORBIDDEN, request)
authenticated = True
except Exception: except Exception:
# Ignore and fallback to api_key check return error_response('Authentication failed', None, status.HTTP_403_FORBIDDEN, request)
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):
authenticated = True
elif not stored_key:
# API key not set on site - provide helpful error message
return error_response( return error_response(
'API key not configured for this site. Please generate an API key in the IGNY8 app and ensure it is saved to the site.', 'API key not configured. Please generate an API key first.',
None, None,
status.HTTP_403_FORBIDDEN, status.HTTP_400_BAD_REQUEST,
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 request
) )
if not authenticated: # Get site URL
return error_response('Authentication credentials were not provided.', None, status.HTTP_403_FORBIDDEN, request) site_url = site.domain or site.url
if not site_url:
# Try to find an existing integration for this site+platform return error_response(
integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first() 'Site URL not configured',
None,
# If not found, create and save the integration to database (for status tracking, not credentials) status.HTTP_400_BAD_REQUEST,
integration_created = False request
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() # Health check results
# Mark this as initial connection test since API key was provided in request body health_checks = {
# This allows the test to pass even if WordPress plugin hasn't stored the key yet 'site_url_configured': True,
is_initial_connection = bool(api_key and request.data.get('api_key')) 'api_key_configured': True,
result = service._test_wordpress_connection(integration, is_initial_connection=is_initial_connection) 'wp_rest_api_reachable': False,
'plugin_installed': False,
'plugin_has_api_key': False,
'api_key_verified': False, # NEW: Verifies IGNY8 and WordPress have SAME key
}
issues = []
if result.get('success'): try:
# Include integration_id in response so plugin can store it # Check 1: WordPress REST API reachable
result['integration_id'] = integration.id try:
result['integration_created'] = integration_created rest_response = http_requests.get(
return success_response(result, request=request) f"{site_url.rstrip('/')}/wp-json/",
timeout=10
)
if rest_response.status_code == 200:
health_checks['wp_rest_api_reachable'] = True
else: else:
# If test failed and we just created integration, delete it issues.append(f"WordPress REST API not reachable: HTTP {rest_response.status_code}")
if integration_created: except Exception as e:
integration.delete() issues.append(f"WordPress REST API unreachable: {str(e)}")
logger.info(f"[IntegrationViewSet] Deleted integration {integration.id} due to failed connection test")
return error_response(result.get('message', 'Connection test failed'), None, status.HTTP_400_BAD_REQUEST, request) # 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)
├─────────────────>│ │ ├───────────────────>│
│ │ Store in Site.wp_api_key
│<───────────────────┤ (SINGLE source) │
│ 2. API Key: igny8_live_xxxxx │
│ │ │ │ │ │
2. Download Plugin │ 3. Download Plugin ZIP
├─────────────────>│ │ ├───────────────────>│
│ │ GET /api/plugins/ │
│ │ igny8-wp-bridge/ │
│ │ download/ │
│<───────────────────┤ │
│ 4. igny8-wp-bridge-1.3.4.zip │
│ │ │ │ │ │
<─────────────────┤ │ 5. Install & Activate Plugin──────────────────────>
│ 3. Plugin ZIP │ │
│ │ │ │ │ │
4. Install Plugin──────────────────────┼──────────> 6. Enter API Key + Site ID in WP Settings─┼────────>
│ │ │ │ │ │
5. Generate API Key │ 7. Click "Test Connection" in Plugin──────┼────────>
├─────────────────>│ │
│<─────────────────┤ │
│ 6. Display API Key │
│ │ │ │ │ │
7. Enter API Key in Plugin─────────────┼──────────> │ 8. POST /api/v1/ │
│ │ integration/ │
│ │ integrations/ │
│ │ test-connection/ │
│ │<─────────────────────┤
│ │ Headers: │
│ │ Authorization: │
│ │ Bearer {api_key} │
│ │ │ │ │ │
8. Test Connection │ Validate against
├─────────────────>│ │ │ Site.wp_api_key
│ 9. GET /wp-json/...
├────────────────────> │ Create/Update
│<────────────────────┤ │ SiteIntegration │
<─────────────────┤ 10. Success │ (credentials_json={})
│ │ │
│ │ 9. 200 OK │
│ ├─────────────────────>│
│ │ {success: true} │
│ │ │
│ 10. Success Message in Plugin─────────────┼────────>
│ │ │
│ │ 11. POST /register/ │
│ │<─────────────────────┤
│ │ Store PluginInstallation
│ │ │ │ │ │
│ │ 11. Register Install│
│ │<────────────────────┤
``` ```
**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);
@@ -49,14 +48,19 @@ export default function WordPressIntegrationForm({
const [pluginInfo, setPluginInfo] = useState<any>(null); const [pluginInfo, setPluginInfo] = useState<any>(null);
const [loadingPlugin, setLoadingPlugin] = useState(false); const [loadingPlugin, setLoadingPlugin] = useState(false);
// Load API key from integration on mount or when integration changes // 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 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'); setIntegrationStatus('configured');
// Test authentication
testAuthentication();
} else { } else {
setIntegrationStatus('not_configured'); setIntegrationStatus('not_configured');
} }
}; }, [site?.wp_api_key]);
checkStatus();
}, [wordPressIntegration, site]);
// Auto-refresh integration list periodically to detect plugin-created integrations // Sync Now handler - tests actual WordPress connection
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');
} finally {
setTestingAuth(false);
}
};
// Sync Now handler extracted
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)
const res = await fetchAPI('/v1/integration/integrations/test-connection/', {
method: 'POST',
body: JSON.stringify({
site_id: siteId,
}),
});
if (res && res.success) { if (res && res.success) {
toast.success('WordPress structure synced successfully'); // Check health checks
if (res.last_sync_at) { const healthChecks = res.health_checks || {};
setLastSyncTime(res.last_sync_at);
} if (healthChecks.plugin_has_api_key) {
setTimeout(() => loadContentTypes(), 1500); 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
* *